← Back to trevorsteinke.com

Critter Gallery: UNION SQLi behind a base64 parameter

Intigriti monthly CTF 0926 · September 2026 · solved in ~6h · write-up published after the challenge closed · PoC: ctf-0926-sqli-poc.py

TL;DR

The challenge hid a classic UNION-based SQL injection behind a base64-encoded parameter. The app decoded pic and interpolated it straight into a MySQL WHERE name = '<INPUT>' clause. No WAF on quotes, no parameterization, one column, MySQL 8.0.46. Flag extracted with a single UNION query against a secret_vault table found via information_schema.

Winning payload (pic = base64 of the string below):

x' UNION SELECT group_concat(note) FROM secret_vault-- 

The trailing space after -- matters. More on that below.

The target

"Critter Gallery" — an animal gallery on PHP 8.2. The landing page offers eight critters; each detail link looks like ?pic=Zm94 (base64 for fox). Unknown values echo their base64-decoded bytes (HTML-escaped) into the page's <h2> — so the server decodes the parameter before using it, and the app is one parameter, one lookup. When the attack surface is this small, the parameter is the app.

Step 1: an error oracle out of nothing

Firing decoded-garbage inputs at the parameter produced something odd immediately: some inputs returned a completely empty 200 response. Not a 500 — HTTP 200, zero bytes, fast (6ms vs ~20ms for normal responses). Mapping which inputs lived and died:

Input (decoded)Result
zzznormal page, "no critter"
abc"d (double quote)normal page
abc'd (single quote)0-byte death
'' (two quotes)normal page
'a' (balanced pair)0-byte death
1'||'2normal page — but all 8 critter descriptions

That last row is where the real work started. First, three wrong theories.

Step 2: three wrong theories

Theory 1: SQLite. The quote behavior — '' surviving as an escaped quote, isolated quotes dying — smells exactly like SQLite's escaping. Burned several probes on this before checking.

Theory 2: a quote filter with odd/even pairing. '''' survived, ''' died, 'a' died but a''b survived. No pairing rule fit the data — because the rule wasn't about quotes at all.

Theory 3: output-encoding bugs. All output was properly htmlspecialchars'd. The bug was never going to be in the rendering.

The mistake in all three: treating the death cases as the signal. The live cases were the signal.

Step 3: 1'||'2 leaks the whole table

That row broke the theories: 1'||'2 rendered a normal page, but the description block contained all eight critter descriptions concatenated. That's not an echo — that's a query returning every row.

Under "the app wraps input in WHERE name = '<INPUT>'":

WHERE name = '1'||'2'

That parses as name = '1' OR '2'. In MySQL, || is logical OR with numeric casting — and '2' casts to a truthy constant. The WHERE clause is always true, and every row comes back.

One observation, three answers:

The earlier "mystery" quote rule dissolved: every 0-byte death was just a MySQL parse error on unbalanced quotes. ''' dies because three quotes don't balance inside the template. 'a' dies because it produces ''a'' — balanced to a human counting characters, but the lexer sees an escaped-quote opening a string that then terminates badly.

The 0-byte 200 is the error oracle. With display_errors off, a MySQL parse error and a normal page differ only in byte count and timing. Once I treated silence as "syntax error", everything after was fast.

Step 4: fingerprinting — and the -- trap

x' UNION SELECT 'a rendered its value in the description block: UNION confirmed. Column count came from ORDER BY: x' ORDER BY 1-- lived, ORDER BY 2-- died. One column.

Then the gotcha that ate twenty minutes: -- without a trailing space does nothing in MySQL. Every payload ending in a bare -- died with the 0-byte response. I briefly concluded comments were WAF-blocked. The real story: MySQL's -- must be followed by whitespace to count as a comment — otherwise it lexes as two minus signs, the template's trailing quote goes unbalanced, and the statement dies.

x' UNION SELECT version()--     → 8.0.46

(Side quest that confirmed it independently: 'abc'||'x' rendered 0 — MySQL casting both strings to 0 for the OR.)

Step 5: information_schema → flag

x' UNION SELECT group_concat(table_name) FROM information_schema.tables--
  → animals, secret_vault

x' UNION SELECT group_concat(column_name) FROM information_schema.columns
  WHERE table_name='secret_vault'--
  → id, note

x' UNION SELECT group_concat(note) FROM secret_vault--
  → INTIGRITI{...}

One quirk worth knowing: x' UNION SELECT 123 — perfectly valid, right arity — died, and so did every numeric/function-call SELECT I tried. The renderer stringifies rows in a way that fatals on non-string values. Practical consequence: extract with group_concat (returns strings) or wrap values in CAST(x AS CHAR).

What I'd tell past-me

1. A 0-byte 200 is data, not a failure. Silent parse errors are an oracle. Treat response-shape differences as signal even when the status code doesn't change.

2. Fingerprint the engine before theorizing. One version() with a properly-spaced comment saves the entire wrong-engine detour. Test || semantics early — OR-of-strings vs concat is a two-second MySQL/SQLite discriminator.

3. MySQL comments need the space. -- not --. This single lexing detail masqueraded as WAF behavior.

4. Base64-wrapped parameters are not sanitized parameters. Decode-then-interpolate is still interpolate.

5. Balance the template's quotes, not just your own. Every payload has to leave the entire statement parseable, including the quote the app appends after your input.