rust-security-handbook

The Complete Rust Security Handbook: From Safe Code to Unbreakable Systems

“Rust prevents you from shooting yourself in the foot with memory corruption, but it can’t stop you from aiming the gun at your users’ money.” - The Security Rustacean’s Creed

Prologue: The Three Pillars of Rust Security

Rust gives you memory safety for free, but application security is earned through discipline. This guide will teach you to think like both a Rustacean and a security engineer, because in production systems, being “mostly secure” is like being “mostly pregnant.”

The Security Trinity:

  1. Type Safety - Make invalid states impossible to represent
  2. Error Safety - Turn panics into controlled failures
  3. Secret Safety - What happens in RAM should stay in RAM (until properly zeroized)

Chapter 1: The Type System - Your First Line of Defense

Why Primitives Are Security Vulnerabilities

Every u64 in your API is a potential million-dollar bug waiting to happen:

// ⚠️ Three bare u64s – compiler can’t tell them apart
fn transfer(from: u64, to: u64, amount: u64) -> Result<(), Error> {
    // What happens when a tired developer swaps these at 2 AM?
    // transfer(balance, user_id, amount) ← 💥 Goodbye money
}

In traditional languages, this compiles and runs. In blockchain contexts, it transfers user ID 12345 tokens from account 67890. The code works perfectly—it just transfers money to the wrong place.

The Newtype Pattern: Zero-Cost Type Safety

Wrap every meaningful primitive in a semantic type:

