Skip to main content

foundry_evm_fuzz/
inspector.rs

1use crate::{invariant::RandomCallGenerator, strategies::EvmFuzzState};
2use foundry_common::mapping_slots::step as mapping_step;
3use foundry_evm_core::constants::CHEATCODE_ADDRESS;
4use revm::{
5    Inspector,
6    context::{ContextTr, JournalTr, Transaction},
7    interpreter::{CallInput, CallInputs, CallOutcome, CallScheme, CallValue, Interpreter},
8};
9
10/// An inspector that can fuzz and collect data for that effect.
11#[derive(Clone, Debug)]
12pub struct Fuzzer {
13    /// If set, it collects `stack` and `memory` values for fuzzing purposes.
14    pub collect: bool,
15    /// Given a strategy, it generates a random call.
16    pub call_generator: Option<RandomCallGenerator>,
17    /// If `collect` is set, we store the collected values in this fuzz dictionary.
18    pub fuzz_state: EvmFuzzState,
19}
20
21impl<CTX: ContextTr> Inspector<CTX> for Fuzzer {
22    #[inline]
23    fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) {
24        // We only collect `stack` and `memory` data before and after calls.
25        if self.collect {
26            self.collect_data(interp);
27            if let Some(mapping_slots) = &mut self.fuzz_state.mapping_slots {
28                mapping_step(mapping_slots, interp);
29            }
30        }
31    }
32
33    fn call(&mut self, ecx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
34        // We don't want to override the very first call made to the test contract.
35        if self.call_generator.is_some() && ecx.tx().caller() != inputs.caller {
36            self.override_call(ecx, inputs);
37        }
38
39        // We only collect `stack` and `memory` data before and after calls.
40        // this will be turned off on the next `step`
41        self.collect = true;
42
43        None
44    }
45
46    fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) {
47        if let Some(ref mut call_generator) = self.call_generator {
48            // Decrement depth when any call ends while inside an override
49            if call_generator.override_depth > 0 {
50                call_generator.override_depth -= 1;
51            }
52        }
53
54        // We only collect `stack` and `memory` data before and after calls.
55        // this will be turned off on the next `step`
56        self.collect = true;
57    }
58}
59
60impl Fuzzer {
61    /// Collects `stack` and `memory` values into the fuzz dictionary.
62    #[cold]
63    fn collect_data(&mut self, interpreter: &Interpreter) {
64        self.fuzz_state.collect_values(interpreter.stack.data().iter().copied().map(Into::into));
65
66        // TODO: disabled for now since it's flooding the dictionary
67        // for index in 0..interpreter.shared_memory.len() / 32 {
68        //     let mut slot = [0u8; 32];
69        //     slot.clone_from_slice(interpreter.shared_memory.get_slice(index * 32, 32));
70
71        //     state.insert(slot);
72        // }
73
74        self.collect = false;
75    }
76
77    /// Overrides an external call to simulate reentrancy attacks.
78    ///
79    /// This function detects reentrancy vulnerabilities by replacing external calls
80    /// with callbacks that reenter the caller contract.
81    ///
82    /// For calls with value (ETH transfers):
83    /// 1. Performs the ETH transfer via the journal first
84    /// 2. Replaces the call with a reentrant callback (value = 0)
85    ///
86    /// For calls without value:
87    /// - Replaces the call entirely with a reentrant callback
88    ///
89    /// This simulates malicious contracts that immediately reenter when called.
90    fn override_call<CTX: ContextTr>(&mut self, ecx: &mut CTX, call: &mut CallInputs) {
91        let Some(ref mut call_generator) = self.call_generator else {
92            return;
93        };
94
95        // Skip if:
96        // - Caller is test contract (don't override the initial calls from the test)
97        // - Not a CALL scheme (only override CALLs, not STATICCALLs, DELEGATECALLs, etc.)
98        // - Inside an override (prevent recursive overrides)
99        // - Target is cheatcode address
100        // - Neither caller nor target is a handler contract
101        //
102        // We override calls when either the caller OR target is a handler. This covers:
103        // 1. EtherStore pattern: handler sends ETH out, attacker reenters handler
104        // 2. Rari pattern: external protocol sends ETH to handler, handler reenters protocol
105        let caller_is_handler = call_generator.is_handler(call.caller);
106        let target_is_handler = call_generator.is_handler(call.target_address);
107        if call.caller == call_generator.test_address
108            || call.scheme != CallScheme::Call
109            || call_generator.override_depth > 0
110            || call.target_address == CHEATCODE_ADDRESS
111            || (!caller_is_handler && !target_is_handler)
112        {
113            return;
114        }
115
116        // There's only a ~27% chance that an override happens (90% * 30% from strategy).
117        let Some(tx) = call_generator.next(call.caller, call.target_address) else {
118            return;
119        };
120
121        // For value transfers, perform the ETH transfer before injecting the callback.
122        // This simulates a malicious receive() that gets the ETH and then reenters.
123        let value = call.transfer_value().unwrap_or_default();
124        let has_value = !value.is_zero() && call.gas_limit > 2300;
125        if has_value && ecx.journal_mut().transfer(call.caller, call.target_address, value).is_err()
126        {
127            return;
128        }
129
130        // Replace the call with a reentrant callback
131        call.input = CallInput::Bytes(tx.call_details.calldata.0.into());
132        call.caller = tx.sender;
133        call.target_address = tx.call_details.target;
134        call.bytecode_address = tx.call_details.target;
135        let target = ecx
136            .journal_mut()
137            .load_account_with_code(tx.call_details.target)
138            .expect("failed to load account");
139        // Clear known_bytecode to force REVM to load bytecode from the new target.
140        // Without this, REVM uses cached bytecode from the original target (e.g., empty
141        // bytecode for EOA), causing the call to short-circuit before executing any code.
142        call.known_bytecode = (target.info.code_hash, target.info.code.clone().unwrap_or_default());
143        // Clear value since ETH was already transferred above
144        call.value = CallValue::Transfer(alloy_primitives::U256::ZERO);
145
146        // Track that we're inside an overridden call to avoid recursive overrides
147        call_generator.override_depth = 1;
148    }
149}