Update 2021-01-31: Archive moved to the Internet Archive.
This is part of my series of writeups on the Shabak 2021 CTF challenges. See the complete collection here.
Introduction
The challenge description reads:
Feel free to run your binary and use our tracer-call® services!
We made sure you won't be able to read the flag anyway ;)
Well, that's not very much to go on, is it? At least we don't have a lot of code files to look at, so that's something. It looks like we have some sort of sandbox, and a sample executable to run inside it.
Let's start with the sandbox.
Boxing the sand
Looking at the main
function in sandbox.c
, we can see that the sandbox:
- Checks that the Linux kernel version is suitable for running the sandbox.
- Reads an ELF from
stdin
. - Checks it for some stuff.
- Initializes the protected region. Oooh, that's interesting.
- Forks a child process.
- Does different things in the parent and the child.
Now, I personally don't know much about ELFs, but a quick glance at the elf_check
function seems to indicate that any sections and segments within a valid ELF
(valid for the sandbox, that is) cannot reside in the protected region. So basically,
no link-time shenanigans for us.
What about this protected region? The initialization function just creates a temporary
file and returns its fd, but the really interesting stuff happens in parent_execute
.
This function sets an execution timeout for the sandboxed process, attaches to it
via ptrace
, then waits for the child to execve
the actual payload executable
(remember that we fork
-ed the child). When that's done, the parent injects
some syscalls (inject_initial_syscalls
) into the child. Specifically:
mmap
the protected region at a fixed address.mprotect
it withPROT_NONE
, so that reads and writes are impossible.- Close the protected region's fd, so we can't read from it.
Note: it's quite easy to get lost in all the framework code in the sandbox, i.e. all the code that moves stuff around, massages memory to inject syscalls, etc. I know I was tempted to dive into all this while I was solving the challenge. However, our first priority here is to get our bearings - understand at a high level what the code does. We can always come back later if we think something warrants further investigation.
Finally, the parent goes into a loop inside handle_tracer_calls
. This loops waits
for the child to make a syscall, and if it's the special "tracer call" - handles it.
The same code also disallows further calls to execve
(since that would be a pretty
easy sandbox escape).
Right, so that's the parent. What about the child? After the fork, the child installs
some limits on its own memory usage (install_resource_limits
) and also on the
syscalls it can use (install_seccomp_filter
). Finally, it execve
-s the payload.
What syscalls can we use? Not many, really. Notably, we can't open files, so we can't easily read the protected region from disk.
So that's the sandbox handled. Well, almost. There's still the matter of the "tracer calls".
Etch A Sketch
Looking at handle_single_tracer_call
, we can see that the "tracer" exposes several
things for us:
- NOP.
- Clearing the protected region.
- Writing the flag to the protected region.
- Calculating a checksum on the memory of the tracee.
- Getting/setting values.
It's a pretty safe bet that we're going to have to use the "tracer-call" that places the flag in the protected region. Let's try to narrow down the list of interesting calls further.
The NOP call, as expected, doesn't do anything. Also, from the looks of it, it doesn't have any potential for interesting shenanigans.
The call for clearing the protected region just calls memset
on it. Again, doesn't
look like anything interesting.
The calls for getting/setting values allow us to get/set values in 256-element
array of uint64_t
s. The bounds checking looks solid, so there doesn't seem to be any
potential for memory corruption.
So, we're left with the checksum "tracer-call". Here's its code:
|
|
In essence, this "tracer-call" performs the following:
- Checks that the memory to checksum does not overlap the protected region (otherwise, we could simply checksum each individial byte, and thus read the whole region).
- Allocates enough memory to hold the memory to be check-summed.
- Reads the memory into the newly-allocated buffer.
- Calculates the checksum, by XOR-ing all the bytes.
- Writes the result back, while checking that the output variable does not reside in the protected region.
A glimpse of forbidden knowledge
So, what can we do with this? At first glance, this looks perfectly normal. Except,
there's something strange: the size of the memory area to checksum is given as
a pointer. And what's more, upon closer examination, the function read_tracee_dword
does not verify that the address it is given does not lie within the protected region.
But how is that helpful? If we pass an address within the protected region as the size parameter, we'll just get the checksum of a region of memory with an arbitrary size. What's more likely, however, is that the function will fail to allocate enough memory, since a DWORD consisting of printable characters is pretty large.
What we really want to do is get the value of the size parameter back into our process. It is not written back directly by the tracer, so we can't get the literal number. But, perhaps there is a way to learn something about this number. Given that we completely control the beginning of the memory range to checksum, and given an unknown size of said range, what can we learn about the size by calling the tracer?
We know that if the range overlaps the protected region the tracer will
fail with EPERM
, since that's the first check it performs. If it doesn't,
then the tracer will either succeed, or fail with some other error code
(since EPERM
is pretty unusual). We also know that the protected range starts at
$\mathtt{0x600000000000}$. Therefore, given any two addresses $S$ and $P$ within our
process, with $P < \mathtt{0x600000000000}$, we can use the tracer to tell us
whether1
$$P + *S \ge \mathtt{0x600000000000}$$
In fact, since the maximum value of a DWORD is $\mathtt{0xFFFFFFFF}$, it is sufficient for $P$ to be in the closed range
$$[\mathtt{0x600000000000} - \mathtt{0xFFFFFFFF}, \mathtt{0x600000000000}]$$
Finally, note that for any address $S$ there exists an address $P$ within this range such that
$$P + *S = \mathtt{0x600000000000}$$
Armed with these observations we can conclude that if we don't know the value stored at some address $S$, we can instead find an address $P$ that satisfies the equality above, which will tell us the value at $S$.
How do we find this $P$? The naive approach would be to scan all addresses starting
from $\mathtt{0x600000000000}$ and going downwards, and return the last address
for which the tracer does not fail with EPERM
. However, this is wildly inefficient,
since in the worst case we're going to scan $2^{32}$ addresses. A better solution
is to use binary search. Specifically, we need the variant that finds
the leftmost element.
Putting it all together
We have a procedure for leaking a single DWORD out of the protected region. To read the complete flag we could just go over the whole page, but there's a better way: since we know the flag is textual, we can stop our scan once we encounter a DWORD that ends with a zero byte. To be sure that there are zero bytes after the flag we can use the "tracer-call" that zeroes-out the protected region before loading the flag into it.
And that's it! Side channels FTW.
-
Yes, that's the correct inequality. The
is_in_protected_region
function returnstrue
if the end of a memory region falls exactly on the start of the protected region. Technically, this is an off-by-one error :) ↩︎