first look
Here is the source code for the challenge:
The challenge allows you to create Exercise
structures and Workout
structures
and always wraps Exercises
in a Rc
.
std::rc::Rc
Rc
stands for reference counted
and is documented here in the rust documentation. Rc
is a type that
allows for shared ownership of a value through refcounting. When a Rc
is created, the internal refcount is set to 1. When a Rc
is cloned, the internal
refcount is incremented, and when dropped the refcount is decremented. Modifying the Rc
value is only allowed when the refcount is 1, meaning that there is only
1 owner and not breaking rusts shared mutability rules.
The internal structure of Rc
looks like this:
All of the cloned copies of a Rc
point to the same RcBox
which is allocated on the heap. When the strong refcount reaches 0 the internal RcBox pointer is freed.
This is safe because Rc
disallows cloning once the refcount reaches 0.
sus code 1
Immediately this part of the code looks suspicious:
since it contains an unsafe block. However this code, in the context of the rest of the program, is actually “safe”, because if an Rc
value exists in the
hashmap the refcount must be at least 1 and the backing pointer is safe to write to the underlying value. This part of the code is not exploitable, even though
it contains an unsafe block.
sus code 2
The type of the workouts vec is Vec<RepeatN<Rc<Exercise>>>
, which is unusual. I have never come across rust code that stored a RepeatN
iterator combined
with Rc
values. A quick search for “RepeatN” and “Rc” brings up a github issue that mentions a uaf bug in the standard library involving RepeatN
iterators
over Rc
values!
https://github.com/rust-lang/rust/issues/130140
The issue was opened on 09/09/24, and the provided rust-toolchain.toml
pins the rustc version to nightly-2024-09-09
. Not suspicious at all.
stdlib uaf
What does RepeatN
do? RepeatN
is an iterator type that returns the wrapped value n times before terminating.
The issues arises from how Rc
interacts with RepeatN
when the repeat count is 0.
This is the poc segfault provided by the github issue:
When a RepeatN
iterator is constructed with a count of 0 it will immediately drop the wrapped value. This causes problems for Rc
because the backing pointer
is freed, while RepeatN
still holds a reference. Cloning the RepeatN
iterator after the Rc
value if freed will increment the RcBox<T>->strong
count of the
now freed backing pointer, giving an uaf increment primitive.
uaf heap increment
In order to properly exploit this bug we need to allocate some heap object over the uaf’d object that has a useful value in the first qword (so it overlaps with
RcBox<T>->strong
). On gnu linux systems rust defaults to linking glibc and defers to glibc malloc to manage memory. RcBox<Exercise>
gets allocated in a 0x50
sized chunk, we need to somehow reclaim the freed RcBox<Exercise>
with a useful structure. Since we only control the first qword, the structure must have some
useful field in the first qword that allows for further exploitation.
It turns out that the backing memory for the Workout->exercises
vector is allocated in a 0x50 sized chunk!
The backing memory looks like this:
arbitrary heap increment
Remember that the Workout->exercises
vector stores RepeatN<Rc<Exercise>>
values. The original uaf increment is initially achieved through RepeatN
with a
count of 0, but now we can control the RcBox<Exercise>
pointer that RepeatN
uses. Using the initial uaf increment to modify the RcBox<Exercise>
pointer of
another RepeatN
, escalates the bug to arbitrary increment in the heap.
arbitrary heap read/write
With arbitrary increment can now modify the backing pointer of the Exercise->description
field.
Modifying Exercise("A")->description
to point to Exercise("B")->description
escalates our arbitrary increment bug to arbitrary heap read/write. Using
Exercise("A")
to modify the description field of Exercise("B")
to an arbitrary address, then read/writing from Exercise("B")
to achieve arbitrary
read/write.
rce
Normally the go-to libc rce is overwriting stdout and using the wide vtable to call system("/bin/sh")
, but this is rust the stdlib which does not use libc
stdout and stdin. Instead they use the file descriptors directly, bypassing stdout and making fsop impossible.
Alternatively we can attack the destructors that are called in exit, but that causes its own issues because of all the heap pointers that have been modified, which crashes the program when main returns.
There is another novel method which I discovered while playing another ctf. The full call chain looks like:
It only depends on the program using the __libc_read
function.
Breaking on __libc_read
and running the challenge shows that __libc_read
is used by rust stdlib!
elixir links:
__libc_read
SYSCALL_CANCEL
LIBC_CANCEL_ASYNC
__pthread_enable_asynccancel
__do_cancel
__pthread_unwind
_Unwind_ForcedUnwind
__libc_unwind_link_get
UNWIND_LINK_PTR
Here are the necessary requirements to trigger the call an arbitrary function (in this case exit):
Also setup a destructor to trigger a shell: