Chapter 5 · Attacks & Defenses· Intermediate· ~20 min

Web Vulnerabilities

Going deeper than the OWASP Top 10 list: XSS variants modern frameworks miss, CSRF defense in depth, SSRF in cloud environments, and the deserialization bug class.

You will learn

  • The three XSS contexts that need different escaping (HTML body, attributes, script blocks)
  • What Content-Security-Policy actually buys you, and how nonces and strict-dynamic work
  • Why SameSite=Lax plus a CSRF token plus an Origin check is the modern stack
  • How SSRF combines with the AWS / GCP metadata endpoint to escalate quickly
  • The deserialization bug class and why "do not deserialize untrusted data" is the only safe rule

XSS: three contexts, three escapes

Naming "stored vs reflected vs DOM-based" describes where the bug lives. Naming the context the user data lands in tells you how to fix it. The three contexts:

  • HTML body: escape < > & " '. Modern templating defaults handle this.
  • HTML attribute: same escapes, plus quote the attribute value. Unquoted attributes are vulnerable to event handlers like onmouseover=....
  • JavaScript context: HTML-escaping is not enough. Use JSON encoding: const data = JSON.parse('<%- JSON.stringify(value) -%>'). Even then, beware of </script> sequences in the value.

DOM XSS specifically: any sink that interprets a string as code or HTML (innerHTML, document.write, eval, setTimeout(string), location = string) reading from any source the attacker controls (location.hash, postMessage, fetch from a third party). Audit by grepping for the sinks; modern frameworks limit them but do not eliminate them.

CSP: nonces, strict-dynamic, and the trap of unsafe-inline

A Content-Security-Policy header tells the browser where scripts may load from. The modern recommended shape is nonce-based with strict-dynamic:

Content-Security-Policy: default-src 'self';
  script-src 'nonce-XYZ' 'strict-dynamic' 'unsafe-inline' https:;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'self';

How it works: legitimate inline scripts carry the matching nonce attribute. strict-dynamic tells the browser that any script trusted via nonce can in turn load other scripts dynamically (this is what makes React or similar work without listing every CDN). 'unsafe-inline' is listed for older browsers that ignore strict-dynamic - modern browsers ignore 'unsafe-inline' when a nonce is present.

Common pitfalls: forgetting object-src 'none' (allows Flash to bypass), forgetting base-uri (lets an attacker change the base URL), and using report-only mode forever without ever enforcing.

CSRF: defense in depth, layer by layer

  1. SameSite cookies. The default is Lax in modern browsers, which blocks the cookie on most cross-site POSTs. Strict blocks more at a UX cost; reserve for the most sensitive sessions.
  2. Anti-CSRF tokens. A per-session secret in a hidden form field, checked on the server. The classic defense. Still useful as a second layer.
  3. Origin / Referer check. Cheap, server-side, last-line defense. Modern browsers always send Origin on cross-origin POSTs.
  4. For JSON APIs: requiring a custom header (X-Requested-With) or a non-simple content type triggers a CORS preflight that the attacker's origin cannot satisfy. Often sufficient on its own.

SSRF and the cloud metadata endpoint

Server-Side Request Forgery becomes a privilege escalation when the server runs in a cloud environment. AWS, GCP, and Azure all expose an instance metadata endpoint at http://169.254.169.254/ that returns the instance's identity, including temporary credentials.

An SSRF that lets the attacker pick an arbitrary URL goes from "interesting" to "domain admin" in one request. Mitigations:

  • IMDSv2 (AWS): requires a session token in a header, which a vanilla SSRF cannot send.
  • Egress filter on link-local and private ranges (169.254.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) at the network or library layer.
  • Allow-list of permitted destinations in the application code that makes the outbound request.

Note that DNS rebinding can bypass naive allow-lists by returning a public IP at the first lookup and a private IP at the second. Resolve once and pass the IP, not the hostname.

SQL injection past simple strings

Parameterized queries fix string injection. They do not fix every SQLi class:

  • Identifier injection: parameters cannot be used for table or column names. Build an allow-list of permitted identifiers and look up against it.
  • Second-order injection: data is escaped on the way in, stored, then re-used unescaped later. The parameterized query at storage time does not help if the read site concatenates.
  • Stored procedures are not magic. They protect only if they themselves use parameters; concatenated SQL inside a stored procedure is just as injectable.
  • NoSQL injection: MongoDB query objects accept operators. Code like { user: req.body.user, pass: req.body.pass } lets the attacker send {$ne: null} as the password and authenticate.

Deserialization: the only safe rule

Many languages let you serialize objects to a string and reconstruct them later. Java ObjectInputStream, Python pickle, PHP unserialize, .NET BinaryFormatter, Ruby Marshal. Reconstructing arbitrary objects sometimes triggers code execution via "gadget chains" the attacker assembles from classes that happen to be on the classpath.

Real defense: do not deserialize untrusted data. Use a data format that cannot describe objects (JSON, MessagePack, protobuf) for anything that crosses a trust boundary. If you must deserialize, restrict to a known type and reject anything else.

The fix that is not

WAF rules block known gadget chain signatures. New gadgets are discovered constantly. Treat the WAF as a tripwire that buys you time, not as the actual defense.