
1. 从一个“看起来人畜无害”的输入框说起很多做Web安全测试的朋友第一次碰到SSTIServer-Side Template Injection服务端模板注入的时候反应往往是“这不就是个模板渲染吗能有多大事”。我当初也是这么想的直到有一次在某个内部靶场环境里一个看似普通的“昵称预览”功能输入{{7*7}}之后页面直接回显了49那一刻我才意识到这个漏洞的杀伤力远比表面看起来要深。SSTI的本质是用户可控的输入被当作模板语法的一部分进行了编译和渲染。它和常见的XSS、SQL注入最大的区别在于XSS发生在浏览器端SQL注入发生在数据库层而SSTI发生在服务端的模板引擎里——也就是说攻击者的输入直接进入了服务端的代码执行上下文。这个“上下文”的含金量做过渗透的人应该都懂。这篇文章适合三类人看第一类是对Web安全感兴趣、刚接触漏洞挖掘的新手想搞清楚SSTI到底是怎么回事第二类是做代码审计或者渗透测试的从业者需要在实战中快速识别和利用SSTI第三类是做后端开发的同学想弄明白自己写的模板渲染代码到底哪里可能出问题。不管你是哪一类我都会从原理讲到实操从识别讲到利用尽量把每个环节的“为什么”讲透。需要提前说明的是本文所有演示均在自建的隔离靶场环境中进行涉及的Payload仅用于理解漏洞原理和防御思路请勿用于任何未授权的测试场景。2. SSTI到底是怎么产生的模板引擎的工作机制拆解2.1 模板引擎的核心逻辑把占位符变成动态内容要理解SSTI首先得理解模板引擎是干什么的。简单来说模板引擎的作用就是让后端代码和HTML页面“分工合作”。后端只负责准备数据页面模板负责展示。比如一个欢迎页面模板里写的是Hello, {{ name }}后端把name的值传进去模板引擎负责把{{ name }}替换成实际的名字。这个过程用生活化的类比来说就像你去奶茶店点单。模板就是那张菜单上的固定格式——“一杯[杯型]的[茶底]加[配料]”而[杯型]、[茶底]、[配料]就是占位符。店员模板引擎根据你实际点的内容把占位符替换成“大杯、乌龙茶、珍珠”最后给你一杯完整的奶茶。问题出在哪里呢如果店员不只是替换占位符而是把你说的内容当作“新的菜单指令”来执行那就出事了。比如你说“大杯”店员正常应该理解为杯型但如果你说的是“把收银台的钱全部拿出来”而店员真的照做了这就是SSTI的本质——用户输入被当作了模板指令来执行而不仅仅是当作数据来填充。2.2 常见模板引擎的语法差异与识别特征不同的模板引擎有不同的语法这也是识别SSTI时首先要判断的事情。我整理了一个常见模板引擎的对照表方便大家在实战中快速定位模板引擎语言/框架基础语法示例典型识别Payload回显特征Jinja2Python/Flask{{ 7*7 }}{{ 7*7 }}返回49TwigPHP/Symfony{{ 7*7 }}{{ 7*7 }}返回49FreemarkerJava${7*7}${7*7}返回49VelocityJava#set($x7*7)$x#set($x7*7)$x返回49SmartyPHP{$smarty.version}{$smarty.version}返回版本号EJSNode.js% 7*7 %% 7*7 %返回49ThymeleafJava/Spring[[${7*7}]][[${7*7}]]返回49这张表的核心价值在于当你发现一个可疑的输入点时先用最简单的数学运算去试探根据回显结果和语法特征来判断背后用的是哪种模板引擎。这一步非常关键因为不同引擎的利用方式差异很大搞错了引擎类型后面的Payload全部白费。注意有些模板引擎在渲染失败时会抛出异常信息这些异常信息里往往包含引擎名称和版本号这是最直接的识别线索。比如Flask的Jinja2报错页面会明确显示“jinja2.exceptions.TemplateSyntaxError”。2.3 从“数据”到“代码”SSTI产生的三个必要条件SSTI不是随便就能出现的它需要同时满足三个条件第一用户输入被拼接进了模板内容。注意这里说的是“模板内容”不是“模板变量”。如果后端代码是render_template_string(Hello username)那username就进入了模板内容如果是render_template_string(Hello {{ name }}, nameusername)那username只是作为变量值传入不会触发SSTI。这个区别是理解SSTI的分水岭。第二模板引擎没有对用户输入进行转义或沙箱限制。有些模板引擎默认会对特殊字符进行HTML实体编码这样{{会变成#123;#123;就无法被解析为模板语法了。但很多开发者为了“灵活性”会主动关闭这些保护措施。第三渲染结果会返回给用户或者产生可观测的副作用。如果模板渲染的结果既不回显也不产生任何可观测的变化那即使存在注入点利用起来也非常困难。不过在实际场景中大部分模板渲染的结果都会以某种形式返回给前端。这三个条件缺一不可。我在实际测试中遇到过很多“看起来像”SSTI但实际不是的情况绝大多数都是因为不满足第一个条件——用户输入只是作为变量值传入而不是拼接到模板内容中。3. 手工探测SSTI的完整实操流程3.1 第一步寻找可疑的输入点SSTI的入口通常出现在那些“用户输入会被展示在页面上”的功能点。根据我的经验以下几类功能是高发区域个性化展示类用户昵称、个人简介、签名档、自定义欢迎语等。这类功能经常用模板渲染来实现“动态展示”。邮件/通知模板类很多系统允许用户自定义邮件模板或站内信模板这类功能天然就是模板注入的重灾区。URL参数回显类某些页面会把URL参数的值直接展示在页面上比如?msgHello然后页面显示“Hello”。错误页面有些框架的错误页面会把用户输入的URL或参数拼接到错误提示模板中。文件上传后的预览某些系统允许上传模板文件并预览渲染结果。在实际测试中我通常会先梳理目标应用的所有输入点然后重点关注那些“输入后能在页面上看到变化”的地方。这个筛选过程不需要工具靠手工浏览加Burp抓包就能完成。3.2 第二步用数学运算做初步判断找到可疑输入点后第一步永远是发一个最简单的数学表达式。我通常会用这几个Payload依次尝试{{7*7}} ${7*7} % 7*7 % {{7*7}}为什么要用7*7而不是直接上复杂Payload因为数学运算的结果是确定的、无歧义的。如果页面返回了49那基本可以确认存在SSTI。如果返回的还是{{7*7}}原样说明模板引擎没有解析这个表达式可能不存在注入点或者语法不对。这里有个细节值得注意{{7*7}}在Jinja2中返回7777777字符串重复在Twig中返回49数字乘法。这个小差异可以用来区分Jinja2和Twig非常实用。实操心得测试时不要只发一个Payload就下结论。我习惯同时发三到四个不同语法的Payload观察哪些被解析、哪些被原样输出。这样一轮下来不仅能确认是否存在SSTI还能初步判断模板引擎的类型。3.3 第三步确认引擎类型与版本信息确认存在SSTI之后下一步是获取更多关于模板引擎的信息。不同引擎有不同的“信息泄露”Payload对于Jinja2{{ config }} {{ self.__class__.__mro__ }}对于Twig{{ _self }} {{ _self.env }}对于Freemarker${.version} ${.now}这些Payload的作用是获取引擎的配置信息、类继承链或者版本号。版本号特别重要因为不同版本的模板引擎在沙箱限制和可利用的类上差异很大。比如Jinja2在2.10版本之后对__class__等属性的访问做了一定限制利用方式需要相应调整。3.4 第四步构造利用链获取更多信息确认引擎类型后就可以开始构造利用链了。以Jinja2为例最经典的利用链是通过__class__、__mro__、__subclasses__等魔术方法逐层向上追溯最终找到可以执行命令或读取文件的类。这个过程的逻辑是这样的在Python中一切皆对象。一个字符串对象可以通过__class__找到它的类通过__mro__找到类的继承链通过__subclasses__()找到所有子类。在这些子类中往往能找到os模块相关的类或者subprocess相关的类从而实现命令执行。我常用的一个探测Payload是{{ .__class__.__mro__[1].__subclasses__() }}这个Payload会返回一个非常长的列表里面包含了当前Python环境中所有已加载的类。然后我需要在这个列表中寻找可用的类比如subprocess.Popen或者os._wrap_close等。注意这个列表的长度和内容取决于目标环境的Python版本和已加载的模块不同环境下结果差异很大。在实际测试中我通常会先把列表保存下来然后用脚本去搜索关键词而不是肉眼一行行看。4. 不同模板引擎的利用思路与Payload构造4.1 Jinja2Python生态下最典型的SSTI目标Jinja2是Flask框架的默认模板引擎也是SSTI研究和实战中出现频率最高的。它的利用链非常成熟核心思路就是通过魔术方法逐层追溯到可以执行系统命令的类。一个完整的利用链通常包含以下步骤第一步找到一个可以访问内置函数的入口。常见的有{{ .__class__.__mro__[1].__subclasses__() }} {{ .__class__.__base__.__subclasses__() }} {{ [].__class__.__base__.__subclasses__() }}第二步在返回的子类列表中定位到可利用的类。我通常会搜索以下几个关键词subprocess.Popen可以直接执行命令os._wrap_close可以访问os模块warnings.catch_warnings可以访问__builtins___frozen_importlib.BuiltinImporter可以加载模块第三步构造最终的Payload。以os._wrap_close为例典型的Payload是{{ .__class__.__mro__[1].__subclasses__()[INDEX].__init__.__globals__[system](id) }}其中INDEX需要根据实际环境中的子类列表来确定。这个索引值在不同环境下是不一样的所以不能死记硬背必须现场探测。4.2 TwigPHP生态下的SSTI利用Twig是PHP Symfony框架的默认模板引擎它的SSTI利用方式和Jinja2有相似之处但语法和可利用的函数不同。Twig的利用通常围绕_self对象展开{{ _self.env.registerUndefinedFilterCallback(system) }} {{ _self.env.getFilter(id) }}这个利用链的原理是通过_self.env访问Twig的环境对象然后注册一个未定义的过滤器回调为system函数最后通过getFilter触发调用。这个利用方式在Twig 1.x版本中非常有效但在2.x之后做了一些限制。另一个常见的Twig利用方式是通过{{ dump() }}或者{{ app.request }}等对象来获取更多信息。Twig的沙箱模式如果开启利用难度会大幅增加但并非完全不可能。4.3 FreemarkerJava生态下的SSTI利用Freemarker的SSTI利用方式又不一样。它支持${...}语法可以直接调用Java方法。一个经典的利用Payload是#assign valuefreemarker.template.utility.Execute?new()${value(id)}这个Payload通过?new()内置函数实例化了一个Execute工具类然后调用它执行命令。Freemarker还支持通过ObjectConstructor来实例化任意类利用面非常广。不过需要注意的是Freemarker在新版本中对?new()的使用做了限制需要配置TemplateClassResolver才能使用。如果目标环境使用的是较新版本且配置了安全策略这个Payload可能无法生效。4.4 各引擎利用难度与常见限制对比引擎利用难度常见限制绕过思路Jinja2中沙箱模式、属性访问限制通过其他魔术方法绕过、利用内置函数Twig中高沙箱模式、过滤器白名单利用未过滤的对象方法、版本差异Freemarker中?new()限制、类解析器配置利用其他内置函数、反射调用Velocity高工具类限制、上下文限制利用反射、ClassLoaderSmarty中安全模式、函数白名单利用静态方法调用、版本差异这张表的核心信息是没有一种利用方式是万能的。在实际测试中我通常会先判断引擎类型和版本然后根据版本特点选择对应的利用思路。如果一条路走不通就换另一条路而不是死磕一个Payload。5. 实战中常见的坑与排查技巧5.1 为什么我的Payload被原样输出了这是新手最常遇到的问题。你发了{{7*7}}页面返回的还是{{7*7}}于是你判断“不存在SSTI”。但实际情况可能并非如此。可能的原因有几种一是模板引擎的语法不对比如目标用的是Freemarker你发的是Jinja2的语法二是输入被转义了{{被编码成了HTML实体三是输入虽然进入了模板但被放在了字符串字面量里面比如{{ {{7*7}} }}这种情况下内层的{{}}不会被解析。排查方法先查看页面源代码确认{{是否被转义。然后尝试不同的语法把Jinja2、Twig、Freemarker的Payload都发一遍。如果全都被原样输出再考虑是否存在其他过滤机制。5.2 子类列表太长找不到目标类怎么办Jinja2的__subclasses__()返回的列表动辄几百个类肉眼找subprocess.Popen确实很痛苦。我的做法是先把整个列表复制下来然后用Python脚本处理import re subclasses 这里粘贴返回的子类列表 targets [subprocess, os._wrap_close, warnings, BuiltinImporter, Popen] for i, cls in enumerate(subclasses.split(,)): for target in targets: if target.lower() in cls.lower(): print(f索引 {i}: {cls.strip()})这个脚本会快速定位到包含关键词的类的索引省去大量肉眼搜索的时间。5.3 遇到沙箱限制怎么绕过沙箱限制是SSTI利用中最大的障碍。不同引擎的沙箱机制不同绕过思路也不同。以Jinja2为例常见的沙箱限制包括禁止访问以_开头的属性禁止调用某些危险函数限制可用的过滤器绕过思路通常有几种一是利用沙箱未覆盖的魔术方法比如通过request对象、config对象等间接访问二是利用字符串拼接绕过关键词过滤比如[__class__]可以写成[__class__]三是利用编码绕过比如用Unicode编码或者十六进制编码。实操心得沙箱绕过是一个需要耐心和创造力的过程。我通常会先把沙箱的报错信息仔细读一遍了解它到底限制了哪些操作然后针对性地寻找绕过点。很多时候沙箱的限制并不完整总会有一些“漏网之鱼”。5.4 常见问题速查表问题现象可能原因排查方向Payload原样输出语法不对或输入被转义检查页面源码、尝试多种语法返回空白或报错模板渲染失败查看报错信息、简化Payload数学运算正常但命令执行失败沙箱限制或类不可用检查沙箱配置、更换利用链子类列表为空环境限制或版本差异尝试其他魔术方法入口命令执行无回显命令执行成功但结果未返回尝试反弹Shell或写入文件6. 从防御视角看SSTI开发中如何避免踩雷6.1 最根本的原则不要把用户输入当模板这句话说起来简单但在实际开发中很多开发者为了“灵活性”会不自觉地违反这个原则。比如为了实现“自定义欢迎语”功能有些开发者会这样写# 危险写法 template 欢迎回来 username return render_template_string(template)正确的写法应该是# 安全写法 return render_template_string(欢迎回来{{ name }}, nameusername)区别在于危险写法把username拼接到了模板内容中username中的{{}}会被解析安全写法把username作为变量值传入模板引擎只会把它当作普通字符串处理。6.2 沙箱配置与输入过滤如果业务场景确实需要允许用户自定义模板内容比如邮件模板功能那就必须启用模板引擎的沙箱模式并对用户输入进行严格过滤。以Jinja2为例可以通过SandboxedEnvironment来创建一个沙箱环境from jinja2.sandbox import SandboxedEnvironment env SandboxedEnvironment() template env.from_string(user_input) result template.render()沙箱环境会限制对危险属性和方法的访问大幅降低SSTI的利用可能性。但需要注意的是沙箱并非绝对安全历史上也出现过多次沙箱绕过。所以沙箱应该作为纵深防御的一层而不是唯一的防线。输入过滤方面至少要过滤或转义以下字符{、}、$、#、%、、。但单纯的字符过滤容易被绕过更可靠的方式是使用白名单机制只允许用户输入预定义的变量名。6.3 代码审计中的快速定位技巧如果你在做代码审计想快速定位潜在的SSTI风险点可以搜索以下关键词Pythonrender_template_string、Template、from_stringPHPTwig、Smarty、createTemplateJavaFreemarker、Velocity、TemplateEngine、process搜索到这些关键词后重点看参数是否用户可控、是否被拼接到模板内容中。如果发现render_template_string(user_input)这样的代码基本可以确认存在SSTI风险。我在实际审计中还会关注一个细节有些框架提供了“模板继承”或“模板包含”功能如果用户可以控制被包含的模板路径也可能导致SSTI或者任意文件读取。这类问题比较隐蔽需要结合具体的框架文档来分析。7. 我个人在SSTI学习和实战中的几点体会刚开始接触SSTI的时候我最大的困惑是“为什么一个模板渲染能执行命令”。后来把Python的魔术方法链彻底梳理了一遍才真正理解了这个过程。我的建议是不要死记Payload而是理解每一步的原理。比如__class__是获取对象的类__mro__是获取类的继承链__subclasses__()是获取所有子类——理解了这些你就能自己构造利用链而不是依赖别人写好的Payload。另一个体会是SSTI的识别比利用更重要。在实际测试中大部分时间花在“判断是否存在SSTI”和“判断是什么引擎”上真正利用的时间反而不多。所以我把数学运算探测和引擎识别的方法练得很熟基本上看到回显就能判断出引擎类型。最后说一个实用小技巧如果你在测试中遇到了SSTI但一时找不到可用的利用链可以先把__subclasses__()的完整列表保存下来然后在本地用Python脚本搜索。我通常会搜索subprocess、os、popen、system、eval、exec这几个关键词基本上能覆盖大部分可利用的类。如果这些都没有再考虑通过__builtins__或者__globals__来间接访问。这个领域的内容其实还有很多可以展开比如不同框架的SSTI利用差异、沙箱绕过的具体案例、自动化检测工具的实现思路等。后续如果有机会可以再单独写一篇关于SSTI自动化检测的实践总结。