Thank You Javascript - Plfanzen 2026 CTF Writeup Thank You Javascript - Plfanzen 2026 CTF Writeup

Thank You Javascript - Plfanzen 2026 CTF Writeup

Thank You Javascript - Plfanzen 2026 CTF Writeup

Introduction

Thank You Javascript was a web challenge from the Plfanzen 2026 CTF, made by Jorian. This was a cool challenge involving an array multiple of cool ideas.

The challenge is a registration and login system built with Express.js and SQLite. On the surface it looks completely boring. Under the surface it is held together with duct tape, questionable async practices, and one very conveniently pinned npm version. We are going to enjoy every second of that.

The goal is to extract the flag, which is stored on the prototype of the Flag class. Three bugs chain together to get us there, none of them obvious until they suddenly are.

TLDR

  1. Send email as an array during registration. sqlite3 treats the array as the full parameter list, leaving verification_code unbound and defaulting it to NULL. Instant verified account.
  2. /update-password calls bcrypt.compare() without await, so the condition evaluates a Promise object, which is always truthy. Any password works. And then there is the LIKE operator that checks a pattern, which also has wildcards % and _ bypassing the check with a different pattern comparison.
  3. CVE-2026-39412: sort_natural in LiquidJS uses raw bracket notation internally, bypassing ownPropertyOnly. Binary search the flag one character at a time by watching where the flag object sorts relative to known probe strings.

Flag: plfanzen{w1th_4n_4rr4y_0f_3xpl01ts_1_pr0m1s3_y0u_w1ll_l1k3_th1s_s0rt_0f_th1ng}


The Challenge

Register an account, verify it via email, log in, update your password if you want. The source is small and readable, which makes it extra fun when you start finding bugs in code that looks totally fine.

Two things jump out immediately when reading the source. First, every dependency uses a ^ version range except liquidjs, which is pinned to exactly 10.25.3. Developers do not pin template engine versions for fun. Second, the flag is stored like this:

class Flag {}
Flag.prototype.name = process.env.FLAG;

On the prototype, not on an instance. Keep that in the back of your head.

The admin account is created at startup with crypto.randomBytes(16).toString("hex") as the password. 128 bits of entropy. You are not guessing that. Good thing we do not have to.


Bug 1: Getting a Verified Account Without Verifying Anything

When you register, the server generates a verification code, inserts the user with that code in the database, and is supposed to email it to you. You need that code to verify your account before you can log in. The email is never actually sent in this challenge, but that does not matter because we are not going to need the code at all.

The insert looks completely fine:

const verification_code = crypto.randomBytes(16).toString("hex");
const password_hash = await bcrypt.hash(password, 10);
await db.runAsync(
"INSERT INTO users (email, username, password_hash, verification_code) VALUES (?, ?, ?, ?)",
email, username, password_hash, verification_code
);

Four placeholders, four arguments. The problem is that nobody checks whether email is actually a string.

Express’s urlencoded({ extended: true }) uses the qs library, which parses repeated bracketed fields into arrays. Sending email[]=a&email[]=b&email[]=c gives you req.body.email = ['a', 'b', 'c']. The server never validates the type and passes it straight to db.runAsync().

Here is what sqlite3 does when the first argument is an array, from statement.cc:

if (info[start].IsArray()) {
auto array = info[start].As<Napi::Array>();
for (int i = 0, pos = 1; i < array.Length(); i++, pos++) {
baton->parameters.emplace_back(BindParameter(array.Get(i), i + 1));
}
}

When the first argument is an array, sqlite3 treats it as the entire parameter list and ignores everything else. It binds each element to each placeholder in order and stops when the array runs out. Any remaining placeholders go unbound, and SQLite defaults unbound parameters to NULL.

So a three-element array binds positions 1, 2, and 3. Position 4, the verification_code, never gets touched. The login check requires verification_code === null to let you through. You just satisfied it without receiving a single email.

Terminal window
curl -i 'http://localhost:3000/register' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'email[]=attacker@test.com' \
--data-urlencode 'email[]=attacker' \
--data-urlencode 'email[]=$2b$10$eledRI33ZjnJGOr7uT2GoOZhHmVwxDKVi3YnTHG9kVXdOgWD5.Qqe' \
--data-urlencode 'username=ignored' \
--data-urlencode 'password=ignored'

What gets stored: email attacker@test.com, username attacker, a precomputed hash for “test”, and verification_code as NULL. Log in and you are through. On to the next bug.


Bug 2: Resetting the Admin Password With Zero Effort

The password update endpoint has two bugs that, together, completely destroy its access control:

const username = req.session.user.username;
const user = await db.getAsync("SELECT * FROM users WHERE username LIKE ?", username);
if (bcrypt.compare(old_password, user.password_hash)) {
const new_password_hash = await bcrypt.hash(new_password, 10);
await db.runAsync("UPDATE users SET password_hash = ? WHERE id = ?", new_password_hash, user.id);
res.redirect("/");
} else {
return res.status(400).send("Wrong old password");
}

The missing await. bcrypt.compare() is async. Without await, calling it returns a Promise object rather than a boolean. A Promise object is always truthy in JavaScript no matter what it would eventually resolve to. The password check passes unconditionally, every time, for any input.

This is one of those bugs that is embarrassingly easy to miss in a code review. The function call looks right, the logic looks right, it just silently does the wrong thing at runtime. You can verify it straight from the node.bcrypt.js source: when no callback is passed, compare() returns promises.promise(compare, this, [data, hash]). A Promise. Not a boolean. Never a boolean.

