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::{
4    MappingSlots, PendingMappingHash, capture_hash as capture_mapping_hash,
5    record_hash as record_mapping_hash, step as mapping_step,
6};
7use foundry_evm_core::constants::CHEATCODE_ADDRESS;
8use revm::{
9    Inspector,
10    context::{ContextTr, JournalTr, Transaction},
11    interpreter::{CallInput, CallInputs, CallOutcome, CallScheme, CallValue, Interpreter},
12};
13
14/// A sub-call observed by the [`Fuzzer`] inspector.
15///
16/// `depth` is 1-indexed relative to the top-level call: depth 1 is a direct call
17/// from the top-level callee, depth 2 is a sub-call of that call, and so on. The
18/// top-level call itself is never recorded.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ObservedCall {
21    pub depth: u32,
22    pub caller: Address,
23    pub target: Address,
24    pub calldata: Bytes,
25    pub value: Option<U256>,
26}
27
28/// An inspector that can fuzz and collect data for that effect.
29#[derive(Clone, Debug)]
30pub struct Fuzzer {
31    /// If set, it collects `stack` and `memory` values for fuzzing purposes.
32    pub collect: bool,
33    /// Given a strategy, it generates a random call.
34    pub call_generator: Option<RandomCallGenerator>,
35    /// If `collect` is set, we store collected values until the invariant worker drains them.
36    pub collected_values: Vec<B256>,
37    /// Maximum number of stack words staged before the invariant worker drains them.
38    pub max_collected_values: usize,
39    /// Mapping accesses observed during execution, used for storage slot sampling.
40    pub mapping_slots: Option<AddressMap<MappingSlots>>,
41    /// A 64-byte Keccak operation waiting to be recorded after execution.
42    pending_mapping_hash: Option<PendingMappingHash>,
43    /// Whether sub-calls should be buffered for later corpus seeding.
44    record_calls: bool,
45    /// Sub-calls observed since the last drain.
46    observed_calls: Vec<ObservedCall>,
47    /// Current EVM call depth. 0 means no active call, 1 means top-level call.
48    call_depth: u32,
49    /// Additional network-specific cheatcode addresses that must not be overridden.
50    extra_cheatcode_addresses: &'static [Address],
51}
52
53impl<CTX: ContextTr> Inspector<CTX> for Fuzzer {
54    #[inline]
55    fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) {
56        self.capture_mapping_hash(interp);
57        // We only collect `stack` and `memory` data before and after calls.
58        if self.collect {
59            self.collect_data(interp);
60        }
61    }
62
63    #[inline]
64    fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) {
65        self.record_mapping_hash(interp);
66    }
67
68    fn call(&mut self, ecx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
69        // We don't want to override the very first call made to the test contract.
70        if self.call_generator.is_some() && ecx.tx().caller() != inputs.transfer_from() {
71            self.override_call(ecx, inputs);
72        }
73
74        self.call_depth = self.call_depth.saturating_add(1);
75        if self.should_record_observed_call(inputs.scheme) {
76            self.observed_calls.push(ObservedCall {
77                depth: self.call_depth - 1,
78                caller: inputs.transfer_from(),
79                target: inputs.transfer_to(),
80                calldata: inputs.input.bytes(ecx),
81                value: inputs.transfer_value().filter(|value| !value.is_zero()),
82            });
83        }
84
85        // We only collect `stack` and `memory` data before and after calls.
86        // this will be turned off on the next `step`
87        self.collect = true;
88
89        None
90    }
91
92    fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) {
93        if let Some(ref mut call_generator) = self.call_generator {
94            // Decrement depth when any call ends while inside an override
95            if call_generator.override_depth > 0 {
96                call_generator.override_depth -= 1;
97            }
98        }
99
100        // We only collect `stack` and `memory` data before and after calls.
101        // this will be turned off on the next `step`
102        self.collect = true;
103
104        self.call_depth = self.call_depth.saturating_sub(1);
105    }
106}
107
108impl Fuzzer {
109    fn capture_mapping_hash(&mut self, interpreter: &Interpreter) {
110        if let Some(mapping_slots) = &mut self.mapping_slots {
111            mapping_step(mapping_slots, interpreter);
112            self.pending_mapping_hash = capture_mapping_hash(interpreter);
113        }
114    }
115
116    fn record_mapping_hash(&mut self, interpreter: &Interpreter) {
117        if let Some(pending) = self.pending_mapping_hash.take()
118            && interpreter.bytecode.action.is_none()
119            && let Some(mapping_slots) = &mut self.mapping_slots
120        {
121            record_mapping_hash(mapping_slots, interpreter, pending);
122        }
123    }
124
125    /// Constructs a new `Fuzzer` inspector.
126    pub const fn new(
127        max_collected_values: usize,
128        mapping_slots: Option<AddressMap<MappingSlots>>,
129    ) -> Self {
130        Self {
131            collect: true,
132            call_generator: None,
133            collected_values: Vec::new(),
134            max_collected_values,
135            mapping_slots,
136            pending_mapping_hash: None,
137            record_calls: false,
138            observed_calls: Vec::new(),
139            call_depth: 0,
140            extra_cheatcode_addresses: &[],
141        }
142    }
143
144    /// Sets additional network-specific cheatcode addresses that must not be overridden.
145    pub const fn with_extra_cheatcode_addresses(mut self, addresses: &'static [Address]) -> Self {
146        self.extra_cheatcode_addresses = addresses;
147        self
148    }
149
150    /// Enables or disables sub-call buffering.
151    pub const fn with_call_recording(mut self, record_calls: bool) -> Self {
152        self.record_calls = record_calls;
153        self
154    }
155
156    /// Enables or disables sub-call buffering on an existing inspector.
157    pub const fn set_call_recording(&mut self, record_calls: bool) {
158        self.record_calls = record_calls;
159    }
160
161    /// Returns the buffered sub-calls observed since the last drain.
162    pub fn take_observed_calls(&mut self) -> Vec<ObservedCall> {
163        std::mem::take(&mut self.observed_calls)
164    }
165
166    #[inline]
167    const fn should_record_observed_call(&self, scheme: CallScheme) -> bool {
168        self.record_calls && self.call_depth > 1 && scheme.is_call()
169    }
170
171    #[inline]
172    fn is_cheatcode_address(&self, address: Address) -> bool {
173        address == CHEATCODE_ADDRESS || self.extra_cheatcode_addresses.contains(&address)
174    }
175
176    /// Collects `stack` and `memory` values into the fuzz dictionary.
177    #[cold]
178    fn collect_data(&mut self, interpreter: &Interpreter) {
179        let remaining = self.max_collected_values.saturating_sub(self.collected_values.len());
180        self.collected_values
181            .extend(interpreter.stack.data().iter().take(remaining).copied().map(B256::from));
182
183        // TODO: disabled for now since it's flooding the dictionary
184        // for index in 0..interpreter.shared_memory.len() / 32 {
185        //     let mut slot = [0u8; 32];
186        //     slot.clone_from_slice(interpreter.shared_memory.get_slice(index * 32, 32));
187
188        //     state.insert(slot);
189        // }
190
191        self.collect = false;
192    }
193
194    /// Overrides an external call to simulate reentrancy attacks.
195    ///
196    /// This function detects reentrancy vulnerabilities by replacing external calls
197    /// with callbacks that reenter the caller contract.
198    ///
199    /// For calls with value (ETH transfers):
200    /// 1. Performs the ETH transfer via the journal first
201    /// 2. Replaces the call with a reentrant callback (value = 0)
202    ///
203    /// For calls without value:
204    /// - Replaces the call entirely with a reentrant callback
205    ///
206    /// This simulates malicious contracts that immediately reenter when called.
207    fn override_call<CTX: ContextTr>(&mut self, ecx: &mut CTX, call: &mut CallInputs) {
208        let target_is_cheatcode = self.is_cheatcode_address(call.transfer_to());
209        let Some(ref mut call_generator) = self.call_generator else {
210            return;
211        };
212
213        // Skip if:
214        // - Caller is test contract (don't override the initial calls from the test)
215        // - Not a CALL scheme (only override CALLs, not STATICCALLs, DELEGATECALLs, etc.)
216        // - Inside an override (prevent recursive overrides)
217        // - Target is cheatcode address
218        // - Neither caller nor target is a handler contract
219        //
220        // We override calls when either the caller OR target is a handler. This covers:
221        // 1. EtherStore pattern: handler sends ETH out, attacker reenters handler
222        // 2. Rari pattern: external protocol sends ETH to handler, handler reenters protocol
223        if call.transfer_from() == call_generator.test_address
224            || !call.scheme.is_call()
225            || call_generator.override_depth > 0
226            || target_is_cheatcode
227        {
228            return;
229        }
230        {
231            let handlers = call_generator.handler_addresses.read();
232            if !handlers.contains(&call.transfer_from()) && !handlers.contains(&call.transfer_to())
233            {
234                return;
235            }
236        }
237
238        // There's only a ~27% chance that an override happens (90% * 30% from strategy).
239        let Some(tx) = call_generator.next(call.transfer_from(), call.transfer_to()) else {
240            return;
241        };
242
243        // For value transfers, perform the ETH transfer before injecting the callback.
244        // This simulates a malicious receive() that gets the ETH and then reenters.
245        let value = call.transfer_value().unwrap_or_default();
246        let has_value = !value.is_zero() && call.gas_limit > 2300;
247        if has_value
248            && ecx.journal_mut().transfer(call.transfer_from(), call.transfer_to(), value).is_err()
249        {
250            return;
251        }
252
253        // Replace the call with a reentrant callback
254        call.input = CallInput::Bytes(tx.call_details.calldata);
255        call.caller = tx.sender;
256        call.target_address = tx.call_details.target;
257        call.bytecode_address = tx.call_details.target;
258        let target = ecx
259            .journal_mut()
260            .load_account_with_code(tx.call_details.target)
261            .expect("failed to load account");
262        // Clear known_bytecode to force REVM to load bytecode from the new target.
263        // Without this, REVM uses cached bytecode from the original target (e.g., empty
264        // bytecode for EOA), causing the call to short-circuit before executing any code.
265        call.known_bytecode =
266            (target.info.code_hash(), target.info.code.clone().unwrap_or_default());
267        // Clear value since ETH was already transferred above
268        call.value = CallValue::Transfer(alloy_primitives::U256::ZERO);
269
270        // Track that we're inside an overridden call to avoid recursive overrides
271        call_generator.override_depth = 1;
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use alloy_primitives::keccak256;
279    use foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS;
280    use revm::{Context, MainContext, bytecode::Bytecode};
281
282    fn fuzzer(record_calls: bool) -> Fuzzer {
283        Fuzzer::new(16, None).with_call_recording(record_calls)
284    }
285
286    /// Runs [`Inspector::call`] for a call at the current `call_depth` of `fuzzer`.
287    fn observe_call(
288        fuzzer: &mut Fuzzer,
289        caller: Address,
290        target: Address,
291        calldata: Bytes,
292        value: U256,
293        scheme: CallScheme,
294    ) {
295        let mut inputs = CallInputs {
296            input: CallInput::Bytes(calldata),
297            return_memory_offset: 0..0,
298            gas_limit: 0,
299            reservoir: 0,
300            bytecode_address: target,
301            known_bytecode: Default::default(),
302            target_address: target,
303            caller,
304            value: CallValue::Transfer(value),
305            scheme,
306            is_static: false,
307            charged_new_account_state_gas: false,
308        };
309        // `call` enters the new frame, so start from the parent depth.
310        fuzzer.call_depth -= 1;
311        fuzzer.call(&mut Context::mainnet(), &mut inputs);
312    }
313
314    #[test]
315    fn network_cheatcode_addresses_are_opt_in() {
316        let ethereum = Fuzzer::new(16, None);
317        assert!(!ethereum.is_cheatcode_address(MONAD_CHEATCODE_ADDRESS));
318
319        let monad =
320            Fuzzer::new(16, None).with_extra_cheatcode_addresses(&[MONAD_CHEATCODE_ADDRESS]);
321        assert!(monad.is_cheatcode_address(MONAD_CHEATCODE_ADDRESS));
322    }
323
324    #[test]
325    fn mapping_hashes_are_recorded_outside_dictionary_collection() {
326        let key = B256::with_last_byte(1);
327        let parent = B256::with_last_byte(2);
328        let preimage = [key.as_slice(), parent.as_slice()].concat();
329        let result = keccak256(&preimage);
330        let mut interpreter =
331            Interpreter::default().with_bytecode(Bytecode::new_raw(Bytes::from_static(&[
332                revm::bytecode::opcode::KECCAK256,
333            ])));
334        interpreter.memory.resize(64);
335        interpreter.memory.set(0, &preimage);
336        assert!(interpreter.stack.push(U256::from(64)));
337        assert!(interpreter.stack.push(U256::ZERO));
338
339        let mut fuzzer = Fuzzer::new(16, Some(AddressMap::default()));
340        fuzzer.collect = false;
341        fuzzer.capture_mapping_hash(&interpreter);
342
343        interpreter.stack.pop().unwrap();
344        interpreter.stack.pop().unwrap();
345        assert!(interpreter.stack.push(result.into()));
346        fuzzer.record_mapping_hash(&interpreter);
347
348        let slots = fuzzer.mapping_slots.unwrap();
349        assert_eq!(slots[&Address::ZERO].seen_sha3.get(&result), Some(&(key, parent)));
350    }
351
352    #[test]
353    fn observed_calls_are_disabled_by_default() {
354        let mut fuzzer = Fuzzer::new(16, None);
355        fuzzer.call_depth = 2;
356
357        observe_call(
358            &mut fuzzer,
359            Address::repeat_byte(0xaa),
360            Address::repeat_byte(0x11),
361            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
362            U256::ONE,
363            CallScheme::Call,
364        );
365
366        assert!(fuzzer.take_observed_calls().is_empty());
367    }
368
369    #[test]
370    fn observed_calls_skip_top_level_call() {
371        let mut fuzzer = fuzzer(true);
372        fuzzer.call_depth = 1;
373
374        observe_call(
375            &mut fuzzer,
376            Address::repeat_byte(0xaa),
377            Address::repeat_byte(0x11),
378            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
379            U256::ZERO,
380            CallScheme::Call,
381        );
382
383        assert!(fuzzer.take_observed_calls().is_empty());
384    }
385
386    #[test]
387    fn observed_calls_record_subcall_depth_target_calldata_and_value() {
388        let mut fuzzer = fuzzer(true);
389        let caller = Address::repeat_byte(0x11);
390        let target = Address::repeat_byte(0x22);
391        let calldata = Bytes::from_static(&[0xca, 0xfe, 0xba, 0xbe]);
392        let value = U256::from(7);
393        fuzzer.call_depth = 3;
394
395        observe_call(&mut fuzzer, caller, target, calldata.clone(), value, CallScheme::Call);
396
397        assert_eq!(
398            fuzzer.take_observed_calls(),
399            vec![ObservedCall { depth: 2, caller, target, calldata, value: Some(value) }]
400        );
401    }
402
403    #[test]
404    fn observed_calls_drop_zero_value() {
405        let mut fuzzer = fuzzer(true);
406        fuzzer.call_depth = 2;
407
408        observe_call(
409            &mut fuzzer,
410            Address::repeat_byte(0x11),
411            Address::repeat_byte(0x22),
412            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
413            U256::ZERO,
414            CallScheme::Call,
415        );
416
417        assert_eq!(fuzzer.take_observed_calls()[0].value, None);
418    }
419
420    #[test]
421    fn observed_calls_skip_non_call_schemes() {
422        let mut fuzzer = fuzzer(true);
423        fuzzer.call_depth = 2;
424
425        observe_call(
426            &mut fuzzer,
427            Address::repeat_byte(0x11),
428            Address::repeat_byte(0x22),
429            Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
430            U256::ZERO,
431            CallScheme::DelegateCall,
432        );
433
434        assert!(fuzzer.take_observed_calls().is_empty());
435    }
436
437    #[test]
438    fn take_observed_calls_drains_buffer() {
439        let mut fuzzer = fuzzer(true);
440        fuzzer.call_depth = 2;
441        observe_call(
442            &mut fuzzer,
443            Address::repeat_byte(0xaa),
444            Address::repeat_byte(0x33),
445            Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]),
446            U256::ZERO,
447            CallScheme::Call,
448        );
449
450        assert_eq!(fuzzer.take_observed_calls().len(), 1);
451        assert!(fuzzer.take_observed_calls().is_empty());
452    }
453}