The Complete Rust Security Handbook

4 months ago 4

"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):

  • IDs, hashes, roots, keys, addresses, nonces
  • Business values (Price, Balance, Quantity)
  • Validated data (Email, PhoneNumber)
  • Array indices with specific meaning

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

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:

  • Ok(value) → extracts value and continues
  • Err(error) → returns early with error
  • No panics, no DoS vectors, just controlled failure

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:

  • Fees/charges: Round UP (never under-collect)
  • Payouts/refunds: Round DOWN (never overpay)
  • Always multiply first, then divide
  • Use basis points (bps) instead of floats for rates

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
// ❌ 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:

  • CPU-intensive work (hashing, parsing, compression)
  • Synchronous I/O (file operations, blocking database calls)
  • Any operation taking more than a few milliseconds

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

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()); }
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

# ✅ 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", ]
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?
// ✅ 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:

  • All primitives wrapped in semantic newtypes
  • No parameter-swapping possible in APIs
  • Business concepts encoded in types
  • No unwrap() or panic!() in production paths
  • All Result types properly handled with ?
  • Clear error types for different failure modes
  • All money/balance operations use checked_*
  • Overflow checks enabled in release mode
  • Proper rounding direction for fees vs payouts
  • OsRng for all security-critical randomness
  • Secrets wrapped in Zeroizing or secrecy
  • No secrets in Debug output or logs
  • Constant-time comparisons for MACs/hashes
  • All SQL queries parameterized
  • No shell injection via format!() + Command
  • Input validation and sanitization
  • No blocking operations in async contexts
  • No locks held across .await points
  • Cancellation-safe state updates

Smart Contract Security ✅

  • All signers verified before state changes
  • PDAs recomputed and validated
  • Deterministic behavior (no SystemTime::now())
  • All unsafe blocks documented with safety contracts
  • Runtime assertions for debug builds
  • FFI boundaries validated
  • cargo audit passes
  • Builds use --locked flag
  • Security-focused clippy lints enabled
  • Property-based tests for invariants
  • Overflow checks enabled in release
  • Debug symbols stripped
  • Dependencies pinned and audited

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. 🦀🔒💰

Read Entire Article