Google CTF 2019 (Quals): Sandstone (Sandbox, 383 pts, 8 solves)
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
println!
s.
Time: T-40s | IT WORKED
Time: T-30s | Flag submitted, my team placed 16th1. I ponder my life choices.
What follows is a story of how a misunderstanding of the Rust compiler was miraculously resolved at the last minute.
The task
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 -
libc
and
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
print!
) and
#
, 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.
The bug
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.
Searching the Rust issue tracker for
I-unsound 💥
, I found #61696, which looked promising.3 I reused snippets from the tracker
to come up with a PoC:
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,
getting to
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
read
/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 aBox
and 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 forread2
:
read(addr: usize) -> usize
, which just callsstack_maker
and thenread2
. Crucially,stack_maker
andread2
must not be inlined in order for the memory reuse trick to work, butread
itself could be.
UD2
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,
SIGSEGV
s 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
read2
did:
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.
The solution is here and here.
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 useunsafe
.↩︎After the CTF, it turned out that the intended solution was to use #57893. The flag lists #31287, but that’s a typo - as I mentioned, it’s been fixed by NLL.↩︎
Turns out,
(&main as *const u64)
and(main as *const u64)
are not the same.↩︎