โ† Back to Writeups
HTBN/AReversing

tiles + ai

XESXOR8/23/20268 min read
#reversing#htb#n/a

tiles + ai

Platform: B01Lersc | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: amx_b_layout_unpack, amx_emulation, column_permutation_tracking, sparse_matrix_extraction, state_space_bfs

Summary

Task: static x86_64 ELF that uses Intel AMX (tmm tile registers, tdpbssd) to implement a puzzle โ€” each hex-pair input multiplies one column of a 3x(16x16) byte state by a precomputed W matrix; after 3 rounds state[1][1,0] must equal 1. Solution: extract B/C/W matrices and initial states from .rodata, unpack the AMX-B interleaved layout to logical form, build a Python emulator of tdpbssd, and BFS over the reduced state space (each row holds at most one '1', so state = tuple of column indices).

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: b01lersc | ID: 20260418_b01lersc_tiles_ai
  • Tags: state_machine, x86_64, matrix_multiplication, amx, intel_amx, tile_registers, tdpbssd, ldtilecfg, static_elf
  • Indicators: unknown opcodes tdpbssd / tileloadd / tilestored / ldtilecfg in disasm, tmm0..tmm7 tile registers, challenge description mentions 'matrix multiplication, tile config struct at .rodata with rows/bytes_per_row fields (AMX TILECFG layout), requires Intel SDE with Sapphire Rapids preset to run
  • Source: 20260418_b01lersc_tiles_ai.md

Foothold

Vulnerability / Misconfiguration

  1. Amx_b_layout_unpack
  2. Amx_emulation
  3. Column_permutation_tracking
  4. Sparse_matrix_extraction
  5. State_space_bfs
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • amx_b_layout_unpack
  • amx_emulation
  • column_permutation_tracking
  • sparse_matrix_extraction
  • state_space_bfs
  • Tags: state_machine, x86_64, matrix_multiplication, amx, intel_amx, tile_registers, tdpbssd, ldtilecfg, static_elf

Original Writeup

<details><summary>Click to expand original content</summary>

Description

I love matrix multiplication ๐Ÿ˜

ncat --ssl tiles--ai.opus4-7.b01le.rs 8443

Files: a single static x86_64 ELF chall. The binary must be run under Intel SDE 10.8.0 with the Sapphire Rapids preset because it uses Intel AMX (Advanced Matrix Extensions), an ISA extension only present on very recent server CPUs.

The server runs three rounds. For each round it reads a line of hex digits from the user, runs a state transition, and only proceeds to the next round if the state reaches a specific value. If all three rounds succeed the server prints the flag.

Analysis

AMX crash course

AMX adds eight 2D "tile" registers tmm0..tmm7, each up to 16 rows ร— 64 bytes, configured via a TILECFG structure loaded by ldtilecfg. The key instruction is:

tdpbssd tmm_dst, tmm_a, tmm_b

It computes dst[m,n] += sum_k a[m,k] * b[k/4, n*4 + k%4] with 8-bit signed operands and 32-bit accumulators. The B operand uses a special AMX-B interleaved layout: a logical K ร— N matrix is laid out so that four consecutive rows of the logical matrix are packed into one row of the tile register, with each group of 4 bytes representing one column-block. Unpacking this correctly is the first non-trivial step.

Binary structure

Disassembly shows the tile config at 0x410100:

TileRowsBytes/row
tmm0, tmm1, tmm21616 (A operands, regular 16ร—16 byte)
tmm3, tmm4, tmm5464 (B operands, AMX-B layout of 16ร—16)
tmm6, tmm71664 (C accumulators, 16ร—16 int32)

.rodata contains four important tables:

AddressContent
0x409000 + h*0x100B(h) โ€” 16ร—16 byte matrix = e_h * e_h^T (column-h extractor)
0x40a000 + h*0x100C(h) โ€” 16ร—16 byte matrix = I - e_h * e_h^T (column-h eraser)
0x40b000 + h_high*0x2400 + l*0x900W(h_high, l) โ€” a 3ร—3 grid of 16ร—16 matrices
0x40f800 + i*0x300initial state for round i (3 ร— 16ร—16 = 768 bytes)

where h is 0..15, h_high = h >> 3, and l is 0..3.

Main loop semantics

The state is three 16ร—16 byte matrices S[0], S[1], S[2] (a "block" ร— "row" ร— "column" cube). Input is consumed as hex pairs (h, l) where h is a full hex digit (0โ€“15) and l is a hex digit but the binary rejects everything except l โˆˆ {0,1,2,3}.

For each pair, only column h of every block is touched. Let v_k = column h of block k (a 16-vector). The operation is:

new_v_m[r] = ( ฮฃ_k W(h_high, l)[m,k] ยท v_k )[r]  mod 256

and then each block's column h is replaced by new_v_m. All other columns are preserved (implemented via the C(h) zero-out + B(h) re-inject trick: first multiply by I - e_h e_h^T, then add back the new column).

