ranger was a pwnable worth 400 points during 32C3 CTF 2015. This is just a brief writeup of my solution, mostly to document a few things (in particular seccomp, which I’ve wanted to document for some time now).

Overview

When connecting to the provided host and port, we receive the following message: Spawning your instance on port xxxx on this server. Your IP is 1.2.3.4.

The binary that was provided as download is then available at the given port and accepts connections from our IP only.

The server binary does roughly the following:

  • Open a listening socket and go into an event loop (select(2))
  • Upon seeing an incoming connection, verify that the remote IP address is allowed, then add the resulting file descriptor to the list of active file descriptors for select(2)
  • Whenever data is available on a client socket, go into a dispatch routine to handle the incoming packet

Additionally, for each client a client structure is created and stored on the heap:

struct client {
    char filename[256];
    char xor_key[256];
    unsigned char command;
    unsigned char filename_received;
    char content[256];
}

The dispatch routine reads a command from the socket and handles it accordingly. It supports the following commands:

  • set_filename (Command 1)
    • Reads up to 256 bytes from the socket and stores them in client->filename
    • Sets client->filename_received to true
  • set_content (Command 2)
    • Reads up to 256 bytes from the socket and stores them in client->content
  • set_xor_key (Command 3)
    • Reads up to 256 bytes from the socket and stores them in client->xor_key
  • write_file (Command 4)
    • Writes client->content to the file specified in client->filename
  • read_file (Command 5)
    • Reads up to 256 bytes from the file specified in client->filename into client->content
  • write_file_crypt (Command 6)
    • XORs client->content with the XOR key
    • Writes the result to the file specified in client->filename
  • read_file_crypt (Command 7)
    • Reads the file specified in client->filename and XORs the data with client->xor_key
    • Stores the resulting data in client->content
  • base64_encode (Command 8)
    • Base64 encodes client->content and writes the result back into client->content
  • base64_decode (Command 9)
    • Base64 decodes client->content and writes the result back into client->content

The last relevant piece of information is the fact that the binary installs a seccomp BPF filter to sandbox itself.

Side Note: Dumping seccomp rules

Since this turns up every now and then I’ve decided to document the steps needed to dump and disassemble seccomp BPF code from a process here.

