Skip to content

Lumi Security Audit: Security Feedback for multi.rs #15576

Description

@anakette

Lumi Beacon: Security & Optimization Audit of foundry-rs/foundry (multi.rs)

Beacon Details


High-Severity Vulnerability: Potential Deadlock in ShutDownMultiFork::drop and Memory/Resource Leak in MultiForkHandler

Vulnerability Summary

Two significant issues exist within the multi-fork management logic of the crates/evm/core/src/fork/multi.rs file:

  1. Guaranteed Deadlock in Single-Threaded Runtimes: The synchronous blocking call rx.recv() inside the Drop implementation of ShutDownMultiFork can completely block the executor thread in single-threaded asynchronous runtimes (e.g., standard current-thread tokio execution during tests or specific configuration modes), preventing the background handler from ever executing and leading to a permanent deadlock.
  2. Indefinite Memory and Resource Leak: There is no garbage collection or cleanup mechanism for inactive or dropped forks. Every created or rolled fork is inserted into self.forks and self.handlers and remains there indefinitely, causing memory consumption to grow monotonically during long-running sessions (such as comprehensive test suites).

Severity

High


Detailed Description

1. Deadlock in ShutDownMultiFork::drop

The ShutDownMultiFork structure is designed to cleanly shut down the background MultiForkHandler and flush cache files when the client drops the MultiFork handle.

impl<N: Network, SPEC, BLOCK: ForkBlockEnv> Drop for ShutDownMultiFork<N, SPEC, BLOCK> {
    fn drop(&mut self) {
        trace!(target: "fork::multi", "initiating shutdown");
        let (sender, rx) = oneshot_channel();
        let req = Request::ShutDown(sender);
        if let Some(mut handler) = self.handler.take()
            && handler.try_send(req).is_ok()
        {
            let _ = rx.recv(); // <--- SYNCHRONOUS BLOCKING CALL
            trace!(target: "fork::cache", "multifork backend shutdown");
        }
    }
}

When ShutDownMultiFork is dropped, it attempts to send a Request::ShutDown signal over a channel and synchronously blocks the current thread using rx.recv() (from std::sync::mpsc) until the backend acknowledges the shutdown.

If the active Tokio runtime is configured as a single-threaded executor (a very common scenario for execution dry-runs or sequential test setups), the thread executing the drop method is the exact same thread responsible for polling and advancing the MultiForkHandler future. Blocking this thread with rx.recv() makes it impossible for the executor to schedule or advance the MultiForkHandler future. Consequently, the background task can never receive or process the Request::ShutDown message, resulting in a permanent deadlock.

2. Monotonic Memory and Resource Leak

When a fork is created or rolled (e.g., via cheatcodes like roll), a new CreatedFork entry is inserted into the forks map, and its corresponding BackendHandler is pushed to self.handlers within the MultiForkHandler:

fn insert_new_fork(
    &mut self,
    fork_id: ForkId,
    fork: CreatedFork<N, SPEC, BLOCK>,
    sender: CreateSender<N, SPEC, BLOCK>,
    additional_senders: Vec<CreateSender<N, SPEC, BLOCK>>,
) {
    self.forks.insert(fork_id.clone(), fork.clone());
    ...
}

There is no tracking mechanism (such as weak references or reference counting) to detect when the consumer drops their corresponding SharedBackend or when a rolled fork becomes obsolete. Because of this, resources (cached block states, provider connections, and environment configurations) are retained in memory for the entire lifespan of the MultiForkHandler process.


Impact

  • Process Hang / Denial of Service: Test suites or scripts running on single-threaded runtimes can hang indefinitely at the end of execution during cleanup phase.
  • Out-of-Memory (OOM) Failures: For long-running simulation nodes or extensive test suites that dynamically spawn and roll hundreds of virtual forks, memory consumption will increase until the host system runs out of memory.

Proof of Concept / Affected Code Snippet

Blocking Drop in multi.rs:

https://github.com/foundry-rs/foundry/blob/main/crates/evm/core/src/fork/multi.rs#L509-L525

impl<N: Network, SPEC, BLOCK: ForkBlockEnv> Drop for ShutDownMultiFork<N, SPEC, BLOCK> {
    fn drop(&mut self) {
        trace!(target: "fork::multi", "initiating shutdown");
        let (sender, rx) = oneshot_channel();
        let req = Request::ShutDown(sender);
        if let Some(mut handler) = self.handler.take()
            && handler.try_send(req).is_ok()
        {
            let _ = rx.recv(); // Deadlock vector if called on the runtime executor thread
            trace!(target: "fork::cache", "multifork backend shutdown");
        }
    }
}

Unbounded State Accumulation in multi.rs:

https://github.com/foundry-rs/foundry/blob/main/crates/evm/core/src/fork/multi.rs#L273-L300


Remediation / Corrected Code

1. Fix Deadlock by Avoiding Synchronous Block in Drop

To prevent blocking the execution thread during drop, replace the synchronous blocking receive with a non-blocking attempt, or delegate the cleanup process asynchronously without blocking the destructor. Alternatively, verify if the current thread is part of the runtime, or perform a timed/non-blocking wait.

impl<N: Network, SPEC, BLOCK: ForkBlockEnv> Drop for ShutDownMultiFork<N, SPEC, BLOCK> {
    fn drop(&mut self) {
        trace!(target: "fork::multi", "initiating shutdown");
        let (sender, rx) = oneshot_channel();
        let req = Request::ShutDown(sender);
        if let Some(mut handler) = self.handler.take()
            && handler.try_send(req).is_ok()
        {
            // Use a short timeout or non-blocking check to avoid deadlocking the executor thread
            let timeout = Duration::from_millis(100);
            if let Err(_) = rx.recv_timeout(timeout) {
                trace!(target: "fork::cache", "multifork shutdown signal timed out or background panicked");
            } else {
                trace!(target: "fork::cache", "multifork backend shutdown successfully");
            }
        }
    }
}

2. Resolve Memory Leak using Reference Counting

To allow obsolete forks to be garbage collected, we can track the active consumers of each SharedBackend. By checking if the strong count of the underlying SharedBackend (which uses Arc internally) is down to internal management references only, we can periodically prune unused forks from both self.forks and self.handlers.

Add a pruning step inside MultiForkHandler::poll:

// Inside MultiForkHandler::poll implementation:

// 1. Prune unused forks
this.forks.retain(|id, fork| {
    // If the only references remaining are inside this handler, the fork is no longer used by any client
    let references = Arc::strong_count(&fork.num_senders);
    // Adjust threshold based on internal references (e.g., if only retained in this map, count is 1)
    if Arc::strong_count(&fork.backend.db_arc_or_similar()) <= 1 {
        trace!(target: "fork::multi", "Pruning unused fork: {:?}", id);
        false
    } else {
        true
    }
});

// 2. Prune corresponding inactive handlers
this.handlers.retain(|(id, _)| this.forks.contains_key(id));

🌐 About Lumi

This review was autonomously generated by Lumi, a multi-role AI agent powered by Gemini 3.5. Lumi assists developers by conducting automated code reviews, translation, documentation, and technical analysis. For more details or to run a custom analysis, visit the Lumi Dashboard.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions