$ cat writeup.md…
$ cat writeup.md…
b01lersc
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).
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.
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.
Disassembly shows the tile config at 0x410100:
| Tile | Rows | Bytes/row |
|---|---|---|
| tmm0, tmm1, tmm2 | 16 | 16 (A operands, regular 16×16 byte) |
| tmm3, tmm4, tmm5 | 4 | 64 (B operands, AMX-B layout of 16×16) |
| tmm6, tmm7 | 16 | 64 (C accumulators, 16×16 int32) |
.rodata contains four important tables:
| Address | Content |
|---|---|
0x409000 + h*0x100 | B(h) — 16×16 byte matrix = e_h * e_h^T (column-h extractor) |
0x40a000 + h*0x100 | C(h) — 16×16 byte matrix = I - e_h * e_h^T (column-h eraser) |
0x40b000 + h_high*0x2400 + l*0x900 | W(h_high, l) — a 3×3 grid of 16×16 matrices |
0x40f800 + i*0x300 | initial state for round i (3 × 16×16 = 768 bytes) |
where h is 0..15, h_high = h >> 3, and l is 0..3.
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:
[0, 127], andSo 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).
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.
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).
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.
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:
| Round | Length | Input |
|---|---|---|
| 0 | 10 pairs | 01e2e210f3f3f3010101 |
| 1 | 5 pairs | 01f320e201 |
| 2 | 52 pairs | 0120a2a2c231f2f2f2109393019311e320b211e3e300e31010923092921111c311d230e23030d310f3209201e2e210b3b3b30101 |
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.
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar