Handbooks / Web Security (OWASP) / Chapter 1
Injection
7 pages · ~5 min✓ Reviewed
Start here. Next up: Cross-Site Scripting (XSS).
Injection Mental Model
1. What Injection Is
- Injection = untrusted input crossing a trust boundary, run as code by an interpreter
- SQL engine, shell, filesystem, HTTP client
- Root cause: data channel and code channel mixed in one string
- Fix: keep them separate, not clean the string
2. Source to Sink
- Sourcequery, header, cookie, JSON, filename
- Boundaryuntrusted text in a string
- Sinkquery exec, shell, file open, fetch
3. Example
- The quote ends the string;
OR '1'='1is always true - Result: every row comes back
python
name = "' OR '1'='1" print("SELECT * FROM users WHERE name='" + name + "'")
input became SQL structure
output
SELECT * FROM users WHERE name='' OR '1'='1'
Remember
Never let data become code. Separate beats sanitize.
- Escaping vs parameters? Escaping patches one string; parameters never mix data into code
- Sink? The call that executes input: query exec, shell, file open, fetch
- Client-side validation? Attackers skip the browser and call the API directly
SQL Injection and Parameterized Queries
1. Parameterized Queries
- Template and values are sent separately
- Values stay data, never query structure
- Python
%s; JDBCPreparedStatementwith? - Unsafe: f-string,
+,String.formatin SQL
python
cur.execute("SELECT * FROM users WHERE id=%s", (uid,)) cur.execute(f"SELECT * FROM users WHERE id={uid}")
first is safe, second is injectable
2. Attack Types
| Type | Data leaves | Examples |
|---|---|---|
| In-band | in the response | UNION, error-based |
| Blind | inferred from true/false or delay | boolean, time-based |
| Out-of-band | sent via DNS or HTTP | exfiltration |
3. Identifiers
- Placeholders bind values only
- Not table names, column names or ORDER BY direction
- Allowlist those identifiers in code instead
python
SORT = {"name": "name", "new": "created_at"}
col = SORT.get(by, "name")
cur.execute(f"SELECT * FROM users ORDER BY {col}")ORM Pitfalls and Command Injection
1. ORM Escape Hatches
- ORMs parameterize by default
- Raw escape hatches reopen injection
- Risk = the string you build, not the library
| ORM | Unsafe call |
|---|---|
| Django | .raw(), .extra() |
| SQLAlchemy | text(f"...") |
| Sequelize | query with interpolation |
| Rails | where("name='#{x}'") |
python
q = text("SELECT * FROM users WHERE id=:id") conn.execute(q, {"id": uid})
safe: bind params
2. Operator Injection
- Body passed straight into a filter
- NoSQL: attacker sends
{"$ne": null}, matches anything - Mass assignment: body sets
is_admin=true - Fix: allowed fields only
3. Command Injection
- Input in a shell:
;|&&$()backticks add commands ping 8.8.8.8; rm -rf /runs both- Bug:
shell=Trueoros.systemwith input - Fix: argument array,
shell=False, allowlist and validate args
python
import subprocess host = "8.8.8.8" r = subprocess.run(["ping", "-c", "1", host], shell=False, capture_output=True)
host stays one argument
Path Traversal, SSRF and Input Validation
1. Path traversal
../, %2e%2e%2f, absolute paths, symlinks escape the base dir- e.g.
/download?file=../../etc/passwd - Blocking
../alone is bypassable - Fix: realpath, then check it starts with BASE; or map IDs to filenames
python
import os from urllib.parse import unquote BASE = os.path.realpath("/srv/files") name = unquote("%2e%2e/%2e%2e/etc/passwd") p = os.path.realpath(os.path.join(BASE, name)) print(p.startswith(BASE + os.sep))
decode first, resolve, then compare with the resolved BASE
output
False2. SSRF
- Server fetches a user-supplied URL, so attackers reach internal services
- Cloud metadata
http://169.254.169.254/leaks instance credentials - Allowlist hosts
- Resolve DNS, block private, loopback, link-local IPs
- Re-validate every redirect (stops 302-to-internal)
- Pin the validated IP (stops DNS rebinding)
- Disable
file://,gopher://schemes - Use IMDSv2 on AWS
3. Input validation
- Allowlist (type, length, format, range) beats blocklist
- Validate server-side
- Canonicalize (decode) before checking
Defense in depth
Validation never replaces parameterization or output encoding.
Common Mistakes and Fixes
1. Four mistakes, four fixes
| Mistake | Fix |
|---|---|
| Escaping or quote-stripping, not parameters | Bind parameters everywhere |
| Concatenated ORDER BY, table or column names | Map input to a fixed allowlist |
| Client-side only, or checked before decoding | Server-side check after canonicalization |
| DB account can DROP or read all schemas | Least privilege, read-only users, generic errors, no SQL or stack traces |
python
SORTS = {"name": "name", "new": "created_at"}
col = SORTS.get(sort, "created_at")
cur.execute(f"SELECT * FROM t WHERE id=%s ORDER BY {col}", (uid,))values bound, identifier picked from the allowlist
2. Defenses and review
- Parameterizestrongest
- Allowlistvalidate
- Least privilegelimit damage
- Safe errorsno leaks
- Review: hunt string-built SQL,
shell=True,os.system, raw ORM calls fed by input - SAST flags source-to-sink flows; DAST and fuzzing send payloads at live endpoints
- Logs: alert on SQL errors, 500 spikes, blocked
../paths
Remember
Escaping cleans the string; parameters keep data out of the code channel.
Injection Defenses Cheat Sheet
1. Attack, Sink, Fix
| Attack | Sink | Primary fix |
|---|---|---|
| SQLi | query exec | bind parameters |
| Command injection | shell | argument array, shell=False |
| Path traversal | file open | realpath + base-dir check |
| SSRF | outbound fetch | host allowlist + private-IP block |
2. Safe vs Unsafe
python
import os, subprocess cur.execute("... WHERE id=%s", (uid,)) # safe cur.execute(f"... WHERE id={uid}") # unsafe subprocess.run(["ping", "-c", "1", host]) # safe os.system(f"ping {host}") # unsafe
fragment: cur, uid and host come from the request
3. Payload Recognizers
| Payload | Attack |
|---|---|
' OR 1=1-- | SQLi |
; id | command injection |
../../etc/passwd | path traversal |
http://169.254.169.254/latest/meta-data/ | SSRF |
- SSRF: re-validate every redirect target
- SSRF: pin the validated IP to stop DNS rebinding
4. Layers in Order
- Parameterize
- Allowlist
- Least privilege
- Safe errors
- Monitoring
One rule
Never let data become code. Keep data and code in separate channels.
Check yourself
cur.execute('SELECT * FROM users ORDER BY %s', (col,)) runs with col = 'age'. Does it sort by the age column?
- No: the value is bound as the string literal 'age', so ORDER BY gets a constant, not a column
- MySQL silently sorts by that constant; PostgreSQL rejects it: non-integer constant in ORDER BY
- Either way the column is unused. Fix: allowlist column names in code
What happens with subprocess.run(f'ping -c 1 {host}', shell=True) when host is 8.8.8.8; id?
- The shell splits at
;and runs ping, then id - Fix:
subprocess.run(['ping','-c','1',host])with shell=False
A filter does name.replace('../',''). What path comes out for ....//etc/passwd?
../etc/passwd: removing the inner../leaves a new one- Fix: realpath, then check it starts with the base directory