At 00:58 local time, two minutes before the end of the qualifiers for Google CTF 2019, drenched in sweat and shaking, I launched my exploit.
Time: T-90s | Remote is compiling my code, sloooowllllly.
Time: T-60s | Hundreds of addresses scrolling in the pwntools shell - I forgot to remove the damn
Time: T-40s | IT WORKED
What follows is a story of how a misunderstanding of the Rust compiler was miraculously resolved at the last minute.
Like others in the sandbox category, this challenge involves escaping a restricted environment. The server is running a Rust binary, the source for which is given. The binary accepts some Rust code, splices it into a file while forbidding
unsafe blocks2, and then compiles and runs that as the child under a
ptrace-ing parent with strict
seccomp rules - no opening files, no
execve, etc. The rule that’s most interesting to us is that when we execute syscall number
0x1337, the parent will capture it and print the flag. So we need to somehow do that using only safe Rust code.
The author went out of his way to make this as difficult as possible. The
CARGO_TOMPL_TEMPLATE only includes two crates -
seccomp-sys. The latter is all unsafe, and the source-splicer checks for
"libc" strings, so we cannot use it. It also forbids uses of
! (except in
#, which means macros and compiler directives are out of the question (I don’t think Unicode helps here, but who knows). This prevents us from bypassing the
seccomp sandbox like in the unintended solution to Rusty Codepad from Hack.lu CTF 2018.
Finally, Non-Lexical Lifetimes are enabled with
#![feature(nll)], which in practice means better checking of borrow lifetimes by the compiler. In particular, rust-lang/rust#31287, which I and others used to solve Rusty Codepad, doesn’t work any more.
From the challenge conditions, it seemed quite clear that I’d need a soundness bug in order to execute arbitrary code. The challenge archive includes a Dockerfile, which has the exact Rust toolchain listed -
nightly-2019-05-18. This being fairly new (
1.36, while the current nightly at the time of writing is
1.37), I realized that the right bug to use is likely still not fixed, so instead of checking release notes, it’s better to look at the issue tracker.
Using the function
mk_any to instantiate a variable effectively leaves it uninitialized - it will reuse whatever value was already on the stack. With this in mind, an uninitialized pointer -
let mut b: Box<usize> = mk_any(); - should allow us to access arbitrary memory as long as we can force the stack frame to contain the right address. As it turned out, doing that is not at all so easy, but after writing the PoC I optimistically assumed the arbitrary read/write primitive would work, and went on to write the rest of the exploit.
The easy-ish part
Assuming an arbitrary R/W in the form of two functions,
mov rax, 1337; syscall; is simple - just overwrite the on-stack return address with a ROP chain. Although the right gadgets could (and probably do) appear in the binary, it’s more convenient to force their existence by using them as immediates:
This will compile to, more or less,
mov rax, <gadget>;, and since instructions are stored in RX pages, we can call the gadgets. Directly referencing an offset from the function name wasn’t working4, so to find the gadgets, I loop and search through the code section using
read. After the CTF, the author hinted that the following also works, with no searching:
Knowing these, I should be able to overwrite the return address of
$crate::main (i.e. the “real” one, not the top-level one that Rust binaries have), right? Well, I would if it had one. For reasons I will explain in the next section, it would stubbornly end with a
ud2 instruction, which causes
SIGILL. At that point, I (incorrectly) assumed that Rust does something like
exit() at the end of
$crate::main instead of using
ret, implying I had to create an inner, legit stack frame with a
ret in the epilogue.
This required more work than simply defining a separate
fn. You see, my code was compiled using
cargo build --release, which translates to
rustc -C opt-level=3. The Rust compiler inlines functions aggressively. Forcing it not to do that without using
#[inline(never)] (remember, no
#s) involves some of possibly the jankiest Rust you will ever see:
By the way, I’m sure there are better ways to prevent inlining, but it works ¯\_(ツ)_/¯.
At this point, it only remains to overwrite the return address:
About that primitive..
I finished the above about 3 hours before the CTF ended. What remained was to turn the malformed
Box<usize> into a working
write implementation. Because the
mk_any() call reuses existing stack contents, my intention was to have three functions:
read2(addr: usize) -> usize, which assuming the memory in which its stack frame is created has the right contents already, will make a
Boxand dereference it to read memory. My initial, broken version looked more or less like so:
stack_maker(addr: usize), which sets up the stack contents for
read(addr: usize) -> usize, which just calls
read2must not be inlined in order for the memory reuse trick to work, but
readitself could be.
After I implemented the above, all hell broke lose. I started running into increasingly bizarre issues - my opaque predicates going into the wrong branch, unexpected values popping out of nowhere,
SIGSEGVs and messages like
"thread 'main' panicked at 'index out of bounds: the len is 94012078353104 but the index is 1'" (I still don’t know how this one is possible).
About an hour before the end, I found what I now think causes all this (but please do correct me if this is wrong). Remember that
ud2 I spotted in
$crate::main? That’s not how sane Rust works - it’s a sign of unreachable code.
The compiler performs unreachability analysis and marks everything after the call to
mk_any() as “should never run”. As a result, suffering. At this point, I resorted to performing random permutations on the source code in desperate hope one of them works - and somehow, this change to
I don’t really know why (and probably don’t want to), but if I were to guess, it would be that borrowing (i.e. creating a shared reference to)
b forces the compiler to treat it as a valid value.
I guess the lesson here is either that sometimes not giving up is worth it, or that CTFs are an unhealthy, dangerous habit kids should stay away from. Huge thanks to mlen for creating this fun, challenging task.
Update (2019-06-28): This blog post by a member of the Rust language team goes into detail on the impact of Non-Lexical Lifetimes.
Up from 36th last year. By induction, in 2020 we’ll be (-4)th.↩︎
Actually the check for
"unsafe"strings, present in the source, wasn’t working on remote, but there is also a
#![forbid(unsafe_code)]compiler directive in the template, so I couldn’t use
(&main as *const u64)and
(main as *const u64)are not the same.↩︎