Testing for reentrancy: write the attacker contract before the fix

To test for reentrancy, write a malicious contract that calls back into your contract from its receive function, run it against your vault, and assert that other users’ funds are untouched. If the assertion fails, you have the bug. The test stays in the suite after the fix and fails again if someone removes the protection.

Most guides show the fix first. I prefer the opposite order. Write the attack, see it succeed, and only then change the contract. A test you have never seen fail is a guess.

What reentrancy is

Reentrancy happens when a contract makes an external call before it finishes updating its own state, and the callee uses that call to enter the contract again. The second entry sees stale state, for example a balance that was already paid out but not yet zeroed.

Here is the definition I use when writing tests. Reentrancy is a call that reaches a function of your contract while another call to your contract is still executing and its state changes are not complete. It can enter the same function, a different function that shares state, or a view function that other contracts rely on.

The vulnerable contract

A vault that takes ether and pays it back. The bug is in the order of operations in withdraw.

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

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] = 0;
    }
}

The contract sends ether, then zeroes the balance. During the send, the recipient’s code runs. If that code calls withdraw again, balances[msg.sender] still holds the full amount.

The attacker

The attacker deposits a small amount, withdraws it, and uses receive to call withdraw again until the vault is empty. The retry cap keeps the test from looping forever if something goes wrong.

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

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

contract Attacker {
    IVault public immutable vault;
    uint256 public reentries;
    uint256 public maxReentries = 10;

    constructor(IVault _vault) {
        vault = _vault;
    }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= msg.value && reentries < maxReentries) {
            reentries++;
            vault.withdraw();
        }
    }
}

With 1 ether deposited, every nested withdraw pays out 1 ether because the stored balance never changes until the stack unwinds. The attacker ends up with everything the vault holds.

The failing test

I put the test in an abstract base contract with a _deploy hook. The same test body then runs against every version of the vault: the vulnerable one first, then each fix.

import {Test} from "forge-std/Test.sol";
import {IVault, Attacker} from "../src/reentrancy/Attacker.sol";
import {VulnerableVault} from "../src/reentrancy/VulnerableVault.sol";
import {FixedVault} from "../src/reentrancy/FixedVault.sol";

abstract contract ReentrancyTestBase is Test {
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function _deploy() internal virtual returns (IVault);

    function test_attackerCannotDrainOtherUsers() public {
        IVault vault = _deploy();

        // Two honest users put 10 ether in.
        vm.deal(alice, 5 ether);
        vm.deal(bob, 5 ether);
        vm.prank(alice);
        vault.deposit{value: 5 ether}();
        vm.prank(bob);
        vault.deposit{value: 5 ether}();

        // The attacker brings 1 ether.
        Attacker attacker = new Attacker(vault);
        try attacker.attack{value: 1 ether}() {} catch {}

        // Whatever happens to the attacker, the honest users' 10 ether must still be there.
        assertGe(address(vault).balance, 10 ether, "vault lost user funds");
    }
}

contract VulnerableVaultTest is ReentrancyTestBase {
    function _deploy() internal override returns (IVault) {
        return IVault(address(new VulnerableVault()));
    }
}

Two choices in this test matter.

The attack call sits in try/catch. A fixed vault may make the attack revert, and a revert is a good outcome. The test should not care how the attack ends. It cares what the vault holds afterwards.

The assertion checks the honest users’ 10 ether, not “the attacker got nothing”. An attacker can lose a little and still drain others, so the invariant to test is the one users care about.

Running it against the vulnerable vault:

[FAIL: vault lost user funds: 0 < 10000000000000000000] test_attackerCannotDrainOtherUsers()

The vault held 11 ether and now holds none. One initial withdrawal plus ten reentrant ones took 11 ether out, which is the 10 ether from Alice and Bob plus the attacker’s own 1.

Fixes

Two standard fixes. Apply them one at a time so the test shows what each does.

Checks-effects-interactions

