前言:
做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
声明变量
var
和let
是用于声明变量的关键字。
区别:
var:
- 作用域:具有函数作用域或全局作用域,在函数内部声明,那么它仅在该函数及其子函数内有效;如果在函数外部声明,则它是全局作用域的。
- 重新声明:在同一个作用域内,你可以多次声明同一个变量名,最后一次声明会覆盖前面的声明。
- 临时死区 (TDZ):由于JavaScript 声明提升,不存在。
- 全局对象属性:在全局作用域中使用
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
//undefinedlet:
- 作用域:具有块级作用域,只在被声明的代码块
{...}
内有效。 - 重新声明:在同一个作用域内,
let
不允许重新声明同名变量。如果尝试这么做,会抛出一个SyntaxError
。 - 临时死区 (TDZ):存在一个称为“临时死区”的概念,即在变量声明之前的任何代码中引用该变量都会抛出
ReferenceError
。 - 全局对象属性:不会自动添加到全局对象上。
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 | const person = { |
全局变量
__dirname:当前模块的目录名。
1
2
3console.log(__dirname);
//C:\Users\LEOVO1\AppData\Roaming\JetBrains\WebStorm2024.2\scratches__filename:当前模块的文件名。 这是当前的模块文件的绝对路径(符号链接会被解析)。
1
2
3console.log(__filename);
//C:\Users\LEOVO1\AppData\Roaming\JetBrains\WebStorm2024.2\scratches\scratch.jsexports变量:默认赋值给
module.exports
。它可以被赋予新值,从而暂时不会绑定到module.exports。module:在每个模块中,
module
的自由变量是对表示当前模块的对象的引用。 为方便起见,还可以通过全局模块的exports
访问module.exports
。 module 实际上不是全局的,而是每个模块本地的require模块:用于引入模块、 JSON、或本地文件。可以从 node_modules引入模块。
1 | // 引入JSON文件 |
对象
在js中几乎所有的事物都是对象
1 | let a= { //定义一个变量a,这里的a就是对象 |
可以看到访问对象的属性有两种方式
1 | a.name |
对象和函数之间的区别:对象是由函数创建的,函数实际上是另外一种对象。
函数
使用 function
关键字声明一个函数。
基础的三种函数:
普通函数:最常见的函数形式,可以有参数和返回值。
1
2
3
4
5
6
7
8
9var 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

完整的函数学习:[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 | var fs = require("fs"); |
1 | var fs = require("fs"); |
通过两种方式运行的结果可以发现异步方式明显阻碍了代码执行。
http模块
这里只介绍http.createServer()
方法
完整的模块学习:Node.js v20.18.0 文档、Node.js 教程
创建并运行一个简单的 HTTP 服务器:http.createServer([options][, requestListener])
1 | const http = require('http'); |
child_process模块(创建子进程)
在上一篇Node.js|node-serialize序列化与反序列化已经提及过了这边就不介绍了
完整的child_process模块学习:Node.js v22.12.0 文档
二、原型链污染
prototype(原型)
在 js 中每个函数都有一个 prototype 属性,它是从一个函数指向一个对象
1 | 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 | function User(){ |
通过User函数的prototype属性,,指向道User函数的原型对象中创建一个新的方法a。
实例化后的对象拥有这个属性中的所有方法和变量,所以这里实例化后的对象user拥有login方法
proto
但是实例化出来后的对象不能够通过prototype访问原型需要通过__proto__
1 | function A(){} |
a.__proto__
是实例a的原型对象,指向函数A的原型对象。A.prototype
和a.__proto__
实际上指向同一个对象,所以console.log()输出true。
1 | function User(){ |
原型链
每个对象都有一个指向它的原型的内部链接,而这个原型对象又有他自己的原型,直到 null 为止。多个对象层层继承,实例对象的原型链接形成了一条链,也就是js的原型链。
1 | function User(){ |
在调用user.name时,实际上JavaScript引擎会进行如下操作:
- 在实例化对象
user
中找name - 没找到,在
user.__proto__
找name - 如果还是没找到就继续叠加
__proto__
,以此类推 - 直到找到
null
结束,null
表示原型链的终点。如果在整个原型链中都没有找到last_name
,则返回undefined
。
该段代码的原型链:
原型链污染
如果修改了一个对象的原型,那么会影响所有来自于这个原型的对象,这就是原型链污染
1 | function User(){ |
可以看到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 | function merge(object1, object2){ //merge函数接受两个参数object1,object2 |
可以看到source对象已经被合并到target对象里了
现在我们假设source是一个可以构造的对象
1 | function merge(object1, object2){ |
可以发现已经合并成功了,但是原型链没有被污染,object对象里还是没有cmd属性。
这是因为构造的过程中,遍历source的所有key得到的结果是[name,cmd],__proto__
是一个特殊属性并不是一个key,所以不会污染Object的原型。
这时候就需要利用JSON.parse()
:解析 JSON 字符串,构造字符串描述的 JavaScript 值或对象。从而让__proto__
被认为是个key。
1 | function merge(object1, object2){ |
可以看到target对象和新建的object对象都存在cmd属性了,污染成功。
clone
创建一个新的对象,并复制原对象的属性和值。
1 | const clone = (a) => { |
其余用法其实跟merge差不多
eval
1 | var Object = {}; |
eval函数会将字符串代码作为JavaScript代码进行解析和执行,因此会向Object的原型对象中添加一个属性a。
三、例题
2024isctf ezejs
下载附件得到源码进行代码审计,先找到关键点
1 | //app.js |
可以发现这是一个ejs模板,在根据我们分析出来的很可能存在原型链污染,那么我们可以联想到ejs原型链污染rce
这段代码其实就是两个功能点
- 展示主页(/view/index.ejs)
- 后门接口(/UserList)
利用这个后门接口,我们可以注入恶意代码到原型链中
构造user对象从而实现原型链污染并且执行rce
1 | {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}} |
由于黑名单中限制了outputFunctionName
、localsName
、escape
所以我们使用destructuredLocals
抓包用POST传入
1 | { |
这题应该是环境有问题,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