Published on

Rust Procedural Macros and Their Security Implications

Authors

⚠️ WARNING: SECURITY DEMONSTRATION ⚠️

This post shows how malicious code can execute on your machine simply by opening a Rust project in your IDE.

Overview

Rust's procedural macros are powerful metaprogramming tools, but they pose a significant security risk because they execute arbitrary code at compile time. This means malicious macros can:

  • Steal environment variables and secrets (API keys, tokens, passwords)
  • Exfiltrate data from your filesystem
  • Establish network connections to send stolen data
  • Install backdoors or malware
  • And much more...

The dangerous part: This can happen even if you never compile or run your program — just opening the project in VS Code (or other IDEs) with rust-analyzer is enough.


1. What Are Macros?

Rust has two types of macros:

Declarative Macros (macro_rules!)

Pattern-matching macros that operate on syntax tokens (e.g., println!, vec!). These are generally safer as they only perform text substitution.

Procedural Macros

Rust functions that take token streams as input and produce token streams as output. They are full Rust programs that execute during compilation. There are three types:

  1. Function-like macros: my_macro!(...)
  2. Derive macros: #[derive(MyTrait)]
  3. Attribute macros: #[my_attribute] (like the one in this demo)

2. How Do They Generate Code?

Procedural macros are compiled and executed by the Rust compiler during the compilation phase:

  1. The macro crate (marked with proc-macro = true in Cargo.toml) is compiled first
  2. The compiled macro binary is loaded by the compiler as a plugin
  3. When the compiler encounters a macro invocation, it calls the macro function
  4. The macro receives the syntax tree (as TokenStream) and can:
    • Analyze the input code
    • Execute any arbitrary Rust code (file I/O, network calls, system commands)
    • Generate new code that replaces or augments the original
  5. The generated code is inserted into the compilation

Key insight: The macro code runs in your build environment with your user permissions, not in a sandbox.


3. How Do Macros Execute Local Code?

Consider the following attribute macro example. The #[return_42] attribute macro appears innocent, but executes malicious code at compile time:

#[proc_macro_attribute]
pub fn return_42(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // This code executes at compile time!

    // Steal environment variables
    let username = env::var("USER").unwrap_or_default();

    // Get local IP address
    let local_ip = get_local_ip().unwrap_or_default();

    // Read .env files (containing secrets like API keys)
    let env_contents = std::fs::read_to_string("./.env").ok();

    // Write stolen data to a file
    // (In a real attack, this would be sent over the network)
    std::fs::write("compile_time_output.txt",
        format!("User: {}\nIP: {}\n{:?}", username, local_ip, env_contents))
        .expect("Failed to write");

    // Return valid Rust code
    quote! { /* generated code */ }.into()
}

When you use this macro:

use dangerous_proc_macro_lib::return_42;

#[return_42]  // ← This executes malicious code at compile time!
fn my_function() -> u32 {
    // Original function body
}

The malicious code in the macro executes before your program even compiles.


4. What Is cargo expand?

cargo expand is a tool that shows you the code generated by macros:

cargo install cargo-expand
cd clueless_import
cargo expand

This reveals what macros actually generate, but it still executes the macro code to produce the expansion.

Important: Running cargo expand will trigger the malicious macro execution, so it's not safe for untrusted code!


5. Why Is Not Even cargo check Safe?

cargo check only checks your code for errors without producing a binary, so many developers assume it's safe. This is wrong.

To check your code, the compiler must:

  1. Resolve dependencies
  2. Compile and execute all procedural macros
  3. Type-check the generated code

The macro execution happens at step 2, so malicious code still runs. Commands that trigger macro execution:

  • cargo check — executes macros
  • cargo build — executes macros
  • cargo test — executes macros
  • cargo clippy — executes macros
  • cargo expand — executes macros
  • ❌ Opening in VS Code with rust-analyzer — executes macros

There is no safe way to inspect untrusted Rust code without potentially executing malicious macros.


6. How Often Does It Get Executed?

Procedural macros execute every time the compiler needs to process your code:

Automatic Execution Triggers:

  1. Opening the project in VS Code/IDEs

    • rust-analyzer runs cargo check automatically for diagnostics
    • Happens right after opening the project
    • Can be disabled but limits IDE functionality
  2. Every code change

    • rust-analyzer re-checks on file save
    • Macros re-execute each time
  3. Every explicit build command

    • cargo build, cargo run, cargo test, etc.
  4. Git operations in some setups

    • Pre-commit hooks running cargo fmt or cargo clippy

Testing This Demo

⚠️ Only run this in a safe environment (Docker/VM) without real secrets!

  1. Clone the example repo:
    git clone https://github.com/cre-mer/vulnerable-macro.git
    cd vulnerable-macro
    
  2. Open this project in GitHub Codespaces (uses the devcontainer)
  3. Create a fake .env file in clueless_import/:
    cd clueless_import && \
    echo "API_KEY=super_secret_key_12345" > .env && \
    echo "DATABASE_PASSWORD=hunter2" >> .env
    
  4. Open clueless_import/src/main.rs in VS Code
  5. Wait a few seconds for rust-analyzer to activate
  6. Check the created files:
    cat compile_time_output.txt
    
  7. See your environment information and .env contents stolen!

How to Protect Yourself

  1. Only use macros from trusted sources

    • Official crates.io crates with many downloads and active maintenance
    • Well-known organizations (Serde, Tokio, etc.)
    • Review the crate's source code if possible
  2. Audit dependencies

    • Use cargo tree to see all dependencies
    • Check for suspicious or unknown proc-macro dependencies
    • Use cargo-audit to check for known vulnerabilities
  3. Use sandboxing

    • Run untrusted code in Docker containers or VMs
    • Use cargo-sandbox or similar tools (still experimental)
  4. Be cautious with git clone + open workflow

    • Review Cargo.toml for proc-macro dependencies first
    • Search for proc-macro = true in dependency sources
    • Consider using --offline mode for initial inspection
  5. Disable automatic checks

    • Disable rust-analyzer auto-check (⚠️ severely limits IDE functionality — not recommended)
    • Manually review code before running any cargo commands

Why This Matters

Real-world attacks using malicious proc-macros:

  • Supply chain attacks: compromised popular macros could affect thousands of projects
  • Typosquatting: macros with names similar to popular crates (serde vs. serdi)
  • Dependency confusion: malicious internal crate names on public registries
  • Targeted attacks: macros designed to activate only in specific environments

Conclusion

By the time you've opened a Rust project with proc-macro dependencies in VS Code, it may already be too late. The compile-time execution model of procedural macros is a powerful feature, but it fundamentally means that building code is equivalent to running code. Treat any untrusted Rust project with proc-macro dependencies the same way you would treat an untrusted binary — run it only in an isolated environment.