Seccomp can be activated for a process through the prctl syscall (man 2 prctl) by providing PR_SET_SECCOMP as first argument. There are two different modes available:

  1. By setting arg2 to SECCOMP_MODE_STRICT (#defined as 1), the process will only be allowed the syscalls read, write, _exit, and sigreturn.
  2. By setting arg2 to SECCOMP_MODE_FILTER (#defined as 2), the caller can provide BPF bytecode to be run for each syscall. The result of that piece of code determines whether the syscall will be allowed or not.

In the 2nd case, the second argument has to be a pointer to a struct sock_fprog. In /usr/include/linux/filter.h we find:

struct sock_filter {    /* Filter block */
       __u16    code;   /* Actual filter code */
       __u8     jt;     /* Jump true */
       __u8     jf;     /* Jump false */
       __u32    k;      /* Generic multiuse field */
};

struct sock_fprog {     /* Required for SO_ATTACH_FILTER. */
       unsigned short           len;    /* Number of filter blocks */
       struct sock_filter *filter;
};

So, dumping the BPF bytecode is pretty straightforward:

  1. In gdb, set a breakpoint at the call to prctl
  2. Once hit, dump the sock_fprog pointed to by the 3rd argument. On 64bit executables the unsigned short len will likely be padded to 8 bytes, so the second quadword will be the pointer to the actual bytecode.
  3. Dump len*8 bytes of the bytecode: dump binary memory bpf_bytecode <start> <start + len*8>

Now you’ll probably also want a BPF disassembler. I used libseccomp which contains a small tool to do the job.

Here is the disassembly for the seccomp rules used by ranger.

 line  OP   JT   JF   K
=================================
 0000: 0x20 0x00 0x00 0x00000004   ld  $data[4]
 0001: 0x15 0x01 0x00 0xc000003e   jeq 3221225534 true:0003 false:0002
 0002: 0x06 0x00 0x00 0x00000000   ret KILL
 0003: 0x20 0x00 0x00 0x00000000   ld  $data[0]
 0004: 0x15 0x00 0x01 0x00000000   jeq 0    true:0005 false:0006
 0005: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0006: 0x15 0x00 0x01 0x00000001   jeq 1    true:0007 false:0008
 0007: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0008: 0x15 0x00 0x01 0x0000003c   jeq 60   true:0009 false:0010
 0009: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0010: 0x15 0x00 0x01 0x000000e7   jeq 231  true:0011 false:0012
 0011: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0012: 0x15 0x00 0x01 0x0000000f   jeq 15   true:0013 false:0014
 0013: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0014: 0x15 0x00 0x01 0x00000009   jeq 9    true:0015 false:0016
 0015: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0016: 0x15 0x00 0x01 0x00000017   jeq 23   true:0017 false:0018
 0017: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0018: 0x15 0x00 0x01 0x0000002b   jeq 43   true:0019 false:0020
 0019: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0020: 0x15 0x00 0x01 0x00000002   jeq 2    true:0021 false:0022
 0021: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0022: 0x15 0x00 0x01 0x0000000c   jeq 12   true:0023 false:0024
 0023: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0024: 0x15 0x00 0x01 0x00000003   jeq 3    true:0025 false:0026
 0025: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0026: 0x15 0x00 0x01 0x00000005   jeq 5    true:0027 false:0028
 0027: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0028: 0x15 0x00 0x01 0x0000000b   jeq 11   true:0029 false:0030
 0029: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0030: 0x15 0x00 0x01 0x0000000a   jeq 10   true:0031 false:0032
 0031: 0x06 0x00 0x00 0x7fff0000   ret ALLOW
 0032: 0x06 0x00 0x00 0x00000000   ret KILL

To understand it you might need some knowlege of the BPF VM. In a seccomp BPF program, $data will be a struct seccomp_data instance, the definition of which we can find in /usr/include/linux/seccomp.h:

/**
 * struct seccomp_data - the format the BPF program executes over.
 * @nr: the system call number
 * @arch: indicates system call convention as an AUDIT_ARCH_* value
 *        as defined in <linux/audit.h>.
 * @instruction_pointer: at the time of the system call.
 * @args: up to 6 system call arguments always stored as 64-bit values
 *        regardless of the architecture.
 */
struct seccomp_data {
        int nr;
        __u32 arch;
        __u64 instruction_pointer;
        __u64 args[6];
};

So, at 0000/0001 the filter verifies that the process architecture is x64. Afterwards, the program simply compares the syscall number to a whitelist of syscall numbers. If none match the process is killed (at 0032).

In this case, the following syscalls are allowed: read, write, exit, exit_group, sigreturn, mmap, select, accept, open, brk, close, fstat, munmap, mprotect.

My friend Niklas (niklasb) likes to automate things and so wrote a nice gdb plugin to do all of this.

The Bug(s)

There are quite a few bugs in this program…

First, write_file (in contrast to read_file) does not sanitize the path, we can thus write arbitrary files. This is pretty useless in this case though :)

Next, the base64 encode and decode functionalities are buggy. Both use strlen to determine the length of the input. base64_encode is vulnerable to a heap buffer overflow since it does not verify that the encoded output is still less than 256 bytes long. Moreover, it is also vulnerable to a stack buffer overflow since it encodes the data into a stack buffer before memcpy’ing it to the heap. The stack buffer is ~0x180 bytes large, but we can still smash it if we use the first vulnerability to “merge” two client structs into a single large C string which will be longer than 256 bytes:

+--------------------------------------+---------------+----------------------
|        1st client                    |  padding and  |        2nd client
| filename | xor key | flags | content | heap metadata | filename | ...
+--------------------------------------+---------------+----------------------

After triggering the heap overflow bug:

+--------------------------------------+---------------+----------------------
|        1st client                    |  overflowed   |        2nd client
| filename | xor key | flags | content . . . . . . . . . . .ename | ...
+--------------------------------------+---------------+----------------------

We now have a large, controlled C string on the heap that will now cause further corruption on the stack.

Unfortunately, we can only overflow with base64 encoded data, so only valid ASCII. We’ll hit a counter variable before corrupting the return address and the function will return. However, base64_decode is vulnerable to the same bug, and this time we can write arbitrary data onto the stack. We win.

Exploitation

Exploitation basically boils down to writing a multistage ROP chain that performs roughly the following steps:

  1. Move the stack into the .bss so we have more space (this isn’t strictly required but makes it a little easier)
  2. Leak a pointer into the libc by calling write() with our socket number and a pointer into the .got
  3. Jump to read() and receive the second stage of the ROP chain which will be dynamically generated based on the libc base address
  4. Prepare a call to mprotect() and change the protections of the .bss to RWX
  5. Read our shellcode from the socket into the .bss and jump to it

We can now read arbitrary files on the disk, but we have no way to list the available files since getdents(2) is blocked. At this point I started looking around in /proc to find some more files to read. I dumped the PPID of the ranger process through /proc/self/status and dumped its parent’s cmdline. That turned out to be a python process running the following script:

import subprocess
import time
import os
import sys
import socket
import atexit
import random
import shutil
import struct

CHALLSRC = '/home/challenge/challtree'
PASSWDREAD = '/home/challenge/pwread'
CHALL = './ranger'

def p32(x):
    return struct.pack('<I', x)

def recv_until(r, pattern):
    buf = ''
    while True:
        data = r.recv(1)
        if not data:
            return ''
        buf += data
        if pattern in buf:
            return buf

def send_cmd(r, cmd, data='', wait_for_ack=True):
    r.sendall(cmd + p32(len(data)) + data)
    if wait_for_ack:
        return recv_until(r, 'OK\n')
    return ''

def spawn_fakeuser(port):
    sys.stdout.close()
    sys.stderr.close()
    sys.stdin.close()
    time.sleep(10)
    flagpw = subprocess.check_output(PASSWDREAD).decode('hex')
    r = socket.socket()
    r.connect(('127.0.0.1', port))
    send_cmd(r, '\x01', 'flag')
    send_cmd(r, '\x05')
    send_cmd(r, '\x03', flagpw)
    send_cmd(r, '\x07')
    send_cmd(r, '\x00', wait_for_ack=False)
    r.close()

def spawn_challenge(port, ip):
    shutil.copytree(CHALLSRC, path_from_port(port))
    atexit.register(cleanup, port)
    os.chdir(path_from_port(port))
    sys.stdout.write('Spawning your instance on port %i on this server. Your IP is %s\n' % (port, ip))
    sys.stdout.flush()
    subprocess.call([CHALL, str(port), ip])
    sys.stdout.write('Exiting...\n')
    sys.stdout.flush()

def path_from_port(port):
    return '/tmp/%05i/' % port

def cleanup(port):
    shutil.rmtree(path_from_port(port))

def do_fork(ip):
    port = get_rand_port()
    try:
        pid = os.fork()
        if pid > 0:
            spawn_fakeuser(port)
        else:
            spawn_challenge(port, ip)
    except OSError, e:
        sys.stderr.write('Failed to fork: %s\n' % e.strerror)
        sys.exit(1)
    sys.exit(0)

def get_rand_port():
    while True:
        port = random.randrange(1025, 2**16)
        if not os.path.exists(path_from_port(port)):
            return port

if __name__ == '__main__':
    do_fork(os.environ['REMOTE_HOST'])

The most interesting part here is the spawn_fakeuser function which will connect to the service after 10 seconds, read the encrypted flag, decrypt it, and disconnect again. So, all we have to do is wait 10 seconds, then dump the 3rd client structure from the heap (we can find a pointer to it in the .bss).

...
000001e0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
000001f0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000200  00 01 33 32 43 33 5f 62  75 67 67 79 5f 63 68 61  |..32C3_buggy_cha|
00000210  6c 6c 65 6e 67 65 5f 69  73 5f 76 65 72 79 5f 62  |llenge_is_very_b|
00000220  75 67 67 79 00 00 00 00  00 00 00 00 00 00 00 00  |uggy............|
00000230  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

As usual, the exploit code can be found in our github repository.