The LIKE wildcard. The user lookup uses LIKE instead of =. In SQL, LIKE supports wildcards: % matches any sequence of characters and _ matches any single character. Most developers think of LIKE as just a fancier string comparison and do not immediately think “wildcard injection” when they see it. But if user-controlled input flows directly into a LIKE pattern without sanitisation, that input becomes the pattern. If your session username is %, the query becomes:

SELECT * FROM users WHERE username LIKE '%'

That matches every single user in the database. db.getAsync() returns the first row, and the first row is always admin since it was inserted at startup before any real users existed. So the update does not touch your account at all. It quietly updates admin.

Go back to Bug 1 and put % in position 2 of the email array:

Terminal window
curl -i 'http://localhost:3000/register' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'email[]=test@test.com' \
--data-urlencode 'email[]=%' \
--data-urlencode 'email[]=$2b$10$eledRI33ZjnJGOr7uT2GoOZhHmVwxDKVi3YnTHG9kVXdOgWD5.Qqe' \
--data-urlencode 'username=ignored' \
--data-urlencode 'password=ignored'

Log in with that account, hit /update-password with any string as old_password and your chosen new password. The missing await lets you through, LIKE '%' matches every user, get() returns admin first, and their hash gets overwritten. Log in as admin@admin.com. You are in.


Bug 3: CVE-2026-39412 and Reading the Flag Through Sort Order

Admin access unlocks /debug-template, which renders arbitrary LiquidJS templates server-side with a Flag instance available in context. First thing you try:

{{ flag.name }}

Nothing. The engine is configured with ownPropertyOnly: true, which blocks prototype chain access during template property lookups. The flag lives on Flag.prototype.name, not on the instance itself, so the template layer cannot see it.

And that is where the pinned version matters.

Why sort_natural Ignores ownPropertyOnly

ownPropertyOnly exists because template engines are often handed untrusted objects and you do not want a template to be able to read internal or inherited properties off them. LiquidJS enforces this by routing all property access in templates through its own resolver, which checks Object.prototype.hasOwnProperty before returning anything. Prototype properties return undefined. Secure by design.

CVE-2026-39412 is a bypass in the sort_natural filter. The whole issue is in one line:

export function sort_natural(input, property) {
const compare = (lhs, rhs) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])
return [...toArray(input)].sort(compare)
}

lhs[propertyString] is raw JavaScript bracket notation, the same as writing lhs.name but with a dynamic key. In JavaScript, bracket notation always walks the prototype chain. It is not special, it is just how property lookup works. The difference here is that this line lives inside a plain JavaScript function, not inside LiquidJS’s template resolver. It never touches ownPropertyOnly. So when the sort comparator reads flag['name'], it gets the actual flag value straight off the prototype, bypassing the entire security boundary.

The full advisory is at GHSA-rv5g-f82m-qrvv if you want to read the original disclosure. The fix in later versions runs property access through the resolver even inside filter implementations.

You still cannot print the flag. But you can watch where it sorts.

Extracting the Flag Through Sort Position

Create two probe objects with known name values, push the flag object into the same array, sort everything by name using sort_natural, and observe where flag lands in the output. If flag sorts between your two probes, its value is alphabetically between them. Tighten the probes and repeat. That is binary search, and each iteration cuts the remaining possibilities in half.

The template that does this:

{% assign arr = "plfanzen{d,plfanzen{z" | split: "," %}
{% assign probes = arr | group_by_exp: "x", "x" %}
{% assign all = probes | push: flag %}
{% assign sorted = all | sort_natural: "name" %}
{% for x in sorted %}[{{ x.name | default: "FLAG" }}]{% endfor %}

group_by_exp is intended for grouping collections by a computed key. For every unique value it produces { name: <value>, items: [...] } where name is an own property on the resulting object. Passing raw strings with group_by_exp: "x", "x" abuses this: each string becomes its own group and the string value lands directly on .name. No other LiquidJS filter does this since map, sort, and where all transform existing objects rather than constructing new ones. So the probes and the flag object end up the same shape and sortable together. After sorting, each name renders in order. Since flag.name is blocked by ownPropertyOnly at render time, default: "FLAG" marks its position in the output. The sort already ran using the real prototype value though, so the position is accurate.

Output [plfanzen{d][FLAG][plfanzen{z] means the next character is between d and z. Narrow the probes and go again until you have the exact character, then move to the next position.

probe() posts the template and checks whether [FLAG] appears between the two bounds in the rendered output. next_char() runs binary search over the charset using that as its comparator. For a 38-character charset, you need at most 6 requests per character. The main loop extends the known prefix one character at a time until the closing brace.

The flag leaks entirely through sort order. The actual value never gets printed once.

After reading other people’s solutions I realised a binary search was not the best way to solve this but it worked so thats what matters.


Full Chain

1. Register with email[] array, position 2 = %
verification_code defaults to NULL, account is instantly verified
2. Login with that account
3. POST /update-password with any old_password
bcrypt.compare() without await returns a truthy Promise, check always passes
LIKE '%' matches every user, get() returns admin first, their password hash gets overwritten
4. Login as admin@admin.com with your new password
5. POST /debug-template in a loop
Binary search via sort_natural CVE
Flag extracted one character at a time through sort position

A type coercion quirk buried in a C++ binding layer. One missing keyword on an async call. A filter that skips the security check because it never goes through the template resolver. None of them dramatic on their own.

Conclusion

The ideas behind the challenge are truly unique and its rare to see these kinds of challenges which is why I decided to create a writeup for this. The parsing differential via extended = true in the qs library is a repeating occurence and a new hot topic in the research space. Will be looking up to Jorians more similar challenges, and hopefully we can see other CTF’s like this.

Great challenge. Thanks to Jorian for building it.


← Back to writeups