Skip to content

SQL Injection

An application builds a query by pasting input into a SQL string. The database receives one flat string and has no way to tell which characters the developer wrote and which arrived in the request, so a value that contains SQL syntax is parsed as SQL syntax. The boundary between data and code exists only while the two travel separately; concatenation destroys it.

python
# The value becomes part of the query text
cur.execute("SELECT * FROM items WHERE id = '" + item_id + "'")

# The value travels beside the query and never inside it
cur.execute("SELECT * FROM items WHERE id = ?", (item_id,))

The rest of this page is about recognising the first shape from the outside, with no access to the source.

Payload shapes

Each shape asks one question. None of them proves anything on its own.

ShapeExampleWhat it probes
Lone quote'Whether the value lands inside a quoted string literal, and whether the unbalanced quote that results breaks parsing
Doubled quote''Whether a balanced pair is accepted where the single quote was not. Separates a parse failure from an input filter that rejects the character
Always-true tail' OR '1'='1Whether a boolean condition you appended is evaluated by the database rather than compared as text
Always-false tail' AND '1'='2The other half of the pair. A probe that can only ever show you more rows cannot be told apart from a filter that stopped working
Comment terminator-- followed by a space, #, /*Whether the remainder of the query after your value can be discarded. Which one works also narrows down the dialect
Arithmetic in a numeric field2-1 where 1 is validWhether the value reaches an expression the database evaluates, instead of being converted by the application first
Time delayThe dialect's sleep function inside a booleanWhether the query runs at all, when status, length and body are all constant

The shapes are the vocabulary. Reading the answer is the skill.

Reading the response

A probe is only interpretable against a baseline you recorded yourself.

  1. Send the request with an ordinary, valid value. Record the status code, the body length in bytes, the response time, and the exact wording of anything that resembles an error.
  2. Send the same request again with only the probe changed. Method, path, every other parameter and every header stay byte-identical.
  3. Compare the second against the first, not against what you expected to happen.

A probe that changes nothing is a result, not a failed attempt.

ObservableBaseline versus probeWhat the difference indicatesWhat else produces it
Status code200 becomes 500The server accepted the value and then failed while handling it. See HTTP status codesAny unhandled exception. A value that fails a type conversion never reaches a query and still returns 500
Status code302 becomes 200, or the reverseThe branch the application took changed — a lookup that found nothing now finds something, or the reverseA redirect that depends on session state rather than on the parameter
Body lengthSame status, different byte countThe row set changed size, or an error path replaced the normal bodyTimestamps, random tokens, and any endpoint that echoes your input back. Measure the size of the difference, not the fact of it
Error wordingA quoted fragment of SQL, a driver name, unrecognized token, near "..."The strongest single signal, because it names the parser that read your valueAn application that prints every exception, including ones raised by its own validation before any query
Response timeA consistent multi-second increase only when the injected condition is trueThe query ran and your condition controlled it. The only channel left when status, length and body never moveCold caches and first hits on an endpoint. Repeat and compare medians, never single readings
Equivalent pairTwo values, equal after SQL evaluation, produce the same responseThe evaluation happened on the server. See belowNothing, when the pair is chosen correctly — which is why it is the strongest test

The equivalent pair

Send the same parameter two values that mean the same thing to a database and different things to plain string handling.

For a numeric parameter, 1 and 2-1 denote the same number to SQL. An application that converts the parameter to an integer rejects 2-1 or fails on it. An application that pastes it into the query hands it to the database, which evaluates the arithmetic and returns exactly the rows that 1 returned. An identical response to two syntactically different values is evidence that something evaluated them.

For a string parameter, the pair is a literal against the same literal written as two concatenated pieces — abc against ab'+'c on dialects where + concatenates. The concatenation operator differs between dialects, so a negative result here rules out a dialect, not the vulnerability.

The pair is meaningful only when its two members are equal after SQL evaluation and unequal before it. If both members are simply valid input, matching responses tell you nothing at all.

The same probe on two parameters

This is the comparison that separates a finding from a coincidence: one probe, two parameters, two different answers.

What you sendParameter that is not injectableParameter that is injectable
A single ' appended to a valid valueResponse identical to the baseline — same status, same length, same body. The quote was stored, escaped, or compared as one more ordinary characterStatus moves into 5xx, or the body gains a parser message, or the body loses the section a matching row would have filled
'' in the same positionIdentical to the baseline again. Both quote forms are ordinary characters hereThe response returns to the baseline shape. Something is parsing quotes rather than storing them
' OR '1'='1 in place of a valid valueIndistinguishable from any other wrong value: the same "not found" body, the same statusDiffers from the wrong-value baseline — usually a longer body, or a different branch of the application
' AND '1'='2 in place of a valid valueAlso indistinguishable from any other wrong valueMoves the response in the opposite direction from the always-true form, and matches the empty-result shape
A comment terminator after a valid valueThe characters appear in the response verbatim, or the value is rejected as invalid inputWhatever the application appended after your value stops taking effect
The equivalent pair, 1 and 2-1The two responses differ, because only one member is valid inputThe two responses are identical
A sleep call inside a true conditionThe response time stays at the baselineThe response time rises by the requested delay, reproducibly

The first row carries most of the weight, and it has a limit worth stating: a lone quote that changes nothing rules out only the noisy cases. Where the application catches its own exceptions and returns the same page either way, the difference exists in the time row, not in the length row.

Doing this on the platform