// ✅ Type-safe: cross-type swaps won’t compile
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Balance(u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TokenAmount(u64);

// Cross-type swaps now fail to compile
fn transfer(from: UserId, to: UserId, amount: TokenAmount) -> Result<(), Error> {
    // Swapping two UserId values still compiles — keep names clear
}

The Magic: These wrappers vanish at compile time (zero runtime cost) while preventing cross-type parameter swaps.

Real-World Impact: The Merkle Root Mixup

// ❌ PRODUCTION INCIDENT: All roots look the same
fn verify_proof(root: [u8; 32], proof: Vec<[u8; 32]>, leaf: [u8; 32]) -> bool {
    // merkle verification logic
}

// Oops - passed balance root where nullifier root expected
let valid = verify_proof(balance_root, proof, nullifier_hash); // 💥 Logic bug
// ✅ BULLETPROOF: Each root type is distinct
struct BalanceRoot([u8; 32]);
struct NullifierRoot([u8; 32]);

fn verify_balance_proof(root: BalanceRoot, proof: Vec<[u8; 32]>, leaf: [u8; 32]) -> bool {
    // verification logic
}

// This won't compile - the type system saves us!
let valid = verify_balance_proof(nullifier_root, proof, leaf); // ❌ Compile error

When to Use Newtypes (Answer: Almost Always):


Chapter 2: Error Handling - When unwrap() Becomes a Weapon

The unwrap() Time Bomb

In Web3 and financial systems, panics aren’t just crashes—they’re denial-of-service attacks:

// ❌ DOS VULNERABILITY
fn calculate_fee(amount: u64, rate: u64) -> u64 {
    amount.checked_mul(rate).unwrap() / 10000  // 💥 Panic = burned gas + no tx
}

An attacker provides values that cause overflow, the transaction panics, the user’s gas is burned, and nothing happens. Repeat this attack to effectively DoS a smart contract.

The ? Operator: Graceful Degradation

// ✅ GRACEFUL FAILURE
fn calculate_fee_safe(amount: u64, rate: u64) -> Result<u64, FeeError> {
    let fee_total = amount
        .checked_mul(rate)
        .ok_or(FeeError::Overflow)?;  // Returns error instead of panic
    
    Ok(fee_total / 10000)
}

The ? Operator Magic:

When unwrap() is Actually Safe

Sometimes unwrap() is mathematically provable to be safe:

// ✅ SAFE: Vector was just created with known size
let numbers = vec![1, 2, 3, 4, 5];
let first = numbers.get(0).expect("vector has 5 elements");

// ✅ SAFE: We just checked the condition
if !user_input.is_empty() {
    let first_char = user_input.chars().next().unwrap();
}

Rule: If you can’t write a comment explaining why the unwrap() can’t fail, it probably can.


Chapter 3: Integer Arithmetic - Where Money Goes to Die

The Silent Killer: Integer Overflow

Rust silently wraps overflows in release mode by default, turning your financial calculations into random number generators:

// ❌ SILENT MONEY CORRUPTION
fn add_to_balance(current: u64, deposit: u64) -> u64 {
    current + deposit  // If this overflows: u64::MAX + 1 = 0
}

// User has maximum balance, deposits 1 wei → balance becomes 0!

This isn’t theoretical. Integer overflow has caused real financial losses in production systems.

The Three Pillars of Safe Arithmetic

1. Checked Arithmetic - For Critical Money Operations

// ✅ OVERFLOW DETECTION
fn add_to_balance_safe(current: u64, deposit: u64) -> Result<u64, BalanceError> {
    current
        .checked_add(deposit)
        .ok_or(BalanceError::Overflow)
}

Use for: Money, prices, balances, critical calculations

2. Saturating Arithmetic - For Counters and Limits

// ✅ CLAMPING TO BOUNDS
fn apply_penalty(reputation: u32, penalty: u32) -> u32 {
    reputation.saturating_sub(penalty)  // Never goes below 0
}

fn increment_counter(count: u32) -> u32 {
    count.saturating_add(1)  // Never overflows, just stays at MAX
}

Use for: Counters, reputation systems, rate limiting

3. Wrapping Arithmetic - For Hash Functions

// ✅ EXPLICIT WRAPAROUND
fn hash_combine(hash: u32, value: u32) -> u32 {
    hash.wrapping_mul(31).wrapping_add(value)  // Wraparound is expected
}

Use for: Cryptographic operations where wraparound is mathematically correct

Financial Calculation Best Practices

// ❌ WRONG: Float precision and rounding issues
fn calculate_fee_wrong(amount: u64, rate_percent: f64) -> u64 {
    (amount as f64 * rate_percent / 100.0).round() as u64  // 💥 Precision loss
}

// ✅ CORRECT: Integer arithmetic with explicit rounding
fn calculate_fee_correct(amount: u64, rate_bps: u64) -> Result<u64, Error> {
    // Multiply first, then divide (order matters!)
    let fee_precise = amount
        .checked_mul(rate_bps)
        .ok_or(Error::Overflow)?;
    
    // For fees: round UP (ceiling division)
    let fee = fee_precise / 10000;
    if fee_precise % 10000 > 0 {
        fee.checked_add(1).ok_or(Error::Overflow)
    } else {
        Ok(fee)
    }
}

fn calculate_payout(amount: u64, rate_bps: u64) -> Result<u64, Error> {
    // For payouts: round DOWN (floor division)
    amount
        .checked_mul(rate_bps)
        .and_then(|x| x.checked_div(10000))
        .ok_or(Error::Overflow)
}

Golden Rules:

Enable Runtime Overflow Checks

[profile.release]
overflow-checks = true  # Panic instead of silent wraparound

Chapter 4: Cryptography and Secrets - The Art of Digital Locks

Random Numbers: The Foundation of Security

Cryptographic security often reduces to: “Can an attacker predict this number?”

// ❌ PREDICTABLE = BROKEN
use rand::{Rng, rngs::StdRng, SeedableRng};
let mut rng = StdRng::seed_from_u64(42);  // Same seed = same sequence!
let private_key: [u8; 32] = rng.gen();    // Predictable = stolen funds
// ✅ CRYPTOGRAPHICALLY SECURE
use rand::rngs::OsRng;
let private_key: [u8; 32] = OsRng.gen();  // Pulls from OS entropy pool

Rule: If it protects secrets or money, use OsRng. If it’s for gameplay or testing, deterministic is fine.

Secret Lifecycle Management

Secrets don’t just disappear when you think they do:

// ❌ SECRET LIVES FOREVER IN MEMORY
let mut password = String::from("super_secret_password");
password.clear();  // Only changes length - data remains in RAM!
// ✅ CRYPTOGRAPHICALLY SECURE WIPING
use zeroize::{Zeroize, Zeroizing};

// Manual zeroization
let mut secret = [0u8; 32];
OsRng.fill_bytes(&mut secret);
// ... use secret ...
secret.zeroize();  // Overwrites memory with zeros

// Automatic zeroization 
let api_key = Zeroizing::new(load_api_key());
// Automatically zeroized when dropped

The Debug Trait Trap

// ❌ SECRETS IN LOGS
#[derive(Debug)]
struct ApiCredentials {
    key: String,
    secret: String,
}

let creds = ApiCredentials { /* ... */ };
println!("Credentials: {:?}", creds);  // Logged forever!
// ✅ REDACTED LOGGING
use secrecy::{Secret, ExposeSecret};

struct ApiCredentials {
    key: String,
    secret: Secret<String>,
}

let creds = ApiCredentials {
    key: "public_key".to_string(),
    secret: Secret::new("very_secret".to_string()),
};

println!("Credentials: key={}, secret=[REDACTED]", creds.key);

// Only expose when absolutely necessary
let actual_secret = creds.secret.expose_secret();

Safe Cryptographic Patterns

// ✅ AUTHENTICATED ENCRYPTION (never use raw encryption!)
use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, NewAead}};

