๐ Injection Classes: SQLi, XSS, SSRF, Traversal and Friends
Injection Classes: SQLi, XSS, SSRF, Traversal and Friends
Every injection bug in this module is the same bug in a different costume. Somewhere a program builds a string mixing data (from a user) with instructions (from the developer) and hands it to an interpreter. The interpreter cannot tell the two apart, because by the time it sees the string the distinction has been erased. Data crossed into a control channel.
That framing predicts the fix as well as the flaw. Anything that keeps data in a separate channel from instructions holds permanently โ bound parameters, argument arrays, a template variable instead of a template. Anything that tries to make dangerous data look safe while leaving it in the same channel โ escaping, blocklists, keyword stripping โ holds until someone finds a context you did not anticipate. There are always more contexts.
SQL injection
// The flaw
$sql = "SELECT id, email FROM users WHERE email = '" . $email . "'";
// $email = "' OR '1'='1" collapses the condition to always-true.
// The fix โ the value never touches the SQL text
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = ?');
$stmt->execute([$email]);
A prepared statement sends the query structure and the parameter values to the database over separate channels. The parser has already finished with the statement before the value arrives, so a value cannot change the statement's shape. This is a structural guarantee, not a filter.
Why escaping fails. Escaping is context-dependent and the context is easy to get wrong. Quote-escaping does nothing in a numeric context โ WHERE id = $id has no quotes to break out of, so 1 OR 1=1 walks straight in. Escaping functions have historically been defeated by character-set mismatches between the client connection and the server. And escaping cannot apply to identifiers at all: table names, column names and ORDER BY directions are syntax, not data, so a dynamic sort column needs an explicit allowlist mapping user input to a fixed set of known-good identifiers. Blocklists โ banning UNION, -- or the word SELECT โ lose to case variation, comments, whitespace and encoding, while breaking anyone whose surname contains an apostrophe.
ORMs are not automatic protection. An ORM parameterises the queries it generates, which covers most day-to-day use, but it also exposes raw escape hatches โ whereRaw, .raw(), extra(), string-built HAVING clauses, dynamic table names โ and those are string concatenation with a friendlier name. Grep for them and audit each one. Watch for second-order injection too: a value stored safely as data and concatenated into a query by a later batch job is still injection, just delayed. And give the database user minimum rights โ no FILE, no DDL, no other schemas โ so a bug stays a disclosure incident rather than a server compromise.
Command injection
# The flaw โ a shell parses this string, and ; is a shell metacharacter
os.system("ping -c 1 " + host) # host = "8.8.8.8; id"
# The fix โ no shell, arguments passed as a list
subprocess.run(["ping", "-c", "1", host], shell=False, timeout=5)
Without a shell there is no metacharacter parsing, so ;, |, &&, backticks and $() are just bytes in an argument. Two residual issues. First, argument injection: a value beginning with - can still be read by the target program as an option, which for some binaries means writing files or running helpers โ validate the format (an IP address should match an IP address) and terminate option parsing with -- where supported. Second, prefer a library call over shelling out at all. This site's backend is an example of the layered version: exec() and shell_exec() are disabled at the PHP level, and the helper that runs external binaries takes an argument array and escapes each element individually rather than accepting a command string.
Cross-site scripting
XSS is injection into a browser rather than a database: attacker-controlled data ends up parsed as HTML or JavaScript in the victim's origin. Three delivery shapes:
- Reflected โ the payload arrives in the request and is echoed into the response. Needs the victim to follow a crafted link.
- Stored โ the payload is saved (comment, profile field, filename, log viewer) and served to everyone who views it. The dangerous one.
- DOM-based โ the server never sees the payload; client-side JavaScript reads from a source such as
location.hashand writes it into a sink such asinnerHTML. Invisible to any server-side scanner.
// The usual culprit
el.innerHTML = userComment; // parses HTML, runs event handlers
// The fix โ this sink cannot execute anything
el.textContent = userComment;
// Building structure safely
const a = document.createElement('a');
a.textContent = title; // text, not markup
a.setAttribute('href', safeUrl); // validate the scheme first
The real fix is contextual output encoding. "Escaping" is not one operation โ HTML body text, an HTML attribute value, a JavaScript string literal, a URL parameter and a CSS value each need different encoding, and applying the wrong one is equivalent to applying none. In practice: use your framework's automatic contextual escaping and never reach for the bypass โ dangerouslySetInnerHTML, v-html, |safe, triple-brace interpolation, innerHTML, outerHTML, document.write, insertAdjacentHTML. When user-authored HTML genuinely must render, run it through a maintained HTML sanitiser, never a regular expression of your own. Validate URL schemes before assigning to href or src: javascript: is a script-execution context.
CSP is defence in depth here, not the fix โ a nonce-based policy stops most injected scripts, but a site relying on CSP instead of encoding is one policy mistake from unprotected. And HttpOnly cookies limit token theft without blunting XSS much: script running in your origin can simply act as the user, session attached.
Template injection
# The flaw โ the user's string becomes part of the TEMPLATE
render_template_string("Hello " + name) # name = "{{7*7}}" renders "Hello 49"
# The fix โ the user's string is DATA passed to a fixed template
render_template("hello.html", name=name)
Server-side template engines are programming languages. If user input reaches the template source rather than its variable bindings, the user is writing code that runs on your server, and in several engines the object graph reachable from a template leads to arbitrary execution. The rule is absolute: templates are static assets authored by developers, user data is only ever bound as a variable. The same shape exists client-side in frameworks that evaluate expressions in the DOM.
Path traversal
# The flaw
open("/var/www/uploads/" + name) # name = "../../../../etc/passwd"
# The fix โ resolve FIRST, then check, then open
import os
base = os.path.realpath("/var/www/uploads")
target = os.path.realpath(os.path.join(base, name))
if not target.startswith(base + os.sep):
raise PermissionError("outside upload root")
open(target)
Ordering is the whole lesson. Checking the raw string for .. before decoding and normalising it is bypassable in a dozen ways: percent-encoding (%2e%2e%2f), double encoding, ....// surviving a naive single-pass strip, backslashes on Windows, over-long UTF-8 forms, and symlinks that leave the root even though the textual path never does. Canonicalise to a real filesystem path first โ realpath resolves .., symlinks and duplicate separators โ then compare. Note the trailing separator above: without it, base /var/www/up happily matches /var/www/uploads-evil. Better still, do not accept paths from users at all โ store an opaque identifier and map it server-side to a filename.
Server-side request forgery
GET /api/thumbnail?url=http://169.254.169.254/latest/meta-data/
SSRF turns your server into the attacker's HTTP client, inside your network, past your perimeter. The highest-value target on a cloud instance is the link-local metadata service at 169.254.169.254: on AWS with IMDSv1 enabled, a plain GET can return temporary role credentials, whereas IMDSv2 requires a PUT for a token and then an X-aws-ec2-metadata-token header on every request โ a design chosen precisely because a naive SSRF cannot issue it. Google Cloud's metadata server requires a Metadata-Flavor: Google header and Azure's requires Metadata: true, for the same reason. Beyond metadata, the targets are anything bound to loopback or an internal range: admin consoles, queues, unauthenticated internal APIs.
Why a blocklist loses. A filter rejecting strings that contain 127.0.0.1 or 169.254 has to survive decimal and octal IPv4 forms, IPv4-mapped IPv6 ([::ffff:127.0.0.1]), 0.0.0.0, hostnames that simply resolve to a private address, redirects to a private address after the check passed, schemes such as file://, gopher:// and dict://, and DNS rebinding โ where the attacker's name resolves public for the validation lookup and private for the connection moments later, because the record's TTL is zero.
What holds: an allowlist of permitted destinations. Where a general fetcher is genuinely required, resolve the hostname yourself, validate every returned address against private, loopback, link-local and reserved ranges, then connect to that validated address, passing the original hostname only as Host and SNI โ connecting to the address you checked instead of re-resolving is what closes the rebinding window. Restrict schemes to http and https, disable redirect following or re-validate each hop, cap response size and time, and never echo the raw upstream body to the caller. Enforce egress filtering at the network layer too, so application code is not the only thing between an attacker and your metadata service.
This site's backend applies that pattern: every endpoint taking a hostname resolves its A and AAAA records and refuses the request if any resolved address falls in a private or reserved range, with per-IP rate limiting and strict parameter validation before any outbound call. The honest caveat applies to every implementation of it โ resolve, validate, then connect by hostname leaves the theoretical rebinding window that only address pinning fully closes.
XML external entities (XXE)
<?xml version="1.0"?>
<!DOCTYPE r [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<r>&xxe;</r>
A parser configured to resolve external entities fetches that URI and substitutes the contents into the document โ a file read, and often SSRF too since the URI can be http://. Nested entity expansion also gives a denial of service ("billion laughs"): gigabytes from a few hundred bytes. Fix: disable DTD processing in the parser. Java: factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true). Python: use defusedxml rather than the standard parsers. PHP: external entity loading has been off by default since libxml 2.9, so the danger is code that re-enables it. Remember the non-obvious XML entry points โ SOAP, SVG uploads, and Office/OpenDocument files, which are ZIP archives full of XML. Where you control both ends, prefer JSON.
Insecure deserialisation
Formats that reconstruct arbitrary object types โ Java's ObjectInputStream, Python's pickle, PHP's unserialize(), Ruby's Marshal.load, .NET's BinaryFormatter โ do not merely restore data. They instantiate classes and invoke lifecycle methods (readObject, __wakeup, __destruct) while doing it. An attacker controlling the byte stream chains together classes already present in your application โ a "gadget chain" โ into arbitrary code execution, with no cooperation from your own logic.
data = pickle.loads(request.body) # attacker-controlled bytes โ code execution
Fix: do not deserialise untrusted input with a type-reconstructing format. Use a plain data format with an explicit schema and map fields onto your own types by hand. If a legacy format is unavoidable, authenticate the blob with an HMAC and verify it before parsing โ accepting that this only proves the data came from you, so it is a containment measure and not a licence to deserialise user input. PHP's unserialize($data, ['allowed_classes' => false]) narrows the surface. .NET's BinaryFormatter has been obsoleted and disabled by default in modern versions; do not re-enable it.
Cross-site request forgery
<!-- On evil.example, auto-submitted by script -->
<form action="https://bank.example/transfer" method="POST">
<input name="to" value="attacker"><input name="amount" value="5000">
</form>
CSRF is not injection into a parser โ it is injection into the ambient authority of the browser. The browser attaches the victim's session cookie to that cross-site POST automatically, so the request is authenticated even though the victim never intended it. Fix, in layers: set SameSite=Lax or Strict on session cookies, which stops the cross-site POST above outright; add a per-session synchroniser token that the server checks on every state-changing request, because SameSite behaviour is not identical across all browsers and a same-site subdomain can undermine it; for JSON APIs, require a custom header and a non-simple content type so that any cross-origin attempt triggers a CORS preflight your server will refuse; and check the Origin or Sec-Fetch-Site request headers as corroboration. One rule follows from all of this: never perform a state change on a GET request, because SameSite=Lax deliberately allows top-level GET navigation. test a host's CORS configuration โ ยท probe which HTTP methods it accepts โ
Open redirects
GET /login?next=https://evil.example # after auth, 302 to whatever "next" says
Low severity alone, high value as a component: it lends your domain's credibility to a phishing link, it can leak tokens through the Referer header, and in OAuth flows a redirect-URI weakness turns into an authorisation-code theft. Fix: only accept relative paths, reject anything containing a scheme or beginning with // (protocol-relative URLs point at another host), normalise backslashes before checking because some parsers treat /\ as //, or best of all map an opaque key to a server-side table of permitted destinations.
Same idea, other channels
| Channel | What crosses over | Structural fix |
|---|---|---|
| LDAP filters | *, ), & altering the filter tree | Parameterised filter APIs; escape per RFC 4515 rules |
| XPath queries | Quote break-out reshaping the node selection | Variable binding via the XPath API |
| NoSQL queries | An object such as {"$ne": null} where a string was expected | Type-check and cast input before it reaches the query |
| HTTP headers (CRLF) | %0d%0a in a redirect location splitting the response | Reject control characters; use header APIs that refuse them |
| Log files | Newlines forging fake log entries | Encode newlines and control bytes; structured logging |
| CSV export | A cell starting =, +, - or @ executed by a spreadsheet | Prefix such cells or quote defensively on export |
- One root cause: data reaching an interpreter through the same channel as instructions. Separate the channels and the class disappears.
- Parameterised queries, argument arrays, template variables and
textContentare structural fixes. Escaping and blocklists are context-dependent and lose over time. - Identifiers (table names, sort columns, file paths) cannot be parameterised โ use an allowlist mapping.
- For traversal, canonicalise before you validate, and compare against the base path with a trailing separator.
- For SSRF, allowlist destinations, validate the resolved address, connect to that address, and filter egress at the network layer.
- Layer CSRF defences: SameSite cookies plus a per-session token, and never change state on a GET.
- Test only what you own or have written permission to test.