FactConsequence for probing
Every request the Code panel's requests sends is recorded in the traffic log, and so are Browser and Repeater requests — all three panels dispatch through the same layerOne list holds every variant you sent, whichever panel sent it
The traffic table shows #, Method, URL, Status and Time; Time is the dispatch duration rounded to whole milliseconds, so 0ms is a normal readingThe Time column is a timing channel that needs no code. Treat single-digit readings as noise
Clicking a row expands it into Request and Response tabs, showing the reassembled raw messageThis is where the byte-level comparison between baseline and probe happens
The Repeater tab appears only on challenges that grant it; when it is absent, no "Send to Repeater" button is rendered in the traffic logWhere it is missing, the Code panel is the way to send variants
A Repeater snapshot stores the request text only, never the responseRecord the response yourself before restoring another snapshot, or the comparison is lost
The host in a URL passed to requests is ignored; only the path and query string are used, and every request reaches the current challenge's applicationThe host name in the examples below is decoration. No probe can leave the challenge
timeout, verify, proxies, stream and cert are accepted by requests and then unusedA Python-side timeout cannot bound a slow probe. Nothing on your side will cut a request short
A challenge request that runs past the runtime's ten-second ceiling returns status 508 with {"error": "challenge runtime execution exceeded the time limit"}, and a banner then reports that the runtime was restarted and that any login or uploaded files from the challenge are goneKeep a requested delay well under ten seconds. A delay that overshoots destroys the session state you were testing with
Learner code is not cut off for running long — enumeration and sweeping are ordinary work here. The one ceiling is six hours per run, after which the output reads the runner stopped respondingA loop over many variants is expected to finish on its own. Only a sweep measured in hours meets the ceiling
A response object carries status_code, headers, content, text, url, request and raw, with text decoded as UTF-8r.elapsed is filled in by Session.send and covers the whole dispatch round trip, so timing needs no separate clock. raw is a stand-in whose read() returns empty bytes and which has no stream(), so stream=True and iter_content read nothing — take the body from .text or .content
requests is the real library, installed with micropip, and micropip stays available for other pure-Python packagesOrdinary requests usage applies, including Session

For how to open the panel, run a script, and stop one, see the Code panel guide. If a script prints nothing at all, see troubleshooting.

Comparing variants from a script

The method is one parameter, one baseline, a list of variants, and a report of which variants moved the response.

python
import time
import requests

TARGET = "http://challenge.local"   # the host is ignored; only the path is used
ENDPOINT = "/api/items"
PARAM = "id"
BASELINE = "1"

VARIANTS = [
    ("lone quote",      BASELINE + "'"),
    ("doubled quote",   BASELINE + "''"),
    ("always true",     BASELINE + "' OR '1'='1"),
    ("always false",    BASELINE + "' AND '1'='2"),
    ("comment tail",    BASELINE + "' -- "),
    ("equivalent pair", "2-1"),
    ("control value",   "9999"),
]

def probe(value):
    start = time.perf_counter()
    r = requests.get(TARGET + ENDPOINT, params={PARAM: value})
    return r.status_code, len(r.content), round((time.perf_counter() - start) * 1000), r.text

base_status, base_len, base_ms, base_body = probe(BASELINE)
print(f"baseline         {base_status}  {base_len:>6} bytes  {base_ms:>5} ms")

for label, value in VARIANTS:
    status, length, ms, body = probe(value)
    diffs = []
    if status != base_status:
        diffs.append(f"status {base_status}->{status}")
    if length != base_len:
        diffs.append(f"length {length - base_len:+d} bytes")
    if ms > base_ms * 3 + 200:
        diffs.append(f"time {base_ms}->{ms} ms")
    body_note = "body identical" if body == base_body else "body differs"
    print(f"{label:16} {status:>3}  {length:>6} bytes  {ms:>5} ms  {body_note:14}  {'; '.join(diffs) or 'no other difference'}")

The control value row is the reason the report is readable: it is a valid-looking value that carries no SQL syntax, so whatever it shows is the endpoint's ordinary variation between two different inputs. Any variant whose difference is no larger than the control's is not evidence of anything.

The script sorts variants into "moved the response" and "did not". It decides nothing. Before believing any row, open the two entries in the traffic log and read their bodies in the Response tab one entry at a time — the panel keeps a single row expanded, so opening the second collapses the first. A length difference of a few bytes on an endpoint that echoes your input back is your input, not the query.

When you can call a parameter injectable

All of the following, together:

  • A probe carrying SQL syntax produces a response that differs from the baseline, and the difference reproduces across repeated sends.
  • The difference tracks the meaning of the injected SQL rather than its presence: the always-true and always-false forms of the same probe move the response in opposite directions.
  • Balancing the syntax restores the baseline — a doubled quote, or a comment terminator that discards the rest of the query, brings back the shape you started from.
  • A control value of similar length and character classes, carrying no SQL syntax, leaves the response at the baseline. This is what rules out an endpoint that is merely fragile.

None of these is sufficient alone, and four cases are specifically not enough:

  • A 500 by itself. Every unhandled exception produces one, including a failed integer conversion that happens long before any query is built.
  • Any single observation. One send is one sample. A difference that does not reproduce is not a difference.
  • One slow response. Timing evidence is a pair — the same probe with its condition true and with it false — measured repeatedly. A single slow reading is a cold cache.
  • A stack trace naming the database. It tells you which database is behind the application. It does not tell you that your value reached the query.

When none of the channels above moves at all, the parameter is not cleared — it is unproven. The remaining channel is timing, and a timing result carries the same burden as every other: a reproducible pair, not a single reading.