WTF Does That Mean
Rust exposes a feature that allows you to “drive” the rustc compiler from within a project. This offers a lot of benefits for offensive tool development because it allows you to embed a copy of the compiler into your projects as if it were a struct and call its methods. While this feature offers a lot of capabilities to the developer, such as examining and modifying the syntax tree of the compiled program, for our purposes we are really just interested in shipping a working copy of the compiler inside of our larger binary.
See the final product.
Use Case
For demonstrative purposes, we are going to be using this feature to take a single shellcode.rs
and compile it into an executable. We are then going to use built in (I think) linux utilities to extract the shellcode from the resulting binary.
Here are the steps we have to take broken down:
- Include the rustc compiler in our project
- Invoke the compiler against our file
- Extract the shellcode from the resulting binary
Including rustc_driver
This functionality is granted by a very weird crate named rustc_driver
that is documented here. Its usage requires a nightly version.
Hello World
At the very top of your main.rs
file, it is necessary to include the following line:
#![feature(rustc_private)]
To enable the usage of internal compiler crate, as outlined here:
Following this, importing the crate requires you to use the
extern
keyword for some reason that I do not entirely understand, meaning that your code should look like this:
#![feature(rustc_private)]
extern crate rustc_driver;
fn main() {
println!("Hello, world!");
}
If you have an LSP configured, you will notice that it cannot resolve the crate. This is normal and will persist forever so you’re just going to have to deal with it.
Setting Rust Toolchain
Now, as mentioned earlier, you need to be on a nightly toolchain to be able to use this feature, so create a file named rust-toolchain.toml
in the root directory of your project like such:
Inside of this file, place the following configurations:
[toolchain]
channel = "nightly-2025-01-06"
components = ["rustc-dev", "rust-src", "llvm-tools-preview"]
You are free to change the channel to suit any nightly version of the compiler that you want. I would just pick one that you are fairly confident is stable. This is both the compiler version that your binary is compiled with as well as the version of the embedded compiler, so if that is important to you then keep that in mind.
Whenever you run cargo build
in the root directory, your project should correctly compile like such:
Invoking the Compiler
Creating the Dummy File
Before we invoke the compiler, we need to create the single file rust code that it is going to be invoked against. There are no LSPs that I am familiar with that support single file programs, meaning you are going to get constant errors in any IDE. Get over it, boohoo!
Create a file anywhere in the project and name it shellcode.rs
:
To properly generate shellcode, this file requires a few things at the start, specifically:
- no_std (The default allocator does not play nicely with shellcode)
- no_main (We don’t care about the default entrypoint anyway, this is position independent)
- feature start (Necessary for no_std)
- custom panic handler (to satisfy requirements of no_std and no_main)
These all sound very complicated, but are extremely easy to implement:
#![no_std]
#![no_main]
#![feature(start)]
use core::panic::PanicInfo;
We now need to implement the “hello world” portion of the shellcode:
#[no_mangle]
#[link_section = ".text.payload"]
pub unsafe extern "C" fn _start()->i32{
return 0;
}
This creates an unmangled C-Style function that returns 0. This particular function is linked at .text.payload
so we can find it easily later.
Very simple stuff, but it needs to be formatted like this so that it is position independent and not reliant on the rust ecosystem to execute. Using a println macro would require much more legwork than I care to implement for this example, so we simply return 0 if it succeeds.
Finally we just need to implement the panic handler, which is extremely barebones because it loops forever:
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
This just defines a panic handler function that loops infinitely. This is necessary to satisfy some requirement or another.
Your final example file should look like this:
#![no_std]
#![no_main]
#![feature(start)]
use core::panic::PanicInfo;
#[no_mangle]
#[link_section = ".text.payload"]
pub unsafe extern "C" fn _start()->i32{
return 0;
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
Calling the Compiler
Now, navigate back to your src/main.rs
file where we imported rustc_driver earlier.
First, inside of the main block, we need to create an empty struct named MyCallbacks
which satisfies a trait requirement for the rustc_driver
crate. This is necessary to call the compiler, but does nothing for our purposes:
struct MyCallbacks;
impl rustc_driver::Callbacks for MyCallbacks{}
We now can invoke the RunCompiler
method on the rustc_driver
, passing our arguments and a reference to the struct we just created. The majority of the arguments are simply arguments you can pass to rustc
if you were to use it from the command line, but they are documented in comments in the following codeblock:
let _result = rustc_driver::RunCompiler::new(
&[
"".to_string(), // arg[0] needs to be null
"-C".to_string(), "panic=abort".to_string(), // Removes the built in panic
"-C".to_string(), "link-arg=-nostdlib".to_string(), // Does not include stdlib
"-C".to_string(), "link-arg=-static".to_string(), //Statically links the binary
"-C".to_string(), "link-arg=nodefaultlibs".to_string(), // Includes no default libs
"-C".to_string(), "opt-level=z".to_string(), // Optimizes for binary size
"--emit=obj".to_string(), // Emits an object file
"./shellcode.rs".to_string(), // Defines the input file
"--target".to_string(), "x86_64-unknown-linux-gnu".to_string(), // Sets the target as a
// linux target (This would work just the same for windows)
"-o".to_string(), "./".to_string() //Defines the output directory
],
&mut MyCallbacks)
.run();
The values, for this example, are hardcoded. Obviously if you were writing an actual program you would handle user input here.
With these changes made, your final main.rs
should look like this:
#![feature(rustc_private)]
extern crate rustc_driver;
fn main() {
struct MyCallbacks;
impl rustc_driver::Callbacks for MyCallbacks{}
let _result = rustc_driver::RunCompiler::new(
&[
"".to_string(), // arg[0] needs to be null
"-C".to_string(), "panic=abort".to_string(), // Removes the built in panic
"-C".to_string(), "link-arg=-nostdlib".to_string(), // Does not include stdlib
"-C".to_string(), "link-arg=-static".to_string(), //Statically links the binary
"-C".to_string(), "link-arg=nodefaultlibs".to_string(), // Includes no default libs
"-C".to_string(), "opt-level=z".to_string(), // Optimizes for binary size
"--emit=obj".to_string(), // Emits an object file
"./shellcode.rs".to_string(), // Defines the input file
"--target".to_string(), "x86_64-unknown-linux-gnu".to_string(), // Sets the target as a
// linux target (This would work just the same for windows)
"-o".to_string(), "./shellcode.o".to_string() //Defines the output file
],
&mut MyCallbacks)
.run();
}
You can now build your project.
Creating the Object File
Now that our project is built, we can test it. From whatever directory your shellcode.rs
is placed in, run the cargo run
and you should see the following:
Extracting Shellcode from an Object File
Now that we have created an object file, we need to extract its .text
section.
First, run the strip
binary on the resulting shellcode.o
file like this:
strip ./shellcode.o
This should return no output.
Finally, we need to copy out the .text section using the objcopy
binary. Run the following command on the .o
file:
objcopy -O binary -j .text.payload -j .text -j .data ./shellcode.o shellcode.bin
This will return no output, but should have created a new file in your current directory:
Examining the file with
xxd
shows that it contains the following bytes:
Which some simple x64 assembly research will tell us is the following:
xor eax, eax
ret
Or simply “return 0”. We’ve successfully generated (extremely simple) shellcode.
Conclusion & References
Obviously the example outlined above is not very impressive, but the techniques discussed above can be extrapolated out to much more complicated and impressive projects. For example, I implemented a COFF loader in a very similar way as outlined in this proof of concept repository. If you are interested in examples on how to take this concept further, I would start there as I have not seen many other people playing with this concept.
Here is some information I used along the way that may be useful in your endeavors: https://os.phil-opp.com/freestanding-rust-binary/
https://jade.fyi/blog/writeonly-in-rust/
https://rustc-dev-guide.rust-lang.org/