Update state before making the external call.

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "nothing to withdraw");

    balances[msg.sender] = 0;

    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");
}

The reentrant call now sees a zero balance and reverts at the require. That revert bubbles up through receive, the first call returns false, and the outer withdraw reverts too. The attacker gets nothing, and the test passes.

A reentrancy guard

OpenZeppelin’s ReentrancyGuard sets a flag on entry and reverts if the flag is already set.

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract FixedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");

        balances[msg.sender] = 0; // effects before interactions

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

I use both on functions that move value. The ordering fix stops this specific bug, and the guard covers the cases where someone later adds a function and forgets the ordering. Subclass the base test for each variant and the same attack runs against it.

contract GuardedVaultTest is ReentrancyTestBase {
    function _deploy() internal override returns (IVault) {
        return IVault(address(new FixedVault()));
    }
}

Run all variants and the output is one failure, the vulnerable vault, and two passes. That is the result you want before the fix gets merged.

Cross-function and read-only reentrancy

The test above covers single-function reentrancy, the easy case. Two other shapes need their own tests.

Cross-function reentrancy. The attacker re-enters a different function that reads the state the first function has not yet updated. In April 2022 an attacker drained about $80M from Rari’s Fuse pools. Rekt’s write-up describes how the new reentrancy protections did not cover exitMarket in the Comptroller. The attacker did not re-enter the function under protection. The reentrant call went to a neighbor that read the same unfinished state.

To test this, make your attacker call each function that touches the same state from receive, including the one under attack. A simple way is to give the attacker a configurable target selector, and loop over all external functions of the contract in the test. A shared nonReentrant guard on all of them blocks the whole family. Guards on only some of them leave gaps.

Read-only reentrancy. A view function is called mid-execution by another contract and returns a value computed from half-updated state. The vault itself loses nothing. The integrator that reads its price or balance gets a wrong number and acts on it. To test it, write an attacker that reads the view function inside its callback and records what it sees, then assert that the value matches the consistent state. If your contract is read by others, a guard on view functions (or a check on the guard’s state) is worth considering.

Tokens add more entry points. ERC-777 hooks, ERC-721 safeTransferFrom with onERC721Received, and ERC-1155 receivers all hand control to the recipient. If your contract calls any of them, the attacker contract needs to implement those callbacks as well. The NFT edge cases post has related cases for minting and transfers.

Reentrancy test checklist

  1. An attacker re-enters the same function from receive or fallback, and honest users’ funds are intact afterwards.
  2. An attacker re-enters every other external function that shares state with the one being called.
  3. An attacker re-enters through each token hook the contract triggers (ERC-777, ERC-721, ERC-1155).
  4. An attacker reads each public view function from inside a callback, and the result matches consistent state.
  5. The same attack runs against each supported variant, including upgraded implementations. See testing smart contract upgrades for how to keep tests running across versions.
  6. Every function that sends ether or calls an untrusted address either has the guard or has a test showing that state is final before the call.

What tests cannot prove

A passing reentrancy test says the attacks you wrote do not work. It says nothing about the ones you did not write. Third-party callbacks, tokens with unusual transfer behavior, and multi-contract flows produce paths that are hard to enumerate by hand.

Use tests to lock in the known cases, and add other tools for the unknown ones. Static analysis such as Slither flags state updates after external calls. The Solidity docs on reentrancy and SWC-107 describe the patterns to look for, and an audit covers the integrations. Invariant tests written as properties over random call sequences find cases a hand-written attacker misses, though I would not rely on them alone for reentrancy.

Next step

Take the contract you are working on and list every function that sends ether or calls an address you do not control. For each one, write the attacker test first and run it against your current code. If everything passes on the first try, break the protection on purpose, remove the guard or move the state update below the call, and confirm that the test fails. For the general structure of unit tests around this, see unit test patterns for Solidity.

The test runner and CI wiring are in the automated testing setup guide.