ARTICLE DETAIL

资讯详情

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

SSTI服务端模板注入:从探测到利用链构造与防御绕过

SSTI服务端模板注入:从探测到利用链构造与防御绕过 1. 从一次模板渲染异常说起SSTI到底在利用什么很多人第一次接触模板注入这个概念是在做代码审计或者CTF题目的时候。输入框里随手敲一个{{7*7}}页面回显49那一瞬间既兴奋又困惑——兴奋的是找到了一个疑似注入点困惑的是这玩意儿到底能走多远、能干什么、为什么一个模板引擎会把用户输入当代码执行。我当初也是这样盯着一个回显的49愣了半天后来才慢慢把整条利用链路摸清楚。先把概念说清楚。SSTI全称 Server-Side Template Injection服务端模板注入。它的本质是用户可控的输入被拼接进了模板内容里而模板引擎在渲染时把这段输入当成了模板语法的一部分去解析执行。注意这里的关键词是拼接进模板内容而不是作为变量值传入模板。这两者的区别恰恰是正常使用和漏洞之间的分水岭。拿 Jinja2 举例。正常的写法是render_template_string(Hello {{ name }}, nameuser_input)用户输入是作为变量name的值传进去的模板引擎只会把它当字符串渲染哪怕你输入{{7*7}}页面上显示的也是原样的{{7*7}}。而有漏洞的写法是render_template_string(Hello user_input)用户输入直接成了模板源码的一部分这时候{{7*7}}就会被当成表达式求值回显49。这个区别听起来简单但实际审计中非常容易看走眼。因为很多框架封装得很深你看到的是一个render调用但参数到底怎么传的、有没有经过字符串拼接得顺着调用链一层层看。我见过不少项目表面上用了模板引擎实际上在某个角落把用户输入拼进了模板字符串这种地方就是雷区。这篇内容适合谁看如果你是在做 Web 安全学习、代码审计、渗透测试入门或者单纯想搞明白模板注入这四个字背后到底发生了什么那接下来的内容应该能帮到你。我会围绕 Jinja2 这个具体引擎把探测、利用链构造、常见绕过、以及防御视角的排查思路都过一遍。需要提前说明的是所有内容仅用于授权环境下的安全研究与防御加固请勿用于任何未授权的目标。另外要强调一点SSTI 不是 Jinja2 独有的Twig、Freemarker、Velocity、Smarty 等引擎都有类似问题但每个引擎的语法、可利用对象、利用链差异很大。本文聚焦 Jinja2是因为它在 Python 生态里出现频率极高而且利用链相对成熟、资料相对丰富适合作为理解 SSTI 的切入点。把 Jinja2 这套逻辑吃透之后再看其他引擎思路是相通的只是 payload 要换。2. 探测阶段怎么确认一个点是不是真的能注入2.1 先分清回显了和执行了探测 SSTI 最容易犯的错误是把输入被原样显示当成注入成功。比如你输入{{7*7}}页面返回{{7*7}}这不叫注入这叫正常转义输出。真正的注入是返回49。还有一种情况是输入{{7*7}}返回空或者报错这可能是语法被解析了但求值失败也可能是被过滤了需要进一步判断。我习惯用一组数学表达式 字符串表达式的组合来交叉验证而不是只测一个{{7*7}}。原因很简单有些环境会对数字做特殊处理或者某些 WAF 规则专门盯着7*7这种经典 payload。用多个表达式交叉验证能降低误判概率。常用的探测 payload 组合如下探测目的Payload预期回显说明基础表达式求值{{7*7}}49最经典的入门探测字符串拼接{{ab}}ab验证字符串运算能力乘法与字符串混合{{7*7}}7777777Jinja2 中字符串乘法Twig 结果不同可用于区分引擎变量访问{{config}}配置对象内容验证是否能访问内置对象过滤器调用{{abcupper}}ABC这里重点说一下{{7*7}}这个 payload。在 Jinja2 里7*7的结果是字符串7重复 7 次即7777777而在 Twig 里同样的表达式结果可能是49取决于版本和类型转换。这个差异在指纹识别阶段非常有用能帮你快速判断后端到底是哪个引擎。类似的还有{{abc|upper}}这种过滤器调用不同引擎的过滤器名称和可用性不一样多测几个就能缩小范围。2.2 上下文决定 payload 的写法探测的时候不能脱离上下文。同一个注入点出现在 HTML 正文里、出现在 HTML 属性里、出现在 JavaScript 代码块里、出现在 CSS 里payload 的构造方式完全不同。举个最直观的例子如果注入点在script标签内部你直接写{{7*7}}可能被 JavaScript 语法干扰需要先闭合或者用注释包裹如果注入点在 HTML 属性值里可能要考虑引号闭合问题。我踩过的一个坑是某个注入点在input value用户输入这种属性位置我直接塞{{7*7}}页面返回的确实是49但整个 input 标签结构被破坏了导致后续利用链根本没法正常回显。后来改成{{7*7}}前面加个空格、后面用//之类的注释处理才让结构恢复正常。这个细节说明探测阶段就要开始观察注入点周围的语法环境而不是只盯着回显值。提示探测时建议同时记录三样东西——原始输入、页面回显、以及回显位置在 HTML 源码中的上下文。这三样信息在后续构造利用链时都会用到。2.3 被过滤时的降级探测思路现实环境里{{和}}被过滤是家常便饭。这时候不要急着放弃先判断过滤的粒度。是过滤了双大括号还是过滤了花括号还是过滤了关键字不同的过滤强度对应不同的绕过思路。如果{{被过滤可以试试 Jinja2 的另一种语法{% ... %}语句块。虽然语句块主要用于控制结构但某些场景下也能触发求值。另外Jinja2 支持{{的变体写法比如用{% print(7*7) %}这种形式取决于版本和配置。如果花括号整体被过滤那基本可以判断这个点走不通 SSTI得换思路。如果只是关键字被过滤比如config、self、__class__这些被拉黑那就进入利用链构造阶段的绕过话题了这个后面单独讲。探测阶段的核心目标只有一个确认这个点是否真的把输入当模板解析了。确认了这一点后面的利用才有意义。3. 利用链构造从{{7*7}}到命令执行要跨过几道坎3.1 为什么不能直接执行系统命令很多人探测到{{7*7}}回显49之后第一反应是能不能直接{{system(whoami)}}。答案是不能。Jinja2 的模板沙箱如果启用了或者默认的全局命名空间里根本没有system、exec、os这些名字。模板能访问的只有渲染时传入的上下文变量以及 Jinja2 内置的一些全局对象和过滤器。所以利用链的本质是从模板能访问到的对象出发通过属性访问、方法调用、继承关系一步步爬到能执行命令或者读写文件的对象上。这个过程在 Python 里之所以可行是因为 Python 的对象模型非常开放——几乎任何对象都能通过__class__、__bases__、__subclasses__、__globals__这些魔术属性爬到其他类和方法上。理解这一点很关键。SSTI 利用链不是某个固定的 payload而是一套对象漫游的方法论。你掌握了这套方法论即使目标环境过滤了某些关键字也能换一条路爬过去。3.2 经典利用链的逐步拆解最经典的 Jinja2 利用链起点通常是空字符串、字符串字面量或者某个内置对象。以空字符串为例逐步拆解第一步.__class__拿到字符串类class str。这一步的意义是从任意一个实例出发拿到它的类。第二步.__class__.__mro__拿到方法解析顺序元组里面包含str、object等类。__mro__是 Python 新式类的属性返回一个元组按顺序列出继承链上的所有类。对于字符串来说通常是(class str, class object)。第三步.__class__.__mro__[1]拿到object类。所有 Python 类最终都继承自object所以这一步是爬到根。第四步.__class__.__mro__[1].__subclasses__()拿到object的所有直接子类列表。这个列表非常长里面包含了当前 Python 进程里加载过的各种类。这一步是整个利用链的核心——因为列表里往往能找到subprocess.Popen、os._wrap_close、warnings.catch_warnings等有用的类。第五步从子类列表里定位到目标类比如subprocess.Popen然后通过__init__.__globals__拿到该类的全局命名空间里面通常有os、sys等模块的引用。第六步通过全局命名空间里的os模块调用popen或者system执行命令。把上面六步串起来一个典型的 payload 长这样{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(id).read()}}这里的索引需要根据目标环境实际返回的子类列表来确定因为不同 Python 版本、不同加载模块下subprocess.Popen在列表里的位置不一样。这也是为什么很多教程里的 payload 直接抄过来跑不通——索引对不上。3.3 定位子类索引的实操方法怎么确定索引最直接的办法是把__subclasses__()的结果整个打印出来然后人工找。但结果往往很长几百个类肉眼找很费劲。我一般用两种方式第一种用过滤器或者字符串方法做筛选。比如{{.__class__.__mro__[1].__subclasses__()|string|truncate(500)}}先看个大概或者用select过滤器配合equalto做精确匹配。不过 Jinja2 的过滤器能力有限复杂筛选不太好写。第二种本地复现。在自己的环境里跑一个相同 Python 版本的脚本把object.__subclasses__()打印出来找到目标类的索引再拿到目标环境去试。这个方法的前提是能大致判断目标环境的 Python 版本。判断版本的方法很多比如通过报错信息、通过某些类的存在与否、通过{{.__class__.__mro__}}的输出格式等。这里有个经验subprocess.Popen不一定总在列表里因为如果目标进程没有 import 过 subprocess这个类就不会被加载。这时候可以退而求其次找os._wrap_close通常由 os 模块加载时产生、warnings.catch_warnings标准库常用等类它们的__init__.__globals__里往往也能摸到os或者sys。3.4 不依赖子类索引的替代路径子类索引不稳定是个大问题每次都要重新找很麻烦。有没有不依赖索引的路径有而且不止一条。一条路是走__globals__直接摸。如果能访问到某个函数的__globals__里面往往有__builtins__而__builtins__里有__import__通过__import__(os).popen(id).read()就能执行命令。构造上大概是{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[__builtins__][__import__](os).popen(id).read()}}另一条路是走request对象如果 Flask 环境。Flask 会把request注入到模板上下文里通过request.application.__globals__可以摸到 Flask 应用的全局命名空间里面通常有os。这条路径在某些场景下比子类漫游更短{{request.application.__globals__.__builtins__.__import__(os).popen(id).read()}}还有一条路是走config对象。Flask 的config是Config类的实例通过config.__class__.__init__.__globals__也能摸到全局命名空间。这条路径的好处是config通常是模板里直接可用的不需要先爬__class__。利用路径起点优点局限子类漫游.__class__.__mro__[1].__subclasses__()通用性强不依赖框架索引不稳定需要现场定位request 对象request.application.__globals__路径短Flask 环境稳定仅限 Flask且 request 需在上下文config 对象config.__class__.__init__.__globals__起点直接可用依赖 Flask 配置对象存在内置函数cycler.__init__.__globals__某些环境 cycler 可直接访问依赖具体环境加载情况实际利用时我一般按request → config → 子类漫游的顺序试哪个通就用哪个。因为前两条路径更短、更稳定子类漫游作为兜底。4. 关键字被拉黑之后绕过手法的取舍与组合4.1 常见过滤规则与对应绕过思路真实环境里直接把__class__、__globals__、os、popen这些词写进 payload大概率会被拦。过滤可能来自 WAF也可能来自应用自身的黑名单。绕过的基本思路无非几种字符串拼接、编码转换、属性访问的替代写法、利用 Jinja2 的过滤器做变形。先看属性访问的替代写法。Python 里访问属性除了obj.attr还可以用obj[attr]对于支持下标访问的对象或者getattr(obj, attr)。Jinja2 里attr过滤器也能达到类似效果obj|attr(__class__)。这几种写法在过滤规则眼里长得完全不一样能绕过不少基于字符串匹配的规则。字符串拼接方面Jinja2 支持~做字符串拼接也支持。比如__cla~ss__拼出__class__。如果过滤规则只匹配完整的__class__这种拆分写法就能过。更极端一点可以用request.args或者request.values从 URL 参数里取值把敏感字符串放在参数里payload 里只写变量名这样模板内容里根本不出现敏感词。编码转换方面Jinja2 的|string、|list、|join等过滤器可以做各种变形。比如把字符串转成列表再拼回来或者用format过滤器做格式化。这些手法组合起来能构造出人眼可读但规则匹配不到的 payload。4.2 用 attr 过滤器重写整条链把前面那条经典链用attr过滤器重写一遍大概是这个形态{{|attr(__class__)|attr(__mro__)|attr(__getitem__)(1)|attr(__subclasses__)()|attr(__getitem__)(索引)|attr(__init__)|attr(__globals__)|attr(__getitem__)(os)|attr(popen)(id)|attr(read)()}}这条链里所有点号访问都换成了attr过滤器所有下标访问都换成了__getitem__调用。虽然长了点但能绕过大量基于.和[]的简单过滤。实测下来这种写法在很多只做了基础关键字匹配的环境里是能过的。不过要注意attr过滤器本身也可能被过滤。如果attr被拉黑可以试试|attr的等价写法或者回到点号访问但用字符串拼接绕过关键字。绕过这件事没有银弹得根据实际过滤规则来调整。4.3 无回显场景下的判断有些注入点执行了但页面不回显结果这时候不能靠看返回值判断成功与否。常见的手法是时间盲注或者带外检测。时间盲注的思路是让 payload 执行一个耗时操作比如sleep然后观察响应时间。Jinja2 里可以通过os.popen(sleep 5)之类的方式制造延迟。带外检测则是让目标发起一个外部请求把结果带出来但这涉及具体环境配置不在本文展开。时间盲注的 payload 大概是{{.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(sleep 5).read()}}如果响应时间明显变长说明命令执行成功了。这个方法的好处是不依赖回显坏处是受网络抖动影响大需要多测几次取平均。注意时间盲注在共享环境或者有超时限制的环境里要谨慎使用sleep时间不宜过长否则可能触发服务端超时或者被监控系统标记。5. 从攻击视角反推防御方该怎么堵住这条路5.1 根因不在模板引擎在拼接方式聊完利用必须聊聊防御。因为 SSTI 的根因非常明确用户输入被当成了模板源码的一部分。所以最彻底的防御就是永远不要把用户输入拼进模板字符串。该用变量传参的地方就用变量传参render_template_string(Hello {{ name }}, nameuser_input)这种写法用户输入再恶意也只会被当字符串渲染。这个道理说起来简单但实际项目里踩坑的地方往往很隐蔽。比如某些动态模板功能为了让用户自定义通知内容、邮件模板直接把用户填的模板字符串拿去渲染。这种设计本身就把模板源码的控制权交给了用户等于把大门敞开。如果业务上确实需要用户自定义模板那就必须上沙箱限制模板能访问的对象和方法。5.2 沙箱不是万能药但能抬高门槛Jinja2 提供了SandboxedEnvironment它会限制模板能访问的属性和方法把__class__、__globals__这类魔术属性挡在外面。启用沙箱之后前面那些利用链基本都走不通了。但沙箱也不是绝对安全历史上出现过不少沙箱逃逸的案例尤其是当沙箱配置不当、或者应用自己往沙箱环境里注入了危险对象的时候。我的建议是沙箱要用但不能只依赖沙箱。沙箱是纵深防御的一层配合输入校验、最小权限、运行时监控才能形成比较完整的防护。另外沙箱的版本要及时更新已知的逃逸手法在 newer version 里通常会被修掉。5.3 排查清单审计时该盯哪些点如果你在做代码审计想快速定位潜在的 SSTI 风险点可以按下面这个清单过一遍排查项关注点风险等级模板渲染函数render_template_string、from_string、Template()的直接调用高字符串拼接模板字符串是否由用户输入拼接而成高动态模板功能用户自定义模板、邮件模板、通知模板高沙箱配置是否使用SandboxedEnvironment配置是否合理中上下文注入渲染时传入的上下文是否包含敏感对象中错误处理模板报错信息是否回显给用户低其中错误处理这一项容易被忽略。模板报错信息里往往包含大量内部细节比如类名、文件路径、甚至部分源码片段这些信息对攻击者来说是宝贵的情报。生产环境应该关闭详细错误回显只给用户看通用错误页。5.4 一个容易被忽视的细节模板继承与 include除了直接的字符串拼接模板的include、extends、import这些指令如果路径可控也可能引入非预期的模板内容。虽然这不完全是 SSTI但同属模板层面的注入风险审计时值得一并关注。比如用户能控制{% include user_input %}里的user_input就可能读取到服务器上的其他模板文件造成信息泄露。这类问题的防御思路和 SSTI 类似路径白名单、禁止用户控制模板路径、对 include 的目标做严格校验。6. 实操中积累的几个判断技巧聊了这么多原理和链路最后分享几个我在实际操作中总结的小技巧都是文档里不太会写、但用起来很顺手的东西。第一个技巧先看报错再看回显。很多注入点在 payload 语法错误时会抛出详细的异常信息这些信息比正常回显更有价值。比如 Jinja2 的TemplateSyntaxError会告诉你哪一行、哪个位置解析失败UndefinedError会告诉你哪个变量未定义。通过这些报错能快速判断模板引擎类型、版本特征、以及上下文里有哪些变量可用。第二个技巧用{{self}}快速摸上下文。self在 Jinja2 模板里指向当前模板对象打印它能带出一堆内部信息包括模板的上下文、环境配置等。这个 payload 很短不容易被过滤适合作为探测阶段的信息收集器。第三个技巧子类索引不要硬编码用循环或者筛选动态找。前面说过索引不稳定与其每次手动找不如写个 payload 动态定位。比如用selectattr或者列表推导Jinja2 支持有限的列表推导来筛选出目标类。虽然写起来复杂但一次写好可以复用。第四个技巧注意 Python 版本差异。Python 2 和 Python 3 在__mro__、__subclasses__的行为上有差异字符串类型也不一样strvsunicode。拿到一个注入点先判断 Python 大版本再决定用哪套 payload。判断方法很多比如{{.__class__}}在 Python 3 里返回class str在 Python 2 里可能是type str或type unicode。第五个技巧记录环境指纹。每次遇到一个新的注入点把引擎类型、Python 版本、可用对象、过滤规则这些信息记下来。积累多了之后你会发现很多环境是相似的下次遇到同类目标能少走很多弯路。这个习惯在做授权测试和代码审计时特别有用。最后说一句实在话SSTI 这个方向原理不难难的是面对千变万化的过滤规则和运行环境时能快速判断该走哪条路、该放弃哪条路。多动手、多记录、多复盘比背一百条 payload 管用。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表