fn encrypt_secure(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, Error> {
    let cipher = Aes256Gcm::new(Key::from_slice(key));
    let nonce: [u8; 12] = OsRng.gen();
    
    let ciphertext = cipher
        .encrypt(Nonce::from_slice(&nonce), plaintext)
        .map_err(|_| Error::EncryptionFailed)?;
    
    // Prepend nonce for storage
    let mut result = nonce.to_vec();
    result.extend_from_slice(&ciphertext);
    Ok(result)
}

// ✅ CONSTANT-TIME COMPARISONS (prevent timing attacks)
use subtle::ConstantTimeEq;

fn verify_mac(expected: &[u8], actual: &[u8]) -> bool {
    expected.ct_eq(actual).into()  // Always takes same time
}

Chapter 5: Injection Attacks - When Strings Become Code

SQL Injection: The Eternal Enemy

Even in Rust, string formatting creates injection vulnerabilities:

// ❌ SQL INJECTION
fn find_user(name: &str) -> Result<User, Error> {
    let query = format!("SELECT * FROM users WHERE name = '{}'", name);
    // name = "'; DROP TABLE users; --" = goodbye database
    database.execute(&query)
}
// ✅ PARAMETERIZED QUERIES
use sqlx::PgPool;

async fn find_user_safe(pool: &PgPool, name: &str) -> Result<User, Error> {
    let user = sqlx::query_as!(
        User,
        "SELECT * FROM users WHERE name = $1",  // $1 is safely escaped
        name
    )
    .fetch_one(pool)
    .await?;
    
    Ok(user)
}

Command Injection: Shell Shenanigans

// ❌ COMMAND INJECTION
fn search_logs(pattern: &str) -> Result<String, Error> {
    let output = Command::new("sh")
        .arg("-c")
        .arg(format!("grep {} /var/log/app.log", pattern))  // pattern = "; rm -rf /"
        .output()?;
    
    Ok(String::from_utf8(output.stdout)?)
}
// ✅ SAFE: Individual arguments, no shell
fn search_logs_safe(pattern: &str) -> Result<String, Error> {
    let output = Command::new("grep")
        .arg(pattern)  // Treated as literal argument
        .arg("/var/log/app.log")
        .output()?;
    
    Ok(String::from_utf8(output.stdout)?)
}

Chapter 6: Async Rust - Concurrency Without Tears

The Blocking Operation Trap

Async Rust is fast until you accidentally block the entire runtime:

// ❌ BLOCKS ENTIRE RUNTIME
async fn hash_password_wrong(password: &str) -> String {
    // This CPU-intensive work freezes ALL async tasks!
    expensive_password_hash(password)
}
// ✅ OFFLOAD TO THREAD POOL
async fn hash_password_right(password: String) -> Result<String, Error> {
    let hash = tokio::task::spawn_blocking(move || {
        expensive_password_hash(&password)
    })
    .await
    .map_err(|_| Error::TaskFailed)?;
    
    Ok(hash)
}

When to use spawn_blocking:

The Lock-Across-Await Deadlock

// ❌ DEADLOCK WAITING TO HAPPEN
async fn dangerous_pattern(shared: &Mutex<Vec<String>>) {
    let mut data = shared.lock().unwrap();  // Lock acquired
    data.push("item".to_string());
    
    some_async_operation().await;  // ⚠️ Lock held across await!
    
    data.push("another".to_string());
}  // Lock released only here - other tasks blocked!
// ✅ SAFE: Release locks before await points
async fn safe_pattern(shared: &tokio::sync::Mutex<Vec<String>>) {
    {
        let mut data = shared.lock().await;
        data.push("item".to_string());
    }  // Lock released here
    
    some_async_operation().await;  // No lock held
    
    {
        let mut data = shared.lock().await;
        data.push("another".to_string());
    }  // Lock released again
}

Cancellation Safety: The Hidden Async Danger

Every .await is a potential cancellation point where your future might be dropped:

// ❌ NOT CANCELLATION SAFE
async fn transfer_funds_unsafe(from: &Account, to: &Account, amount: u64) {
    from.balance -= amount;  // ⚠️ What if cancelled here?
    network_commit().await;  // Cancellation point!
    to.balance += amount;    // Might never execute → money lost!
}
// ✅ CANCELLATION SAFE: Atomic state updates
async fn transfer_funds_safe(from: &Account, to: &Account, amount: u64) -> Result<(), Error> {
    // Do all async work first
    let transfer_id = prepare_transfer(amount).await?;
    
    // Then atomic state update (no cancellation points)
    tokio::task::spawn_blocking(move || {
        // This runs to completion
        from.balance -= amount;
        to.balance += amount;
        commit_transfer(transfer_id);
    }).await?;
    
    Ok(())
}

Chapter 7: Web3 and Smart Contract Security

The Missing Signer Check (Solana Example)

// ❌ AUTHORIZATION BYPASS
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let user_account = &mut ctx.accounts.user_account;
    // Bug: Never verified that user_account signed this transaction!
    
    user_account.balance -= amount;
    Ok(())
}
// ✅ VERIFY AUTHORIZATION
pub fn withdraw_safe(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let user_account = &mut ctx.accounts.user_account;
    
    // Critical: Verify the account holder authorized this
    require!(user_account.is_signer, ErrorCode::MissingSigner);
    
    // Additional safety: Check balance
    require!(
        user_account.balance >= amount,
        ErrorCode::InsufficientFunds
    );
    
    user_account.balance -= amount;
    Ok(())
}

