Skip to content

Understanding Rust Macros and Their Security Implications

Notifications You must be signed in to change notification settings

cre-mer/vulnerable-macro

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Understanding Rust Macros and Their Security Implications

⚠️ WARNING: SECURITY DEMONSTRATION ⚠️

This project 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?

See dangerous_proc_macro_lib/src/lib.rs for a working example. The #[return_42] attribute macro:

#[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 in clueless_import/src/main.rs:

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. The output file expanded.rs shows the generated code.

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

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 (⚠️ severly limits IDE functionality – not recommended)
    • Manually review code before running any cargo commands

Testing This Demo

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

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

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.

About

Understanding Rust Macros and Their Security Implications

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published