DEV Community

duchu.nft
duchu.nft

Posted on

How a Fox and a Single Quote Broke the Critter Gallery — SQLi in Intigriti Challenge 0926

How a Fox and a Single Quote Broke the Critter Gallery — SQLi in Intigriti Challenge 0926

My write-up for Intigriti Challenge 0926: a cute animal gallery hiding a textbook SQL injection behind a base64 ?pic= parameter. Solved 27/09/2026, submission INTIGRITI-TAPV7AA2 accepted.

The challenge

The Critter Gallery shows one animal per page. The animal is picked via a base64-encoded ?pic= query parameter that decodes to the critter's name, e.g. ?pic=Rk9Y → FOX. Beneath each picture sits a short description pulled from a database. Somewhere on the server, a second table — secret_vault — holds the flag.

The official tip: "the gallery speaks different languages" — the same input is processed differently by two layers of the stack.

First look: two code paths, one input

Requesting FOX vs Fox was the first tell. Both returned the fox description, but the ASCII art differed — one fell back to a default drawing. So the image lookup and the description lookup clearly don't share the same comparison logic: the art is matched one way (PHP, exact/case-sensitive), the description another (MySQL, case-insensitive collation with fullwidth folding). Two languages, exactly as the tip promised.

That mismatch told me the description path talks directly to SQL — worth poking.

Finding the injection: the blank page

Classic quote test, base64-wrapped:

  • ' → blank white page (zero-length response)
  • \ → blank page too
  • " → normal page, unaffected

A single quote killing the page while a double quote does nothing means the input lands inside a single-quoted SQL string. The backslash breaking it too confirms the quote isn't being escaped — it's raw string concatenation.

Confirming output: dumping all descriptions

Next, a boolean break-out to prove the query result reaches the page:

?pic=JyBPUiAnMSc9JzE=        # base64(' OR '1'='1)
Enter fullscreen mode Exit fullscreen mode

The description box printed all 8 rows instead of one. Injection confirmed, and the output lands in div.desc.

Column count: UNION needs exactly one

UNION-based extraction needs the right column count. Probing ORDER BY / UNION with 1..n columns showed the query selects a single column (SELECT desc ...), and UNION output renders in the same description div.

Fingerprinting via UNION:

?pic=JyBVTklPTiBTRUxFQ1QgQEB2ZXJzaW9uIC0tIC0=   # base64(' UNION SELECT @@version -- -)
Enter fullscreen mode Exit fullscreen mode

→ MySQL 8.0.46, database critter_gallery, tables animals and secret_vault.

The vault

secret_vault has two columns: id, note. One shot to dump it:

zzz' UNION SELECT GROUP_CONCAT(id,'~',note SEPARATOR '|') FROM secret_vault -- -
Enter fullscreen mode Exit fullscreen mode
?pic=enp6JyBVTklPTiBTRUxFQ1QgR1JPVVBfQ09OQ0FUKGlkLCd+Jyxub3RlIFNFUEFSQVRPUiAnfCcpIEZST00gc2VjcmV0X3ZhdWx0IC0tIC0=
Enter fullscreen mode Exit fullscreen mode

The description box returned:

1~INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
Enter fullscreen mode Exit fullscreen mode

(flag as rendered; submitted value INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}, accepted ✅)

Lessons

  1. Behavioral diffs are the real tip. The FOX/Fox art mismatch revealed two comparison semantics before I sent a single quote. When one input gets two treatments, one of them is usually injectable.
  2. A blank page is an answer. Zero-length responses on ' and \ (but not ") fingerprint a single-quoted context with no escaping — no error message needed.
  3. Dump small, then go wide. ' OR '1'='1 (8 rows in the desc box) proved output control before I spent requests on schema enumeration.
  4. Know your collations. PHP exact-match vs MySQL case-insensitive/fullwidth-folding comparison is a classic desync primitive — it shows up in auth bypasses too, not just galleries.

Thanks to the Intigriti team for a fun challenge. Full probe trail (14 rounds, from recon to flag) is documented in my notes.

Top comments (0)