Program Derived Address (PDA) Verification

// ❌ TRUSTING USER INPUT
pub fn update_vault(ctx: Context<UpdateVault>) -> Result<()> {
    let vault = &mut ctx.accounts.vault;
    // Bug: Attacker could pass a vault they control!
    vault.amount += 100;
    Ok(())
}
// ✅ VERIFY PDA OWNERSHIP
pub fn update_vault_safe(ctx: Context<UpdateVault>) -> Result<()> {
    let vault = &mut ctx.accounts.vault;
    
    // Recompute expected PDA
    let (expected_vault, _bump) = Pubkey::find_program_address(
        &[b"vault", ctx.accounts.user.key().as_ref()],
        ctx.program_id,
    );
    
    require!(vault.key() == expected_vault, ErrorCode::InvalidVault);
    
    vault.amount += 100;
    Ok(())
}

Determinism in Smart Contracts

// ❌ NON-DETERMINISTIC (causes consensus failures)
pub fn create_auction(duration_hours: u64) -> Result<()> {
    let start_time = SystemTime::now()  // Different on each validator!
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    Ok(())
}
// ✅ DETERMINISTIC
pub fn create_auction_safe(ctx: Context<CreateAuction>, duration_hours: u64) -> Result<()> {
    let clock = Clock::get()?;  // Blockchain-provided timestamp
    let start_time = clock.unix_timestamp as u64;  // Same on all validators
    Ok(())
}

Chapter 8: The unsafe Keyword - Handle With Care

Document Your Contracts

Every unsafe block must explain its safety invariants:

// ❌ DANGEROUS: No safety documentation
unsafe fn write_bytes(ptr: *mut u8, bytes: &[u8]) {
    std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
}
// ✅ SAFE: Clear safety contract
/// Copy bytes to the given pointer.
///
/// # Safety
/// 
/// - `ptr` must be non-null and properly aligned
/// - `ptr` must point to writable memory for at least `bytes.len()` bytes
/// - The memory region must not overlap with `bytes`
/// - No other threads may access the memory region during this call
unsafe fn write_bytes_safe(ptr: *mut u8, bytes: &[u8]) {
    debug_assert!(!ptr.is_null(), "ptr must not be null");
    debug_assert!(ptr.is_aligned(), "ptr must be aligned");
    
    // SAFETY: Caller guarantees all safety requirements above
    std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
}

FFI Safety

extern "C" {
    fn c_hash_function(input: *const u8, len: usize, output: *mut u8);
}