After every pair, a validity predicate is evaluated on rows 0โ€“35 (block 0 + block 1 + first 4 rows of block 2, which gives 36 constrained rows). Each of those rows must:

  1. Contain only bytes in [0, 127], and
  2. Sum to less than 2.

So every constrained row has at most one 1 and the rest zeros โ€” i.e. the row is either empty or points at a single "column label" 0โ€“15. This collapses the state space enormously.

After all pairs of a round, the check is S[1][1][0] == 1 (byte at offset 0x110 from the state base).

The W matrices

Decoding each W(h_high, l) produces only four non-pathological maps:

(h_high, l)Meaning
(0, 0)"shift up" within blocks, with transfer block 1 โ†’ block 0
(0, 1)"shift down" within blocks, with transfer block 0 โ†’ block 1
(1, 2)"fold / shift up by 6"
(1, 3)"shift down by 6"

The other four combinations (0,2), (0,3), (1,0), (1,1) are filled with the constant 7, so applying any of them to a non-empty column produces row sums โ‰ฅ 7 and instantly fails the validity predicate. This is how the binary enforces the rule "first hex char in 0..7 โ‡’ second must be 0 or 1; first hex char in 8..15 โ‡’ second must be 2 or 3".

Semantically each input pair picks one of 8 legal transformations and applies it to the column-h "track" across the three blocks. The track has 36 positions (rows) with merge/destruction at specific boundaries and cross-block transfers โ€” a small state machine per column.

Solution

Step 1 โ€” extract matrices from .rodata

All four tables are extracted directly from the ELF. The only subtlety is unpacking AMX-B interleaved layout for B(h) and C(h):

def amx_b_to_logical(raw):
    # 4 K-blocks ร— 16 N-dwords ร— 4 bytes-per-dword
    M = [[0]*16 for _ in range(16)]
    for k_block in range(4):
        for n_dword in range(16):
            for j in range(4):
                M[k_block*4 + j][n_dword] = raw[k_block*64 + n_dword*4 + j]
    return M

W is stored in regular row-major 16ร—16 layout (it's used as the A-operand in tdpbssd, not the B-operand).

Step 2 โ€” Python emulator

The emulator operates on the reduced state โ€” for each constrained row we store only the column index of the 1 (or -1 for "empty"):

def normalize(S):
    result = []
    for m in range(3):
        rows = 16 if m < 2 else 4
        block = []
        for r in range(rows):
            ones = [c for c, v in enumerate(S[m][r]) if v == 1]
            block.append(ones[0] if len(ones) == 1 else (-1 if not ones else -2))
        result.append(tuple(block))
    return tuple(result)

Applying an op means: reconstruct column h as vectors, run the tdpbssd sum with 8-bit truncation, replace column h, re-validate.

Step 3 โ€” BFS per round

Because the state is a tuple of 36 small integers, the reachable space is tiny (~4400 states for the hardest round). Plain BFS with only the 8 legal moves per step finds optimal solutions quickly:

def bfs(initial, max_depth=60):
    init = normalize(initial)
    q = deque([(init, "")])
    seen = {init}
    moves = [(h, l) for h in range(16) for l in ((0,1) if h<8 else (2,3))]
    while q:
        norm, path = q.popleft()
        S = state_from_normalized(norm)
        for h, l in moves:
            ns = apply_op(S, h, l)
            if ns is None: continue
            nn = normalize(ns)
            if nn in seen: continue
            seen.add(nn)
            np = path + f"{h:x}{l}"
            if ns[1][1][0] == 1:
                return np
            q.append((nn, np))

Solutions found:

RoundLengthInput
010 pairs01e2e210f3f3f3010101
15 pairs01f320e201
252 pairs0120a2a2c231f2f2f2109393019311e320b211e3e300e31010923092921111c311d230e23030d310f3209201e2e210b3b3b30101

Step 4 โ€” submit to remote

import socket, ssl, time

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

solutions = [
    "01e2e210f3f3f3010101",
    "01f320e201",
    "0120a2a2c231f2f2f2109393019311e320b211e3e300e31010923092921111c311d230e23030d310f3209201e2e210b3b3b30101",
]

s = socket.create_connection(("tiles--ai.opus4-7.b01le.rs", 8443))
s = ctx.wrap_socket(s, server_hostname="tiles--ai.opus4-7.b01le.rs")
s.settimeout(10)
time.sleep(1)
out = b""
for inp in solutions:
    while True:
        try:
            c = s.recv(4096)
            if not c: break
            out += c
        except socket.timeout:
            break
    s.send(inp.encode() + b"\n")
    time.sleep(2)
time.sleep(3)
try:
    out += s.recv(8192)
except socket.timeout:
    pass
print(out.decode("latin-1"))

The server prints the flag after the third round is accepted.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR