Skip to main content

foundry_evm_fuzz/
inspector.rs

1use crate::invariant::RandomCallGenerator;
2use alloy_primitives::{Address, B256, Bytes, U256, 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/// A sub-call observed by the [`Fuzzer`] inspector.
12///
13/// `depth` is 1-indexed relative to the top-level call: depth 1 is a direct call
14/// from the top-level callee, depth 2 is a sub-call of that call, and so on. The
15/// top-level call itself is never recorded.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ObservedCall {
18    pub depth: u32,
19    pub caller: Address,
20    pub target: Address,
21    pub calldata: Bytes,
22    pub value: Option<U256>,
23}
24
25/// An inspector that can fuzz and collect data for that effect.
26#[derive(Clone, Debug)]
27pub struct Fuzzer {
28    /// If set, it collects `stack` and `memory` values for fuzzing purposes.
29    pub collect: bool,
30    /// Given a strategy, it generates a random call.
31    pub call_generator: Option<RandomCallGenerator>,
32    /// If `collect` is set, we store collected values until the invariant worker drains them.
33    pub collected_values: Vec<B256>,
34    /// Maximum number of stack words staged before the invariant worker drains them.
35    pub max_collected_values: usize,
36    /// Mapping accesses observed during execution, used for storage slot sampling.
37    pub mapping_slots: Option<AddressMap<MappingSlots>>,
38    /// Whether sub-calls should be buffered for later corpus seeding.
39    record_calls: bool,
40    /// Sub-calls observed since the last drain.
41    observed_calls: Vec<ObservedCall>,
42    /// Current EVM call depth. 0 means no active call, 1 means top-level call.
43    call_depth: u32,
44}
45
46impl<CTX: ContextTr> Inspector<CTX> for Fuzzer {
47    #[inline]
48    fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) {
49        // We only collect `stack` and `memory` data before and after calls.
50        if self.collect {
51            self.collect_data(interp);
52            if let Some(mapping_slots) = &mut self.mapping_slots {
53                mapping_step(mapping_slots, interp);
54            }
55        }
56    }
57
58    fn call(&mut self, ecx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
59        // We don't want to override the very first call made to the test contract.
60        if self.call_generator.is_some() && ecx.tx().caller() != inputs.caller {
61            self.override_call(ecx, inputs);
62        }
63
64        self.call_depth = self.call_depth.saturating_add(1);
65        if self.should_record_observed_call(inputs.scheme) {
66            self.observed_calls.push(ObservedCall {
67                depth: self.call_depth - 1,
68                caller: inputs.caller,
69                target: inputs.target_address,
70                calldata: inputs.input.bytes(ecx),
71                value: inputs.transfer_value().filter(|value| !value.is_zero()),
72            });
73        }
74
75        // We only collect `stack` and `memory` data before and after calls.
76        // this will be turned off on the next `step`
77        self.collect = true;
78
79        None
80    }
81
82    fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) {
83        if let Some(ref mut call_generator) = self.call_generator {
84            // Decrement depth when any call ends while inside an override
85            if call_generator.override_depth > 0 {
86                call_generator.override_depth -= 1;
87            }
88        }
89
90        // We only collect `stack` and `memory` data before and after calls.
91        // this will be turned off on the next `step`
92        self.collect = true;
93
94        self.call_depth = self.call_depth.saturating_sub(1);
95    }
96}
97
98impl Fuzzer {
99    /// Constructs a new `Fuzzer` inspector.
100    pub const fn new(
101        max_collected_values: usize,
102        mapping_slots: Option<AddressMap<MappingSlots>>,
103    ) -> Self {
104        Self {
105            collect: true,
106            call_generator: None,
107            collected_values: Vec::new(),
108            max_collected_values,
109            mapping_slots,
110            record_calls: false,
111            observed_calls: Vec::new(),
112            call_depth: 0,
113        }
114    }
115
116    /// Enables or disables sub-call buffering.
117    pub const fn with_call_recording(mut self, record_calls: bool) -> Self {
118        self.record_calls = record_calls;
119        self
120    }
121
122    /// Enables or disables sub-call buffering on an existing inspector.
123    pub const fn set_call_recording(&mut self, record_calls: bool) {
124        self.record_calls = record_calls;
125    }
126
127    /// Returns the buffered sub-calls observed since the last drain.
128    pub fn take_observed_calls(&mut self) -> Vec<ObservedCall> {
129        std::mem::take(&mut self.observed_calls)
130    }
131
132    #[cfg(test)]
133    fn record_observed_call(
134        &mut self,
135        caller: Address,
136        target: Address,
137        calldata: Bytes,
138        value: Option<U256>,
139        scheme: CallScheme,
140    ) {
141        if self.should_record_observed_call(scheme) {
142            self.observed_calls.push(ObservedCall {
143                depth: self.call_depth - 1,
144                caller,
145                target,
146                calldata,
147                value,
148            });
149        }
150    }
151
152    #[inline]
153    const fn should_record_observed_call(&self, scheme: CallScheme) -> bool {
154        self.record_calls && self.call_depth > 1 && matches!(scheme, CallScheme::Call)
155    }
156
157    /// Collects `stack` and `memory` values into the fuzz dictionary.
158    #[cold]
159    fn collect_data(&mut self, interpreter: &Interpreter) {
160        let remaining = self.max_collected_values.saturating_sub(self.collected_values.len());
161        self.collected_values
162            .extend(interpreter.stack.data().iter().take(remaining).copied().map(B256::from));
163
164        // TODO: disabled for now since it's flooding the dictionary
165        // for index in 0..interpreter.shared_memory.len() / 32 {
166        //     let mut slot = [0u8; 32];
167        //     slot.clone_from_slice(interpreter.shared_memory.get_slice(index * 32, 32));
168
169        //     state.insert(slot);
170        // }
171
172        self.collect = false;
173    }
174
175    /// Overrides an external call to simulate reentrancy attacks.
176    ///
177    /// This function detects reentrancy vulnerabilities by replacing external calls
178    /// with callbacks that reenter the caller contract.
179    ///
180    /// For calls with value (ETH transfers):
181    /// 1. Performs the ETH transfer via the journal first
182    /// 2. Replaces the call with a reentrant callback (value = 0)
183    ///
184    /// For calls without value:
185    /// - Replaces the call entirely with a reentrant callback
186    ///
187    /// This simulates malicious contracts that immediately reenter when called.
188    fn override_call<CTX: ContextTr>(&mut self, ecx: &mut CTX, call: &mut CallInputs) {
189        let Some(ref mut call_generator) = self.call_generator else {
190            return;
191        };
192
193        // Skip if:
194        // - Caller is test contract (don't override the initial calls from the test)
195        // - Not a CALL scheme (only override CALLs, not STATICCALLs, DELEGATECALLs, etc.)
196        // - Inside an override (prevent recursive overrides)
197        // - Target is cheatcode address
198        // - Neither caller nor target is a handler contract
199        //
200        // We override calls when either the caller OR target is a handler. This covers:
201        // 1. EtherStore pattern: handler sends ETH out, attacker reenters handler
202        // 2. Rari pattern: external protocol sends ETH to handler, handler reenters protocol
203        if call.caller == call_generator.test_address
204            || call.scheme != CallScheme::Call
205            || call_generator.override_depth > 0
206            || call.target_address == CHEATCODE_ADDRESS
207        {
208            return;
209        }
210        {
211            let handlers = call_generator.handler_addresses.read();
212            if !handlers.contains(&call.caller) && !handlers.contains(&call.target_address) {
213                return;
214            }
215        }
216
217        // There's only a ~27% chance that an override happens (90% * 30% from strategy).
218        let Some(tx) = call_generator.next(call.caller, call.target_address) else {
219            return;
220        };
221
222        // For value transfers, perform the ETH transfer before injecting the callback.
223        // This simulates a malicious receive() that gets the ETH and then reenters.
224        let value = call.transfer_value().unwrap_or_default();
225        let has_value = !value.is_zero() && call.gas_limit > 2300;
226        if has_value && ecx.journal_mut().transfer(call.caller, call.target_address, value).is_err()
227        {
228            return;
229        }
230
231        // Replace the call with a reentrant callback
232        call.input = CallInput::Bytes(tx.call_details.calldata);
233        call.caller = tx.sender;
234        call.target_address = tx.call_details.target;
235        call.bytecode_address = tx.call_details.target;
236        let target = ecx
237            .journal_mut()
238            .load_account_with_code(tx.call_details.target)
239            .expect("failed to load account");
240        // Clear known_bytecode to force REVM to load bytecode from the new target.
241        // Without this, REVM uses cached bytecode from the original target (e.g., empty
242        // bytecode for EOA), causing the call to short-circuit before executing any code.
243        call.known_bytecode = (target.info.code_hash, target.info.code.clone().unwrap_or_default());
244        // Clear value since ETH was already transferred above
245        call.value = CallValue::Transfer(alloy_primitives::U256::ZERO);
246
247        // Track that we're inside an overridden call to avoid recursive overrides
248        call_generator.override_depth = 1;
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn fuzzer(record_calls: bool) -> Fuzzer {
257        Fuzzer::new(16, None).with_call_recording(record_calls)
258    }
259
260    #[test]
261    fn observed_calls_are_disabled_by_default() {
262        let mut fuzzer = Fuzzer::new(16, None);
263        fuzzer.call_depth = 2;
264
265        fuzzer.record_observed_call(
266            Address::from([0xaa; 20]),
267            Address::from([0x11; 20]),
268            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
269            Some(U256::from(1)),
270            CallScheme::Call,
271        );
272
273        assert!(fuzzer.take_observed_calls().is_empty());
274    }
275
276    #[test]
277    fn observed_calls_skip_top_level_call() {
278        let mut fuzzer = fuzzer(true);
279        fuzzer.call_depth = 1;
280
281        fuzzer.record_observed_call(
282            Address::from([0xaa; 20]),
283            Address::from([0x11; 20]),
284            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
285            None,
286            CallScheme::Call,
287        );
288
289        assert!(fuzzer.take_observed_calls().is_empty());
290    }
291
292    #[test]
293    fn observed_calls_record_subcall_depth_target_calldata_and_value() {
294        let mut fuzzer = fuzzer(true);
295        let caller = Address::from([0x11; 20]);
296        let target = Address::from([0x22; 20]);
297        let calldata = Bytes::from_static(&[0xca, 0xfe, 0xba, 0xbe]);
298        let value = Some(U256::from(7));
299        fuzzer.call_depth = 3;
300
301        fuzzer.record_observed_call(caller, target, calldata.clone(), value, CallScheme::Call);
302
303        assert_eq!(
304            fuzzer.take_observed_calls(),
305            vec![ObservedCall { depth: 2, caller, target, calldata, value }]
306        );
307    }
308
309    #[test]
310    fn observed_calls_skip_non_call_schemes() {
311        let mut fuzzer = fuzzer(true);
312        fuzzer.call_depth = 2;
313
314        fuzzer.record_observed_call(
315            Address::from([0x11; 20]),
316            Address::from([0x22; 20]),
317            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
318            None,
319            CallScheme::DelegateCall,
320        );
321
322        assert!(fuzzer.take_observed_calls().is_empty());
323    }
324
325    #[test]
326    fn take_observed_calls_drains_buffer() {
327        let mut fuzzer = fuzzer(true);
328        fuzzer.call_depth = 2;
329        fuzzer.record_observed_call(
330            Address::from([0xaa; 20]),
331            Address::from([0x33; 20]),
332            Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]),
333            None,
334            CallScheme::Call,
335        );
336
337        assert_eq!(fuzzer.take_observed_calls().len(), 1);
338        assert!(fuzzer.take_observed_calls().is_empty());
339    }
340}