// ✅ SAFE FFI WRAPPER
/// Compute hash using C library.
pub fn hash_bytes(data: &[u8]) -> [u8; 32] {
    let mut output = [0u8; 32];
    
    // SAFETY: 
    // - data.as_ptr() is valid for data.len() bytes
    // - output.as_mut_ptr() is valid for 32 bytes
    // - C function documented to write exactly 32 bytes
    unsafe {
        c_hash_function(data.as_ptr(), data.len(), output.as_mut_ptr());
    }
    
    output
}

Chapter 9: Development and Deployment Security

Dependency Security

# ✅ Security audit pipeline
cargo audit                    # Check for vulnerabilities
cargo build --release --locked # Use exact dependency versions
cargo clippy -- -D warnings   # Lint for security issues

Security-Focused Compiler Settings

[profile.release]
overflow-checks = true     # Catch integer overflows
debug-assertions = true    # Keep debug_assert! in release
strip = true              # Remove debug symbols
lto = true                # Link-time optimization
codegen-units = 1         # Better optimization

# Security-focused clippy config
[alias]
secure-check = [
    "clippy", "--all-targets", "--all-features", "--",
    "-D", "clippy::unwrap_used",
    "-D", "clippy::expect_used", 
    "-D", "clippy::indexing_slicing",
    "-D", "clippy::panic",
]

Property-Based Testing

use proptest::prelude::*;

// Test security properties, not just happy paths
proptest! {
    #[test]
    fn transfer_never_creates_money(
        initial_from in 0u64..=1_000_000,
        initial_to in 0u64..=1_000_000,
        amount in 0u64..=1_000_000
    ) {
        let mut from = Account { balance: initial_from };
        let mut to = Account { balance: initial_to };
        let total_before = initial_from + initial_to;
        
        let _ = transfer(&mut from, &mut to, amount);
        
        let total_after = from.balance + to.balance;
        prop_assert_eq!(total_before, total_after, "Money created or destroyed!");
    }
}

Chapter 10: The Security Mindset

Threat Modeling Questions

For every function, ask:

  1. What if the inputs are malicious?
  2. What if this is called a million times per second?
  3. What if multiple threads call this simultaneously?
  4. What’s the worst an attacker could do with this function?
  5. What assumptions might not hold in production?

Defense in Depth

// ✅ LAYERED SECURITY
pub fn process_payment(
    user: &User,
    amount: TokenAmount,
    signature: &Signature,
) -> Result<(), PaymentError> {
    // Layer 1: Authentication
    verify_signature(&user.public_key, signature)?;
    
    // Layer 2: Authorization
    user.check_payment_permissions()?;
    
    // Layer 3: Input validation
    if amount.0 == 0 {
        return Err(PaymentError::ZeroAmount);
    }
    
    // Layer 4: Business rules
    if amount.0 > user.balance.0 {
        return Err(PaymentError::InsufficientFunds);
    }
    
    // Layer 5: Rate limiting
    user.check_rate_limit()?;
    
    // Layer 6: Overflow protection
    let new_balance = user.balance.0
        .checked_sub(amount.0)
        .ok_or(PaymentError::ArithmeticError)?;
    
    // Finally: Execute
    user.balance = Balance(new_balance);
    user.record_payment(amount);
    
    Ok(())
}

The Ultimate Security Checklist

Before deploying Rust code to production:

Type Safety ✅

Error Handling ✅

Arithmetic Safety ✅

Cryptography ✅

Injection Prevention ✅

Async Safety ✅

Smart Contract Security ✅

unsafe Code ✅

Development Security ✅

Deployment Security ✅


Final Wisdom: The Three Laws of Secure Rust

  1. Make invalid states unrepresentable - Use the type system to prevent bugs at compile time
  2. Fail explicitly and gracefully - Turn potential panics into controlled Result types
  3. Trust but verify - Validate all boundaries, especially between safe and unsafe code

One-Liner for Your Laptop Sticker

“Memory safety for free, application security for a price—but that price is just good habits.”


Remember: Rust gives you a head start on security, but it’s not a silver bullet. The most secure code is code that’s never written, and the second most secure code is code that’s written by developers who think like attackers.

Now go forth and build systems that are not just fast and memory-safe, but actually secure. Your users’ money depends on it. 🦀🔒💰

Contributing & Contact