How Rust Prevents Out of Bound Reads/Writes
21 Jan 2017Introduction
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.
C
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 [0] = A
Byte at [1] = $
Byte at [2] = $
Byte at [3] = :
Byte at [4] =
Byte at [5] = C
Byte at [6] = 0
Byte at [7] = 0
Byte at [8] = 1
Byte at [9] = ^D
Byte at [10] = @
Byte at [11] = ^@
Byte at [12] = ^@
Byte at [13] = ^@
Byte at [14] = ^@
Byte at [15] = ^@
Byte at [16] = <F0>
Byte at [17] = <CD>
Byte at [18] = <95>
Byte at [19] = f
Byte at [20] = <FF>
Byte at [21] = ^?
Byte at [22] = ^@
Byte at [23] = ^@
Byte at [24] = ^@
Byte at [25] = G
Byte at [26] = ^Q
Byte at [27] = <D4>
Byte at [28] = F
Byte at [29] = <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.
Rust Slices
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;
rdi
=[rsp+0x7f]
rsi
=0x9
rdx
=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.
Conclusion
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.