Rust bills itself as a memory safe language, due in part to the way the compiler forces you to write code. With the exception of using the
unsafe keyword, entire bug classes are demolished: use after frees, buffer overflows, format string bugs, etc. You can read more about how Rust defines “safe” in the documentation.
In my spare time, I like to play CTF which often involves reverse engineering binaries to find bugs. As a small exercise, I took an approach similar to CTF to take a closer look at how Rust programs defend against out of bound reads/writes. All my experiments are on 64-bit linux.
Let’s start by proposing a simple C program:
This lil program is a bit contrived but not unrealistic. We have an array of
chars containing 9 characters, but the for loop reads 30. From reading the source we know this is bad, but the compiler has no way of knowing that we would end up reading out of bounds. In a real vulnerability in a real program, the source of the out of bounds read would typically be user input being used to determine how far into the buffer we should read. In many cases the same code paths could also lead to an out of bounds write, possibly leading to code execution. Running the program, we get this output:
Byte at  = A Byte at  = $ Byte at  = $ Byte at  = : Byte at  = Byte at  = C Byte at  = 0 Byte at  = 0 Byte at  = 1 Byte at  = ^D Byte at  = @ Byte at  = ^@ Byte at  = ^@ Byte at  = ^@ Byte at  = ^@ Byte at  = ^@ Byte at  = <F0> Byte at  = <CD> Byte at  = <95> Byte at  = f Byte at  = <FF> Byte at  = ^? Byte at  = ^@ Byte at  = ^@ Byte at  = ^@ Byte at  = G Byte at  = ^Q Byte at  = <D4> Byte at  = F Byte at  = <BA>
Let’s look at the (unoptimized) code generated for
use_arr to see what’s happening:
At 0x40056c and 0x400570 we see rdi and esi being loaded into the stack. This is the calling convention on x86-64; we are loading the two arguments (
char* buf and
int index, respectively) into the stack. After some shuffling and integer promotion, the base of the array is added to the offset (0x400578), the byte from that memory location is read (0x40057c), and then that byte is printed (0x400594). This is pretty much what we would expect: reading/writing out of bounds is a result of naively adding offsets. There’s not much else that we can do since there’s no way of knowing if we’re within range.
In this example, we just end up leaking useless data from the stack. In a real world application however, a bug like this could end up leaking critical information from the process’ memory: passwords, encryption keys, etc.
Let’s translate the C to its equivalent in Rust:
Running it, we quickly see Rust in action:
Let’s take a look at the code generated:
Oof, that’s a lot more assembly to read now. One thing to immediately notice is that the addresses are much shorter. The Rust compiler by default generates a Position Independent Executable which means this program can be placed anywhere at runtime. This makes the program safer by opting into ASLR. Additionally, the names are mangled. Rust, C++, and others do this to help out the linker that would otherwise have trouble with same named functions when it comes to polymorphism, function overloading, and so on.
Starting at 0x79e2, we begin setting up the call to
use_slice. We follow the same x86-64 calling convention as always and start loading up the registers;
QWORD PTR [rsp+0x48]
But wait! That’s three arguments, and use_slice is clearly defined to take just two. Where’s this spooky third parameter coming from?!? It turns out that Rust sneaks in an extra parameter to the function and uses it to hold the size of the slice we’re passing in. The other two parameters are what we would expect;
rdi holds the address of the buffer (created on the stack at 0x7911 to 0x794e) and
rdx holds the current index of the slice, pulled from the parent function’s stack frame.
Following the use_slice function down to 0x77ce, we end up at a compare; we’re comparing
rcx (which now holds the input index) and
rdi (which now holds the slice size). With the
cmp and following
setb r8b instruction, we end up setting
r8b to 1 if the input index is below the slice size. That value is later used to branch at 0x77e8 and 0x77ea.
Following the failure branch to 0x78e0, we can see the binary calls panic_bounds_check, which is what’s used to print that error message and exit. The success branch takes us to 0x77ef, where we load
rcx with the address of the input buffer (0x7803) and
rsi with the index we want to read at. We then perform the addition (0x780d) - exactlylike we did in the C program - and use its value to start crafting the format string to print (0x782f).
So from this example, we can see that while Rust and C do the same thing to read the byte, Rust sneaks in an extra parameter and some logic to make sure what you’re doing is acceptable.
Dynamically Sized Slices
Wait, what about slices whose sizes aren’t known at compile time, like with Vectors? The previous example had the length hardcoded into the logic (0x79e2) but that clearly doesn’t work for something like a Vector that can hold any number of elements at any given time. The answer is somewhat obvious: the program just grabs the size and uses that as the parameter.
There are a few things to note here. At 0x7ffd, we’re grabbing the Vector’s current size. In Rust, a Vector is a struct of 3 elements: a pointer to the data, the capacity, and the size. Since my platform is 64bit, reading
[rax+0x10] ends up requesting the 3rd element (size). That size and the pointer to the data are then passed to slice::from_raw_parts (0x8006). The function then returns the data needed to pass to a function that accepts slices:
rax has the data and
rdx has ths size.
For a language that is mostly safety it’s no surprise that any of this is taking place behind the scenes. Even though it’s easy to have an idea how these things are implemented, it’s still a fun exercise to take apart the code that’s actually executing on your machine and see things for yourself.
A question I saw discussed often (not any more, cause I’m late to Rust) is whether or not Rust would have stopped heartbleed. The tl;dr on heartbleed was that you could send it a packet containing a size and arbitrary data, and the server would respond with the data at the size you specified. If you sent it a size bigger than the length of the data, you would end up getting extra data back - often containing sensitive information. Based on the experiments above, we know that a binary generated by the Rust compiler would not let you do that.