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.
# 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.
| Shape | Example | What 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'='1 | Whether a boolean condition you appended is evaluated by the database rather than compared as text |
| Always-false tail | ' AND '1'='2 | The 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 field | 2-1 where 1 is valid | Whether the value reaches an expression the database evaluates, instead of being converted by the application first |
| Time delay | The dialect's sleep function inside a boolean | Whether 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.
- 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.
- Send the same request again with only the probe changed. Method, path, every other parameter and every header stay byte-identical.
- 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.
| Observable | Baseline versus probe | What the difference indicates | What else produces it |
|---|---|---|---|
| Status code | 200 becomes 500 | The server accepted the value and then failed while handling it. See HTTP status codes | Any unhandled exception. A value that fails a type conversion never reaches a query and still returns 500 |
| Status code | 302 becomes 200, or the reverse | The branch the application took changed — a lookup that found nothing now finds something, or the reverse | A redirect that depends on session state rather than on the parameter |
| Body length | Same status, different byte count | The row set changed size, or an error path replaced the normal body | Timestamps, random tokens, and any endpoint that echoes your input back. Measure the size of the difference, not the fact of it |
| Error wording | A quoted fragment of SQL, a driver name, unrecognized token, near "..." | The strongest single signal, because it names the parser that read your value | An application that prints every exception, including ones raised by its own validation before any query |
| Response time | A consistent multi-second increase only when the injected condition is true | The query ran and your condition controlled it. The only channel left when status, length and body never move | Cold caches and first hits on an endpoint. Repeat and compare medians, never single readings |
| Equivalent pair | Two values, equal after SQL evaluation, produce the same response | The evaluation happened on the server. See below | Nothing, 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 send | Parameter that is not injectable | Parameter that is injectable |
|---|---|---|
A single ' appended to a valid value | Response identical to the baseline — same status, same length, same body. The quote was stored, escaped, or compared as one more ordinary character | Status 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 position | Identical to the baseline again. Both quote forms are ordinary characters here | The response returns to the baseline shape. Something is parsing quotes rather than storing them |
' OR '1'='1 in place of a valid value | Indistinguishable from any other wrong value: the same "not found" body, the same status | Differs from the wrong-value baseline — usually a longer body, or a different branch of the application |
' AND '1'='2 in place of a valid value | Also indistinguishable from any other wrong value | Moves the response in the opposite direction from the always-true form, and matches the empty-result shape |
| A comment terminator after a valid value | The characters appear in the response verbatim, or the value is rejected as invalid input | Whatever the application appended after your value stops taking effect |
The equivalent pair, 1 and 2-1 | The two responses differ, because only one member is valid input | The two responses are identical |
| A sleep call inside a true condition | The response time stays at the baseline | The 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
| Fact | Consequence 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 layer | One 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 reading | The 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 message | This 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 log | Where it is missing, the Code panel is the way to send variants |
| A Repeater snapshot stores the request text only, never the response | Record 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 application | The 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 unused | A 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 gone | Keep 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 responding | A 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-8 | r.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 packages | Ordinary 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.
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
500by 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.