The Rust compiler is written in Rust. This is overall a pretty common practice in compiler development. This usually means that the process of building the compiler involves downloading a (typically) older version of the compiler.
This also means that the compiler is vulnerable to what is colloquially known as the “Trusting Trust” attack, an attack described in Ken Thompson’s acceptance speech for the 1983 Turing Award. This kind of thing fascinates me, so I decided to try writing one myself. It’s stuff like this which started my interest in compilers, and I hope this post can help get others interested the same way.
To be clear, this isn’t an indictment of Rust’s security. Quite a few languages out there have popular self-hosted compilers (C, C++, Haskell, Scala, D, Go) and are vulnerable to this attack. For this attack to have any effect, one needs to be able to uniformly distribute this compiler, and there are roughly equivalent ways of doing the same level of damage with that kind of access.
If you already know what a trusting trust attack is, you can skip the next section. If you just want to see the code, it’s in the trusting-trust branch on my Rust fork, specificallythis code.
The attack
The essence of the attack is this:
An attacker can conceivably change a compiler such that it can detect a particular kind of application and
make malicious changes to it. The example given in the talk was the UNIX login
program — the attacker
can tweak a compiler so as to detect that it is compiling the login
program, and compile in a
backdoor that lets it unconditionally accept a special password (created by the attacker) for any
user, thereby giving the attacker access to all accounts on all systems that have login
compiled
by their modified compiler.
However, this change would be detected in the source. If it was not included in the source, this change would disappear in the next release of the compiler, or when someone else compiles the compiler from source. Avoiding this attack is easily done by compiling your own compilers and not downloading untrusted binaries. This is good advice in general regarding untrusted binaries, and it equally applies here.
To counter this, the attacker can go one step further. If they can tweak the compiler so as to
backdoor login
, they could also tweak the compiler so as to backdoor itself. The attacker needs to
modify the compiler with a backdoor which detects when it is compiling the same compiler, and
introduces itself into the compiler that it is compiling. On top of this it can also introduce
backdoors into login
or whatever other program the attacker is interested in.
Now, in this case, even if the backdoor is removed from the source, every compiler compiled using this backdoored compiler will be similarly backdoored. So if this backdoored compiler somehow starts getting distributed, it will spread itself as it is used to compile more copies of itself (e.g. newer versions, etc). And it will be virtually undetectable — since the source doesn’t need to be modified for it to work; just the non-human-readable binary.
Of course, there are ways to protect against this. Ultimately, before a compiler for language X existed, that compiler had to be written in some other language Y. If you can track the sources back to that point you can bootstrap a working compiler from scratch and keep compiling newer compiler versions till you reach the present. This raises the question of whether or not Y’s compiler is backdoored. While it sounds pretty unlikely that such a backdoor could be so robust as to work on two different compilers and stay put throughout the history of X, you can of course trace back Y back to other languages and so on till you find a compiler in assembly that you can verify1.
Backdooring Rust
Alright, so I want to backdoor my compiler. I first have to decide when in the pipeline the code that insert backdoors executes. The Rust compiler operates by taking source code, parsing it into a syntax tree (AST), transforming it into some intermediate representations (HIR and MIR), and feeding it to LLVM in the form of LLVM IR, after which LLVM does its thing and creates binaries. A backdoor can be inserted at any point in this stage. To me, it seems like it’s easier to insert one into the AST, because it’s easier to obtain AST from source, and this is important as we’ll see soon. It also makes this attack less practically viable2, which is nice since this is just a fun exercise and I don’t actually want to backdoor the compiler.
So the moment the compiler finishes parsing, my code will modify the AST to insert a backdoor.
First, I’ll try to write a simpler backdoor; one which doesn’t affect the compiler but instead affects some programs. I shall write a backdoor that replaces occurrences of the string “hello world” with “जगाला नमस्कार”, a rough translation of the same in my native language.
Now, in rustc, the rustc_driver
crate is where the whole process of compiling is coordinated. In particular,phase_2_configure_and_expand
is run right after parsing (which is phase 1). Perfect.
Within that function, the krate
variable contains the parsed AST for the crate3, and we need to modify that.
In this case, there’s already machinery in syntax::fold
for mutating ASTs based on patterns. AFolder
basically has the ability to walk the AST, producing a mirror AST, with modifications. For
each kind of node, you get to specify a function which will produce a node to be used in its place.
Most such functions will default to no-op (returning the same node).
So I write the following Folder
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051 |
|
I invoke it by calling let krate = trust::fold_crate(krate);
as the first line of phase_2_configure_and_expand
.
I create a stage 1 build4 of rustc (make rustc-stage1
). I’ve already set up rustup
to have a “stage1” toolchain
pointing to this folder (rustup toolchain link stage1 /path/to/rust/target_triple/stage1
), so I can easily test this new compiler:
12345 |
|
123 |
|
Note that I had the string on a separate line instead of directly doing println!("hello world")
.
This is because our backdoor isn’t perfect; it applies to the pre-expansion AST. In this AST,println!
is stored as a macro and the "hello world"
is part of the macro token tree; and has not
yet been turned into an expression. Our folder ignores it. It is not too hard to perform this same attack
post-expansion, however.
So far, so good. We have a compiler that tweaks “hello world” strings. Now, let’s see if we can get
it to miscompile itself. This means that our compiler, when compiling a pristine Rust source tree,
should produce a compiler that is similarly backdoored (with the trust
module and thetrust::fold_crate()
call).
We need to tweak our folder so that it does two things:
- Inserts the
let krate = trust::fold_crate(krate);
statement in the appropriate function (phase_2_configure_and_expand
) when compiling a pristine Rust source tree - Inserts the
trust
module
The former is relatively easy. We need to construct an AST for that statement (can be done by
invoking the parser again and extracting the node). The latter is where it gets tricky. We can
encode instructions for outputting the AST of the trust
module, but these instructions themselves
are within the same module, so the instructions for outputting these instructions need to be
included, and so on. This clearly isn’t viable.
However, there’s a way around this. It’s a common trick used in writing quines, which face similar issues. The idea is to put the entire block of code in a string. We then construct the code for the module by doing something like
1234567891011121314 |
|
With the code of the module entered in, this will look something like
123456789101112131415161718192021222324252627 |
|
So you have a string containing the contents of the module, except for itself. You build the code for the module by using the string twice – once to construct the code for the declaration of the string, and once to construct the code for the rest of the module. Now, by parsing this, you’ll get the original AST!
Let’s try this step by step. Let’s first see if injecting an arbitrary string (use foo::bar::blah
)
works, without worrying about this cyclical quineyness:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172 |
|
We also change the original call in phase_2_configure_and_expand
to let krate = trust::fold_crate(krate, sess);
Compiling with make rustc-stage2
(we now want the backdoored stage1 compiler to try and compile
the same sources and fudge the phase_2_configure_and_expand
function the second time around), gets us this error:
12345678 |
|
This is exactly what we expected! We inserted the code use foo::bar::blah;
, which isn’t going to
resolve, and thus got a failure when compiling the crate the second time around.
Let’s add the code for the quineyness and for inserting the fold_crate
call:
123456789101112131415161718192021222324252627282930313233343536373839404142 |
|
The #
s let us specify “raw strings” in Rust, where I can freely include other quotation marks
without needing to escape things. For a string starting with n
pound symbols, we can have raw
strings with up to n - 1
pound symbols inside it. The SELF_STRING
is declared with four pound
symbols, and the code in the trust module only uses raw strings with three pound symbols. Since the
code needs to generate the declaration of SELF_STRING
(with four pound symbols), we manually
concatenate extra pound symbols on – a 4-pound-symbol raw string will not be valid within a three-
pound-symbol raw string since the parser will try to end the string early. So we don’t ever directly
type a sequence of four consecutive pound symbols in the code, and instead construct it by
concatenating two pairs of pound symbols.
Ultimately, the code_for_module
declaration really does the same as:
1 |
|
conceptually, but also ensures that things stay escaped. I could get similar results by calling into a function that takes a string and inserts literal backslashes at the appropriate points.
To update SELF_STRING
, we just need to include all the code inside the trust
module after the
declaration of SELF_STRING
itself inside the string. I won’t include this inline since it’s big,
but this is what it looks like in the end.
If we try compiling this code to stage 2 after updating SELF_STRING
, we will get errors about
duplicate trust
modules, which makes sense because we’re actually already compiling an already-
backdoored version of the Rust source code. While we could set up two Rust builds, the easiest way
to verify if our attack is working is to just use #[cfg(stage0)]
on the trust module and thefold_crate
call5. These will only get included during “stage 0” (when it compiles the stage 1
compiler6), and not when it compiles the stage 2 compiler, so if the stage 2 compiler still
backdoors executables, we’re done.
On building the stage 2 (make rustc-stage2
) compiler,
123 |
|
I was also able to make it work with a separate clone of Rust:
12345678910111213141516171819202122 |
|
Thus, a pristine copy of the rustc source has built a compiler infected with the backdoor.
So we now have a working trusting trust attack in Rust. What can we do with it? Hopefully nothing! This particular attack isn’t very robust, and while that can be improved upon, building a practical and resilient trusting trust attack that won’t get noticed is a bit trickier.
We in the Rust community should be working on ways to prevent such attacks from being successful, though.
A couple of things we could do are:
- Work on an alternate Rust compiler (in Rust or otherwise). For a pair of self-hosted compilers, there’s a technique called “Diverse Double-Compiling” wherein you choose an arbitrary sequence of compilers (something like “
gcc
followed by 3xclang
followed bygcc
” followed byclang
), and compile each compiler with the output of the previous one. Difficulty of writing a backdoor that can survive this process grows exponentially. - Try compiling rustc from its ocaml roots, and package up the process into a shell script so that you have reproducible trustworthy rustc builds.
- Make rustc builds deterministic, which means that a known-trustworthy rustc build can be compared against a suspect one to figure out if it has been tampered with.
Overall trusting trust attacks aren’t that pressing a concern since there are many other ways to get approximately equivalent access with the same threat model. Having the ability to insert any backdoor into distributed binaries is bad enough, and should be protected against regardless of whether or not the backdoor is a self-propagating one. If someone had access to the distribution or build servers, for example, they could as easily insert a backdoor into the server, or place a key so that they can reupload tampered binaries when they want. Now, cleaning up after these attacks is easier than trusting trust, but ultimately this is like comparing being at the epicenter of Little Boy or the Tsar Bomba – one is worse, but you’re atomized regardless, and your mitigation plan shouldn’t need to change.
But it’s certainly an interesting attack, and should be something we should at least be thinking about.
Thanks to Josh Matthews, Michael Layzell, Diane Hosfelt, Eevee, and Yehuda Katz for reviewing drafts of this post.