硬编码的 MD5 密钥,加上没有覆盖 getter 路径的语言级沙箱,组合出默认配置下的未授权 RCE。

Intro
JimuReport 这条Pre-Auth RCE 链路由两个缺陷叠加而成:
- 自动导出接口 /jmreport/auto/export/python/plugin 的签名密钥硬编码在 jar 里,X-Sign 可伪造。
- JimuReport 给 Aviator 做了沙箱,但 5.2.6 的 getter 路径没有类白名单校验:可以先拿到 Feature.StaticMethods 加回白名单,再把 functionMissing 改成反射兜底,最终用 Runtime.exec 执行命令。
| 项目 | 内容 |
|---|---|
| 编号 | QVD-2026-61751 |
| 类型 | 未授权 RCE(签名伪造 + 表达式沙箱逃逸) |
| 影响版本 | JimuReport 2.5.1;2.5.2 的关键类字节码与 2.5.1 完全一致,starter POM 仍锁 aviator.version=5.2.6 |
| 依赖版本 | Aviator < 5.4.0(JimuReport 打包的是 5.2.6);5.4.0 起才在 getter 路径上校验类白名单,且默认配置下 5.4.4 仍可利用 |
| 所需权限 | 无需认证,默认配置 |
| 入口 | POST /jmreport/auto/export/python/plugin |
| 注入点 | 报表参数值以 = 开头,被当作 Aviator 表达式求值 |
| 影响 | 以应用进程权限执行任意命令,完全控制服务器 |
第一步:伪造 X-Sign 绕过认证
接口 /jmreport/auto/export/python/plugin 上打了 @JimuSignature,由 JimuReportSignatureInterceptor 做校验(该类在未开源的 starter jar 里)。核心逻辑如下:
1 | public class JimuReportSignatureInterceptor implements HandlerInterceptor { |
三个致命点:
- 密钥硬编码在 jar 里。6fea20a1940df21797d89f09c9111d56c1fe1fcfbe41a121 是编译进产物的常量,攻击者从安装包里就能提取,从零构造合法签名。
- 签名只覆盖 body。X-Sign = MD5(body + secret).toUpperCase(),X-TIMESTAMP 不参与计算。
- 时间戳只防过期、不防未来。判断是 now - ts > 300000,只拒绝超过 5 分钟的请求;把时间戳设成未来反而能拿到更长的重放窗口。
伪造过程非常直接:
1 | def sign(body: str) -> str: |
重放时只需要把 X-TIMESTAMP 换成当前时间,X-Sign 保持不变(因为 body 没变)。
进入导出流程后,报表参数如果以 = 开头,就会被当作 Aviator 表达式求值。请求体形如:
1 | { |
sex 是官方示例数据预置的参数,默认部署就有。它是这条 demo 报表数据集 s1 声明的参数(底层查 rep_demo_dxtj 表,SQL 里按 sex 过滤),exportType: PDF 触发导出流程,从而走到表达式求值。
第二步:Aviator 沙箱逃逸到 RCE
JimuReport 并不是裸用 Aviator,它在 ExpressUtil 里做了裁剪:
1 | public static void a(AviatorEvaluatorInstance engine) { |
预期的沙箱边界是:不允许 new 任意对象(NewInstance)、不允许 use(…) 导入类(Use)、不允许 load / require 模块(Module)、不允许调用静态方法或访问静态字段(StaticMethods / StaticFields)、类名解析白名单为空(ALLOWED_CLASS_SET = emptySet)。注意 Aviator 里 null 表示允许所有类、空集表示禁止所有类,所以这里是取最严的一档,目标是让表达式彻底碰不到 Java 类。
看起来挺严,但 Aviator 的 Feature 并不是 JVM 级的安全机制,而是在特定语法节点上手工插入的检查。Feature 是 Aviator 定义的语言能力枚举:
1 | public enum Feature { |
它默认是全开的(Feature.getFullFeatures()),执行时靠两处显式检查:
1 | // Parser:针对特定语法节点 |
问题就在于:普通对象的属性访问走的是 getter 路径,这条路径上没有 Feature 检查,也没有类白名单检查。而 Aviator 又把引擎自身的内部变量暴露了出来:
1 | // Env.get() |
InternalVars 默认在 features 里,所以表达式可以匿名读取 __instance__ 与 __env__,沙箱的入口就开了。step1 的完整 payload:
1 | =seq.put(__env__, 'instance', __instance__); |
逐段拆解(ExpressUtil 编译前会替换掉 payload 里所有 =,所以不能用 = 赋值,只能用 seq.put 往 Env 里写变量):
| 片段 | 实际语义 |
|---|---|
| __instance__ / __env__ | Env.get() 的内部变量,分别返回引擎实例和 Env 自身,只需 InternalVars(默认开) |
| seq.put(__env__, ‘instance’, __instance__) | seq.put 是往 Map 里写键值,而 Env 本身就是 Map;这一行把引擎实例注册成变量 instance,后面就能直接写 instance.features。第二行同理把 Env 注册成 env |
| instance.features | getFeatures(),返回引擎内部活的 Set |
| x.declaringClass.enumConstants[16] | x.getDeclaringClass().getEnumConstants()[16] 得到 Feature.StaticMethods。纯 getter 链,不受 StaticMethods / StaticFields 约束 |
| seq.put(instance.funcMap, …) | 借用引擎公开的 getFuncMap()(也是活的 Map)当草稿纸 |
| seq.add(instance.features, …) | 把 StaticMethods 加回引擎白名单 |
| instance.Class 到 c.Class | 两次 getClass(),拿到 java.lang.Class 的 Class 对象 |
| CC.forName(…) | 静态方法已放行,可以加载任意类 |
| RF.setProperty(env, ‘instance.functionMissing’, FM.getInstance()) | 解析到 setFunctionMissing,把引擎的 functionMissing 改成反射兜底实现 |
对照 Aviator 5.2.6 的 Feature 源码,索引 16 是 StaticMethods,也是本链真正必需的核心。这一步的关键在于 getter 路径:x.declaringClass 走 PropertyType.Getter,实际调用 Enum.getDeclaringClass();拿到 Class 对象后,Aviator 把 target.innerClazz 设成它,而 .enumConstants 在 StaticFields 被禁用的情况下仍会落到 Getter 分支,调用 Class.getEnumConstants()。
这两个都是公开实例方法,不经过 StaticMethods / StaticFields 检查,所以攻击者能直接把白名单里的常量读出来再塞回去。反过来,如果 StaticFields 是开着的,这里会走 StaticField 分支去 Feature 类上找名为 enumConstants 的静态字段,找不到直接返回 null 并抛 NPE,链反而断掉——禁用 StaticFields 恰好把它推到了 getter 分支。
接下来把函数调用变成任意实例方法调用。functionMissing 是 Aviator 引擎上的一个回调字段:当函数名在 funcMap 里找不到时,就交给它兜底。
1 | // AviatorEvaluatorInstance |
而 JavaMethodReflectionFunctionMissing 的实现是:把第一个参数当作 this,其余参数当作方法实参,反射调用同名实例方法。
1 | public AviatorObject onFunctionMissing(String name, Map<String, Object> env, AviatorObject... args) { |
一旦 functionMissing 被写成这个实现,exec(r, arr) 就等于反射调用 r.exec(arr),由此获得任意对象任意公开实例方法的原语。注意 functionMissing 不是 Feature,它走的是 invokeInstanceMethod,整条路径上没有任何 Feature 校验,这是与 StaticMethods 白名单完全独立的一块攻击面。另外,RuntimeFunctionDelegator 在编译表达式时就把 functionMissing 捕获为 final 字段,所以必须先发 step1 污染引擎,再发 step2 编译恶意表达式。
顺带说清一点:r.exec(arr) 这种「对象点实例方法」的写法是走不通的。Aviator 5.2.6 的点号链最后一段固定按 Getter 解析(只认 getXxx / isXxx / 字段),PropertyType 枚举本身只有 Getter / StaticField / StaticMethod 三个值,实例方法根本没有对应分支。实测在默认引擎里 l.add(3)、s.length()、System.out.println(…) 全是 Function not found,只有 getter 形式能用。所以 functionMissing 不是一个更省事的替代写法,而是 5.2.6 下调用任意实例方法的唯一入口。
payload 里的 RT.getRuntime()、AY.set(arr, 0, …) 能用点号,是因为接收者是 Class 对象、名字只有两段,走的是 StaticMethod 分支,跟这条路径不是一回事。
step2 的完整 payload:
1 | =seq.put(__env__, 'instance', __instance__); |
关键片段:
- CC.forName(‘java.lang.Runtime’) 加 RT.getRuntime() 拿到 Runtime 实例,用的是 step1 放行的 StaticMethods
- CC.forName(‘java.lang.reflect.Array’) 加 AY.newInstance(SC, 3) 造出一个 String[3]
- AY.set(arr, 0, ‘/bin/sh’) 逐个填元素,填的是 /bin/sh -c command
- exec(r, arr) 走 functionMissing,最终命中 Runtime.exec(String[])
最后:
1 | RT.getRuntime() → Runtime 实例 |
Reflector.invokeMatchingMethod 会在 Runtime 的多个 exec 重载里按参数类型匹配,[String[]] 正好命中 Runtime.exec(String[]),命令随即以应用进程身份执行。
Break & Fix
step1 请求(沙箱逃逸):

step2 请求(命令执行):


Aviator 5.4.0 的 PR #571(fix: check innerClazz while get property,2023-10-03 合并)给这条 getter 路径补上了类白名单校验:把 Env 里原有的检查抽成 AviatorEvaluatorInstance.checkIfClassIsAllowed,再在 Reflector.fastGetProperty 的 target.innerClazz != null 分支加一次调用。

在 Class 对象上取成员时,先校验这个类是否在 ALLOWED_CLASS_SET 里
补丁没有改动 ALLOWED_CLASS_SET 的语义:checkIfClassIsAllowed 里 null 依旧表示放行所有类,只有非 null 的白名单才会拦,所以链断不断取决于宿主怎么配。要注意校验对象是 target.innerClazz,也就是上一段刚解析出来的那个 Class,而不是表达式里的 x:x 只是一个 Feature 实例,x.declaringClass 是普通对象上的 getter,照常通过并返回 Feature.class;紧接着的 .enumConstants 要在这个新拿到的 Class 上取成员,才会撞上白名单。JimuReport 配的是空集,这里直接拒绝,functionMissing 保持 null,链断在第一步:
1 | // 5.4.4 + ALLOWED_CLASS_SET = emptySet() |
反过来,同一份 payload 放到 5.4.4(当前最新发行版,同样带这个补丁)的默认配置(ALLOWED_CLASS_SET = null)下仍然是通的,实测命令照旧落地:
1 | aviator = 5.4.4,allowSet = null |