SSTI专项复习:从模板注入原理到命令执行与防御实战

发布时间:2026/10/19 10:43:42
SSTI专项复习:从模板注入原理到命令执行与防御实战 5月14号这天我给自己安排了一次SSTI的专项复习。服务端模板注入Server-Side Template Injection这名字听起来唬人但本质上就是攻击者把自己的输入当成了模板代码的一部分让服务器在渲染模板时执行了不该执行的逻辑。花了一整天把原理、探测、绕过、防御整个串了一遍踩了几个坑也把一些以前模棱两可的细节彻底弄清楚了。这篇就把我那天复习的过程和心得完整记录下来给同样在啃Web安全的同学一份参考。先说结论SSTI之所以值得单独花一天复习是因为它经常隐藏得很深而且一旦打通往往是直接拿到服务器权限的路径严重程度不亚于反序列化。很多开发者知道SQL注入、知道XSS但很少注意模板渲染这层于是漏洞就这么被留下来了。1. 从原理到现象SSTI为什么会发生1.1 模板引擎的工作方式要理解SSTI先得理解模板引擎本身。大多数Web框架都有模板系统作用是让展示层和逻辑层分离。拿Python生态来说Flask默认配Jinja2Django内置自己的模板PHP那边有Twig、SmartyJava那边有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($x7*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但可以用popen来构造。在Jinja2里直接用加号连接字符串是允许的{{ .__class__.__mro__[1].__subclasses__()[index].__init__.__globals__[os].popen(id).read() }}第二种是用attr过滤器代替点号访问。点号访问实际上就是取属性所以当过滤了__符号时可以用|attr过滤器{{ .__class__ }}等价于{{ .|attr(__class__) }}注意这里字符串__class__本身没有下划线过滤问题如果下划线也被过滤那就再用拼接{{ .|attr(__class__) }}结合起来就是{{ .|attr(__class__)|attr(__mro__) }}第三种是过滤器链试探。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 h1Hello, name /h1 return render_template_string(template) if __name__ __main__: app.run(debugTrue, host127.0.0.1, port5000)这个代码漏洞明显到有点刻意就是专门为了复现“字符串拼接模板”这种经典错误。跑起来后先在浏览器里访问http://127.0.0.1:5000/hello?nameworld页面输出了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 }}, namename)原理很简单用户输入应该作为变量传递而不是作为模板的一部分。只要不给模板解析器接触原始输入的机会SSTI就少了一大半。接下来是输入过滤和校验。作为纵深防御哪怕采用了变量传参也要对输入做白名单校验。比如只允许字母、数字、下划线、连字符长度限制在几十个字符那些{{、}}、${、%等模板语法字符直接视为非法输入。白名单比黑名单靠谱因为模板语法的变体太多。再往下是最小权限和沙箱隔离。如果业务硬要实现模板自定义功能那就必须把模板引擎放进受限沙箱里关闭危险的内置函数和模块访问。Jinja2有SandboxedEnvironmentTwig有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)}}字符串拼接{{popen}}关键字被过滤时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都管用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询