1. 一道 Flasks SSTI 题,为什么能让新手卡三天
先说结论:Simple_SSTI_2 是 Bugku 上一道入门级 Flask 模板注入题,但它卡住的人绝对不比那些中型难题少。原因很简单——这题考的不仅仅是你能不能打出{{7*7}},而是你有没有把服务端渲染、模板上下文、过滤器白名单、WAF 绕过这一整条链路想清楚。很多人在第一步就走了弯路,一上来就疯狂尝试各种语句,结果永远卡在flag读取那一环。
我最早做这道题的时候,其实也一度以为它要做的是“命令执行”,因为在页面上能看到一个像查询功能的输入框。我试了id、ls这类命令,发现全都无效。这时候如果你怀疑“自己思路错了”,那方向就对了——这题根本不是命令注入,而是模板注入。你要注入的不是 shell 命令,而是 Jinja2 模板表达式。弄清楚这一点,整道题的出口才真正打开。
这道题的适用人群也很清楚:刚入门 Web 安全的选手,想搞懂 SSTI 原理却找不到手感的人,以及已经会一点 Flask 但没实际打过模板注入题的开发向同学。这篇文章我要把整道题的思路、工具、关键 payload 的构造过程、踩坑点全部拆开讲一遍,尤其会解释那些“为什么必须这样写”的底层逻辑,尽量让你做完之后不只是拿到 flag,而是真正理解 SSTI 是怎么回事。
2. SSTI 到底在注入什么?先补上这段基础
2.1 模板引擎的工作方式:它不是文本拼接
在讲这题之前,我必须先把 Flask 默认模板引擎 Jinja2 的原理讲清楚。很多人误解了模板注入的本质,以为服务端是简单地拼接字符串然后执行,但实际上 Jinja2 是先解析,再渲染。
举个例子,正常服务端代码可能是这样写的:
from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'guest') return render_template_string('<h1>Hello, {{ name }}</h1>', name=name)这段代码里,用户传的name是作为一个变量值被传入模板的。注意这里的关键:用户在 URL 中传?name=world,服务端拿到字符串world,将其作为变量name的值,然后在模板渲染时,{{ name }}会被替换成world。这个流程没有注入问题。
但如果代码是这样写的:
@app.route('/') def index(): name = request.args.get('name', 'guest') return render_template_string('<h1>Hello, {{ ' + name + ' }}</h1>')问题就出现了。用户传的name被直接拼进了模板字符串里,然后再交给render_template_string解析渲染。此时如果你传name={{7*7}},那么最终拼接出来的模板字符串是:
<h1>Hello, {{ {{7*7}} }}</h1>这就会导致异常。如果服务端代码对输入做了一定的拼接处理,或者直接把整个用户输入放进模板里,你传入的{*7*7}这类内容就会被当作模板语法执行。这就是 SSTI 的基本雏形。
2.2 为什么{{7*7}}是万能探测点
几乎所有 SSTI 题的入门探测都是这个表达式,原因在于它简单、无副作用、易于观察。传进去之后如果页面显示49,说明你的输入确实进入了模板的渲染上下文,并且作为表达式被执行了。如果显示的是原文{{7*7}}或一堆报错,那就说明要么输入没被解析,要么存在某种过滤机制。
Bugku 上这题,输入{{7*7}}后的返回是 49,让你确认两件事:
- 模板引擎是 Jinja2。
- 用户输入会被直接渲染。
- 存在某种基于黑名单的过滤(后面会讲)。
- 过滤方式是替换或删除敏感字符串,而非彻底拦截。
2.3 从模板表达式到任意读文件,中间需要什么
很多新手会问:{{7*7}}能执行了,但这离读取文件还远着呢。你需要理解一个关键概念:在 Jinja2 模板中,表达式能访问的范围取决于模板上下文。默认情况下,上下文里只有你传入的变量和 Flask 内建的一些对象。但 Python 对象的属性访问和方法调用是可以通过魔术方法链不断延伸的。
比如''是一个字符串对象,通过''.__class__可以拿到它的类str,通过str.__mro__可以拿到继承链,找到object基类,再通过object.__subclasses__()可以拿到当前 Python 解释器加载的所有类的列表。这个列表里包含了很多危险的类,比如os._wrap_close、subprocess.Popen、warnings.catch_warnings、甚至是file(Python2 中的内建文件类型)。
这就是一条完整的链路:
''.__class__.__mro__[2].__subclasses__()拿到子类列表之后,你可以从中筛选出能够执行命令的类,比如subprocess.Popen,然后通过它调用系统命令。或者你也可以找文件操作相关的类来直接读文件。这也就是为什么''.__class__.__mro__[2]是 SSTI 的核心入口。
3. 这道题的第一个坑:过滤与绕过
3.1 黑名单的内容:字母与数字
Bugku 这题的过滤方式很多人描述得很模糊,但实际操作时你会遇到一个非常明显的问题——你输入常规的__class__这种带下划线的英文内容时,会被过滤机制删除或替换。换句话说,过滤规则针对的是字母和数字。
这里的“过滤字母数字”有两种常见实现:
- 直接删除输入中所有的字母和数字。
- 将包含某些关键词的内容删掉或替换为空。
如果是第一种,那你输入的整个字符串几乎被清空了,基本上没法正常构造 payload。如果是第二种,相对好处理一些。实际测试下来,这题属于第二种:它有自己的敏感列表,包括但不限于class、mro、subclasses等常见 SSTI 关键词。
3.2 绕过思路:字符串拼接与 Jinja2 的过滤器
既然不允许直接出现敏感词,那我们换个方式构造它们。Jinja2 有一个强大特性:过滤器可以处理字符串。举个例子:
{{ ''['__cl''ass__'] }}这样写之后,Jinja2 在解析时会把它当成一个字符串字面量'__class__'来处理。它不会因为中间有拼接语法就阻止你,因为这里并不涉及 Python 语法层面的拼接,而是在 Jinja2 解析阶段就把两个字符串字面量合在一起了。所以'__cl''ass__'等同于'__class__'。
这个绕过方式很基础,但相当有效。在把这题卡住的人里,相当一部分不是用不了这个技巧,而是没想到可以拆开字符串。其实你用 Python 写代码的时候,'a''b'本来就是允许的,只是很少有人在日常代码里这么写而已。
另一种绕过方式是借助 Jinja2 的|attr过滤器。它是专门用来获取对象属性的,比如:
{{ ''|attr('__class__') }}这条表达式等价于''.__class__。而且|attr里面传的字符串也照样可以进行拼接,配合起来就是完整的绕过组合拳:
{{ ''|attr('__cl''ass__')|attr('__mro__') }}3.3 实际测试:哪些字符没被过滤
在这个题里,测试下来有几个关键点:
_下划线没有被过滤。.点号没有被过滤。- 单引号双引号没有被过滤。
- 敏感英文单词会被过滤。
- 数字似乎没有过滤。
这就给了我们很大的操作空间。你可以在下划线和点号都合法的情况下,去构造一个完整的 payload。当然不同的源码版本可能会有细微差别,但整体思路是一致的。
如果你在自己搭的测试环境里想要模拟这道题的过滤规则,可以写一个简单的逻辑:
blacklist = ['class', 'mro', 'subclasses', 'flag', 'os', 'system', 'popen'] def filter_blacklist(s): for word in blacklist: s = s.replace(word, '') return s这种替换型过滤是最容易绕的,因为你把它删掉之后,只要用拼接的方式让敏感词出现在最终解析结果里即可。
4. 构造完整攻击链:从读取文件到拿到 flag
4.1 获取所有子类的通用方式
先不管这题的过滤,我们要形成一套通用思维。常规 SSTI 读取文件的 payload 有很多种写法,但它们共同的起点都是拿到object类的所有子类。
Jinja2 里访问当前对象类的方式是''.__class__,拿到类对象之后,通过__mro__拿到继承关系元组,元组里最后一个元素通常是object。我习惯用[2]来定位它,因为字符串对象的 MRO 通常是:
<class 'str'>, <class 'object'>但不同 Python 版本、不同对象,它的位置可能有差异。更稳妥的方式是配合__base__,比如:
''.__class__.__base__这个方法能直接拿到底层基类,不依赖于索引。
4.2 定位文件读取类的思路
拿到子类列表后,你需要在这个列表里找到合适的目标。文件读取类的常见目标有_io.FileIO、_io.TextIOWrapper等。在 Python3 环境下,_io.FileIO是一个非常好用的类,它能以二进制方式打开文件。你不需要完全理解它内部机制,只需要知道它的构造函数接收一个文件路径参数。
筛选目标类的方式是在浏览器里逐个看,但这样效率太低。我的习惯是先不筛选,直接把整个列表打印出来,再用 Python 脚本对列表内容做索引匹配。当然在比赛中你可能不能运行脚本,那就只能在页面里搜索关键词了。
一种比较流行的方式是:
import subprocess for index, subcls in enumerate(''.__class__.__mro__[2].__subclasses__()): if 'FileIO' in str(subcls): print(index, subcls)在本地你有 Python 环境可以这么干,但在线上题目中,你要么靠经验直接猜常见索引,要么就把整个名单打印出来然后逐段翻看。
4.3 这道题的经典经典请求与注入点
这道题的典型注入 URL 格式类似于:
/?flag={{...payload...}}是的,传入参数名可能就是flag,看起来就像一个查询功能。这里有个小细节,注入点在查询参数中,而不是在路径中。你直接把 payload URL 编码后放进查询参数即可。
测试过程是这样的:
- 先访问
/,看到页面提示。 - 输入
{{7*7}},返回 49。 - 尝试构造子类读取。
由于过滤规则里class、mro、subclasses会被替换掉,你就需要写成拼接形式来看待。
4.4 过滤存在的幸与不幸:拼接过滤词即可
碰到这种“替换型过滤”,我有一个经验:过滤函数本身就是在帮你做字符串拼接。因为它只是去掉敏感词,而不会在去除后检查上下文,所以你可以这样绕过:
原始目标表达式:
''.__class__.__mro__[2].__subclasses__()全部替换为拼接形式:
''['__cl''ass__']['__mro__'][2]['__subcl''asses__']()这样过滤函数去掉class和subclasses后,原本的内容反而被破坏了?不对,这里需要仔细想一下。
如果你直接传__class__,过滤函数会把class这个词删掉,于是剩下__。这个结果已经不是我们要的了。
但如果传的是__cl''ass__,过滤函数会在字符串里找class这个子串吗?注意,__cl''ass__里并没有连续完整的class字母,因为中间被单引号截断了。所以过滤函数实际上不会匹配到它。
然后在 Jinja2 解析阶段,两个相邻字符串字面量'__cl'和'ass__'会被拼接为一个整体'__class__'。最终进入属性访问的就是完整的关键词。这就是拼接绕过的完整原理。
4.5 读取 flag 文件的实际载荷
因为 flag 文件通常是/flag或者/flag.txt,最好的选择是找一个可以直接读取文件的类。在 Python3 的_io.FileIO位于子类列表中,通过它我们可以实现文件读取。
借用拼接过滤的思路,最终的一个可用 payload 大概长这样:
/?flag={{%20''['__cl''ass__']['__mro__'][2]['__subcl''asses__']()[94](-1,'/flag',-1).read()%20}}这里需要解释几个部分:
[94]:这个索引值不是绝对的,不同环境下_io.FileIO在子类列表中的位置不同,通常分布在 90 到 110 之间。你需要实测确定。(-1, '/flag', -1):这是FileIO构造函数的三个参数。第一个参数-1表示使用默认缓冲区,第二是文件名,第三是打开模式标志,-1表示只读。这些参数比较复杂,但在实际构造中传(-1, '/flag', -1)是可行的。另一种写法是直接open('/flag').read(),如果环境里有open函数的话。.read():文件读取方法,把内容读出来。
如果[94]不对,你可以用更稳妥的方法来定位,比如用列表遍历。但在线上环境,通常你得做二分测试,把[94]替换成不同的数字,观察页面有没有出现文件内容或者异常回显。
如果FileIO在这个环境里不可用,另一个备选方案是借助os模块的popen执行命令,然后读取输出。找到包含os类名的方式一般是通过warnings.catch_warnings的__init__方法中访问os。但这个思路链路更长,对于这题来说属于备选方案。
5. 实操记录:我在解题时实际踩过的坑
5.1 第一个坑:误判注入类型
我最早看到这个输入框时,第一反应是 SQL 注入,试了单引号、报错语句都没反应。后来看到{{7*7}}返回 49,才确认是 SSTI。这个误判浪费了大概二十分钟。
回头想想,其实只要先输入一组特殊字符串,比如${{7*7}}或者{{7*7}},就能快速识别模板引擎。遇到查询功能先别急着跑 SQLMap,先确认后端有没有模板渲染。
5.2 第二个坑:过度关注索引,忽略了过滤规则
我在构造__subclasses__()时,因为没有注意过滤规则,直接传__subclasses__,结果页面没有任何反应。尝试多次后才发现,敏感词已经被过滤掉了。这个问题其实很典型——你以为是 WordPress 题目执行不了,实际是关键词被替换了。
排查方式很简单:输入{{'class'}},看回显是class还是空。如果是空,就说明这个关键词被过滤了。
5.3 第三个坑:Python 版本影响子类索引
同一个题目环境,在 Python3.7 和 Python3.10 下,_io.FileIO的索引位置完全不同。所以网上很多 WriteUp 里写的[94]不一定适用于你的实例。如果你发现某个固定索引不行,先不要慌,试着用脚本把所有带 FileIO 的类名打印出来。
在没有本地的脚本执行条件下,你可以采用笨办法,但也很有效:用{{...__subclasses__()}}把整个列表回显出来,然后复制内容到本地数索引。这种方式虽然麻烦,但至少可以精确定位。
还有一种方式是利用 Jinja2 的条件表达式和循环能力来做自动筛选。比如:
{% for c in ''.__class__.__base__.__subclasses__() %}{% if 'FileIO' in c.__name__ %}{{ loop.index0 }}{% endif %}{% endfor %}这段代码会遍历所有子类,并打印出FileIO类对应的索引位置。不过它本身也含有敏感词,需要先拼接绕过。实际比赛中如果规则允许,这个方法才是最优解。
6. 常用替代 payload 与建议的调试流程
6.1 三种类型的 payload 预案
平时打 CTF,我会在本地准备三类 SSTI payload 备忘:
类型一:读文件
{{ ''.__class__.__mro__[2].__subclasses__()[<index>](-1, '/flag', -1).read() }}类型二:执行命令
优先找subprocess.Popen:
{{ ''.__class__.__mro__[2].__subclasses__()[<index>]('cat /flag', shell=True, stdout=-1).communicate() }}类型三:利用os模块
{{ ().__class__.__base__.__subclasses__()[<index>].__init__.__globals__['os'].popen('cat /flag').read() }}这三个方案是递进关系,第一个不行换第二个,第二个找不到相关的类就试第三个。实际做题时不要死磕一种。
以下是类型的适用场景对比表格,方便你出门前参考:
| 方案 | 核心利用对象 | 适用场景 | 难点 |
|---|---|---|---|
| 文件读取 | _io.FileIO | 只在拿到 flag 文件路径时 | 子类索引定位 |
| 命令执行 | subprocess.Popen | 文件读取受限,但命令可执行 | 构造函数参数复杂 |
os模块链 | os.popen | 需要动态执行系统命令 | 需要借助__globals__链 |
6.2 调试时的操作顺序
我给你梳理一套比较顺利的调试流程,这个顺序在 Bugku 这道题以及其他大部分 Flask SSTI 题里都适用:
- 先用
{{7*7}}确认模板注入。 - 用
{{'class'}}测过滤关键词。 - 用
{{''['__class__']}}测属性访问是否可行,如果不行再考虑拼接。 - 用拼接形式访问
__mro__和__subclasses__。 - 打印子类列表,找出可用的类及索引。
- 构造最终读取 flag 的 payload。
- 如果读取失败,尝试命令执行方案。
- 如果全部失败,重新检查过滤规则是否还有其他限制。
这套流程不仅适用于这题,也可以作为 SSTI 类题目的通用方法论。环境千变万化,但链路是不变的:确认注入点、确认过滤器、构造访问链、寻找利用点。
7. 常见问题速查:SSTI 做题时总被卡住的地方
我整理了几个高频问题,基本上你在做题过程中遇到的“为啥我这个不行”,都能在这里找到答案。
7.1 输入{{7*7}}没回显 49,是怎么回事?
可能性一:页面渲染结果里{{7*7}}被转义了,你看到的是原文。这种情况说明服务端做了输出编码,不代表不能注入。你试试看传入{{7*7}}后页面源码里有没有 49。可能性二:模板引擎不是 Jinja2,而是 Twig、Smarty 等其他引擎,语法有差异。可能性三:注入点根本不在这个参数中,可能藏在 Cookie、Header 里。
7.2 为什么__class__老是被替换成空?
典型的黑名单替换。你可以尝试使用attr过滤器,或者用拼接字符串。拼接的核心原理再强调一遍:Jinja2 的解析阶段会把相邻字符串字面量合并,黑名单无法提前感知这种合并后的结果。
7.3 子类里找不到能用FileIO的索引?
可能你找错类型了。在不同 Python 版本中,FileIO可能存在,但可能不叫这个名字,或者它的完整类名包含路径前缀。你可以先输出全部子类的类名,搜索关键词io和File。
如果你要自己快速写脚本定位,在本地可以用这段代码:
import inspect classes = ''.__class__.__mro__[2].__subclasses__() for i, c in enumerate(classes): if 'FileIO' in str(c) or 'file' in str(c).lower(): print(i, c)7.4 为什么FileIO读取空文件或报错?
FileIO读取文件时,如果你给的路径是相对路径,它会基于进程当前工作目录去解析。很多 CTF 环境里 flag 在根目录/flag,少部分在/flag.txt,但也不排除在/app/flag这种位置。如果你不确定,可以先用命令执行方式执行ls和find / -name '*flag*'。
7.5 页面无法回显,怎么判断 payload 是否执行?
有一种情况是回显被注释掉或吞掉了。如果你执行的是命令类 payload,它可能执行成功但输出没有显示。这时可以尝试把执行结果写入一个文件,再通过文件读取接口去读:
{{ ''.__class__.__mro__[2].__subclasses__()[<index>]('cat /flag > /tmp/result', shell=True, stdout=-1) }}然后再用文件读取类去读/tmp/result。
7.6 如何判断当前环境到底是不是 Flask?
当你不确定模板引擎时,用{{7*7}}和常见引擎的探测语法交叉验证。Jinja2 的{{...}}是最常见的,Tornado 也是{{...}},但它可用的对象和过滤器会不同。如果 URL 路径带/static/或/login这类 Flask 特征,可以先按 Flask 来试。
8. 从这道题延伸出的通用方法论
8.1 思维模型:入口到目标的链路拆解
我做渗透测试和 CTF 题目时有一个习惯:先把目标链路画出来,而不是盲目试探。SSTI 题目从入口到目标无非是这几层:
- 输入点(查询参数、表单、头)
- 渲染点(模板直接渲染用户输入)
- 过滤层(黑名单、转义、长度限制)
- 执行链路(属性访问、子类获取、对象利用)
- 目标动作(读文件、命令执行、反弹 shell)
链路拆解的价值在于:一旦某个环节卡住,你能迅速判断是链路断了,还是某一个节点需要替换方案。比如你在执行命令时发现os导入不了,你可以换subprocess的方式,而不必从头再来。
8.2 本地搭个实验环境才最快
我认为做 SSTI 题最有效的学习方式,就是在本地搭一个与题目过滤规则相似的实验环境,不断测试 payload。你不需要完全复现 Bugku 的源码,只要能构造出“模板渲染+黑名单替换”的环境即可:
from flask import Flask, request, render_template_string app = Flask(__name__) blacklist = ['class', 'mro', 'subclasses', 'flag', 'os', 'system', 'popen'] def filter_blacklist(s): for word in blacklist: s = s.replace(word, '') return s @app.route('/') def index(): query = request.args.get('flag', '') return render_template_string('{{ ' + filter_blacklist(query) + ' }}')这个代码虽然不能完全等同于 Bugku 的源码,但对于练习拼接绕过已经够了。你可以随意修改黑名单,观察不同过滤规则下的绕过方式。把这道题彻底搞懂,你对 SSTI 的理解会有一个质的飞跃。
8.3 工具与辅助脚本
如果你在命令行环境下调试,可以用 Python 快速构建本地测试脚本,也可以使用 Burp Suite 的 Repeater 反复发包。我建议准备一个小工具集合,包括:
- Python requests 脚本,用于自动化发送 payload 并判断回显。
- 一个本地 Flask 模拟环境,方便快速测试。
- 一个子类索引定位脚本。
这些工具准备的时间不长,但能显著提高刷题效率。
9. 最后说点实在的:SSTI 题的关键不是背 Payload
在这个题上花了一整天之后,我最大的体会是:SSTI 的题目刷几十道,不在于你会不会背华丽的绕过链,而在于你有没有形成一个可以应对各种过滤规则的思维方式。所谓思维,就是当你面对一个未知的过滤规则时,能快速试出它是替换型还是阻断型,能在不依赖网上现成 WriteUp 的情况下,自主构造拼接字符串,自主定位可用索引。
这道 Bugku 题目本身并不复杂,但它像一面镜子,照出了很多初学者对模板渲染原理的模糊理解。如果你在做这道题时也卡了很久,不要灰心,我建议你回到基础,亲手写一个 Flask 的render_template_string例子,把用户输入直接拼进去运行,多试几组 payload,感受一下“输入是如何变成模板代码的”。
这道题之后,你可以继续挑战几道更复杂的 SSTI 题,比如涉及{%...%}语句块、涉及多个过滤器叠加、涉及长度限制的场景。你会发现,基础链路永远是那几条:确认注入、探测过滤、构造链、找对象、执行动作。
回到题目本身,如果你按我上面的流程走完,应该能拿到一个明确的结果:flag 文件被成功读取,页面回显了内容。如果你还想再深入一点,不妨试着换一种绕过方式,比如用编码解码的方式处理关键词,或者用 Jinja2 的{% set %}语句来赋值,这些玩法都能帮你把 SSTI 的基础打得更加扎实。