jsbox — gaslightCTF 2026 writeup
Category: misc (100)
Author: sportshead
Solves: 201
Service: ncat --ssl <instance>.play.gaslightctf.cooking 31337
Flag: gaslightCTF{n4t1v3_c0d3_0r_n4t1v3_fl4g?_0142da7d4c7e}
"I created a black box for you to guess the flag. It's using secure comparison, so don't bother with a side channel!"
TL;DR
The service is a node REPL that evaluates JS inside an isolated-vm sandbox exposing a
single blackbox(guess) function. blackbox compares the guess against the real flag
using a 10M-iteration loop ("secure comparison" = no timing side channel) and returns a
boolean.
blackbox.toString() is overridden to return 'nice try', but the override lives on
Function.prototype.toString, so calling it via
Function.prototype.toString.call(blackbox) bypasses the sandbox's tampering and returns
the actual function source — which hardcodes the flag as a const.
Interaction
Connect with pwntools over TLS:
from pwn import *
r = remote(class="hljs-string">'<instance>.play.gaslightctf.cooking', 31337, ssl=True)The banner is blackbox("is this the flag?") with a > REPL prompt. We evaluated arbitrary
expressions in the sandbox:
| Expression | Result |
|---|---|
typeof blackbox |
'function' |
blackbox("") |
false |
blackbox(1) |
TypeError: flag must be a string (at <isolated-vm>:5:11) |
blackbox.toString() |
'nice try' (function source hidden) |
String(blackbox) |
'nice try' |
typeof process / typeof require |
'undefined' (fully isolated) |
The stack trace also revealed the runtime: jsbox-0.0.0 under /nix/store/... using
isolated-vm.
Vulnerability
The sandbox tries to hide the blackbox implementation by shadowing its toString:
// host-side context script (isolated-vm), roughly:
function blackbox(flag) {
const FLAG = class="hljs-string">"gaslightCTF{n4t1v3_c0d3_0r_n4t1v3_fl4g?_0142da7d4c7e}";
if (typeof flag !== class="hljs-string">"string") {
throw new TypeError(class="hljs-string">"flag must be a string");
}
let ok = FLAG.length === flag.length;
for (let i = 0; i < 10_000_000; i++) {
if (FLAG[i % FLAG.length] !== flag[i % flag.length]) ok = false;
}
return ok;
}
blackbox.toString = () => class="hljs-string">"nice try";The sandbox only overrode blackbox.toString. Function.prototype.toString.call(x) is the
canonical way to read a function's source regardless of its own toString (it is what
Function.prototype.toString itself does internally for callables that don't override it),
so the full source — including the hardcoded FLAG — was returned.
Exploit
Function.prototype.toString.call(blackbox)Output (source dump):
function blackbox(flag) {
const FLAG = "gaslightCTF{n4t1v3_c0d3_0r_n4t1v3_fl4g?_0142da7d4c7e}";
...
}Verification
blackbox(class="hljs-string">"gaslightCTF{n4t1v3_c0d3_0r_n4t1v3_fl4g?_0142da7d4c7e}")returns true.
Flag
gaslightCTF{n4t1v3_c0d3_0r_n4t1v3_fl4g?_0142da7d4c7e}Takeaways / fix
- Never hide a secret by obscuring how it's used; secrets must never live in code that can
be introspected at all. For this kind of oracle challenge, read the flag from a file/env
into the host process and never expose the blackbox source to the sandbox (e.g. wrap it
via an
ivm.Referencecallback that runs host-side, sotoStringshows only the reference wrapper). Function.prototype.toString.call(fn)bypasses per-instancetoStringoverrides.