32C3 CTF: Ranger writeup
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:
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
- Reads up to 256 bytes from the socket and stores them in
- set_content (Command 2)
- Reads up to 256 bytes from the socket and stores them in
client->content
- Reads up to 256 bytes from the socket and stores them in
- set_xor_key (Command 3)
- Reads up to 256 bytes from the socket and stores them in
client->xor_key
- Reads up to 256 bytes from the socket and stores them in
- write_file (Command 4)
- Writes
client->content
to the file specified inclient->filename
- Writes
- read_file (Command 5)
- Reads up to 256 bytes from the file specified in
client->filename
intoclient->content
- Reads up to 256 bytes from the file specified in
- write_file_crypt (Command 6)
- XORs
client->content
with the XOR key - Writes the result to the file specified in
client->filename
- XORs
- read_file_crypt (Command 7)
- Reads the file specified in
client->filename
and XORs the data withclient->xor_key
- Stores the resulting data in
client->content
- Reads the file specified in
- base64_encode (Command 8)
- Base64 encodes
client->content
and writes the result back intoclient->content
- Base64 encodes
- base64_decode (Command 9)
- Base64 decodes
client->content
and writes the result back intoclient->content
- Base64 decodes
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:
- By setting arg2 to
SECCOMP_MODE_STRICT
(#defined as 1), the process will only be allowed the syscalls read, write, _exit, and sigreturn. - 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:
So, dumping the BPF bytecode is pretty straightforward:
- In gdb, set a breakpoint at the call to prctl
- 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. - 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
:
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:
- Move the stack into the .bss so we have more space (this isn’t strictly required but makes it a little easier)
- Leak a pointer into the libc by calling
write()
with our socket number and a pointer into the .got - Jump to
read()
and receive the second stage of the ROP chain which will be dynamically generated based on the libc base address - Prepare a call to
mprotect()
and change the protections of the .bss to RWX - 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:
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.