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