May 10, 2026·9 min·By

Solidity Reentrancy Attacks: How They Work and How to Prevent Them

Soliditysmart contractssecurityWeb3DeFi

Reentrancy attacks have drained over $2 billion from smart contracts. Understanding them is non-negotiable if you're writing production Solidity.

What Is Reentrancy?

When your contract sends ETH to an external address, execution pauses and control passes to the recipient. If the recipient is a malicious contract, it can call back into your function before the first call finishes -- re-entering the function while state is still inconsistent.

Classic Vulnerable Pattern

// VULNERABLE
contract Bank {
    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");

        // Bug: send ETH before updating state
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // Attacker re-enters before this line runs
        balances[msg.sender] = 0;
    }
}

The Attack Contract

contract Attacker {
    Bank public bank;
    uint256 public attackCount;

    constructor(address _bank) {
        bank = Bank(_bank);
    }

    function attack() external payable {
        require(msg.value >= 1 ether);
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    // This is called every time the Bank sends ETH
    receive() external payable {
        if (address(bank).balance >= 1 ether && attackCount < 10) {
            attackCount++;
            bank.withdraw();  // Re-enter!
        }
    }
}

The attacker deposits 1 ETH, withdraws, and during the ETH transfer re-enters withdraw() repeatedly before balances[msg.sender] = 0 runs. They drain the entire contract.

Fix 1: Checks-Effects-Interactions (CEI)

The canonical fix: always update state BEFORE external calls.

// SAFE: CEI pattern
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "Nothing to withdraw");

    // Effects first
    balances[msg.sender] = 0;

    // Interaction last
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Now when the attacker re-enters, balances[msg.sender] is already 0 and the require fails.

Fix 2: Reentrancy Guard

For complex functions where CEI is hard to apply cleanly:

// OpenZeppelin's ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

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

        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant sets a mutex that prevents re-entry. Implementation:

contract ReentrancyGuard {
    uint256 private _status = 1;  // NOT_ENTERED

    modifier nonReentrant() {
        require(_status != 2, "ReentrancyGuard: reentrant call");
        _status = 2;  // ENTERED
        _;
        _status = 1;  // NOT_ENTERED
    }
}

Cross-Function Reentrancy

A subtler variant: the attacker re-enters a DIFFERENT function:

// Still vulnerable even with state update in withdraw()
function withdraw() external {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: amount}("");  // attacker calls transfer()
    require(success, "Transfer failed");
}

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount);  // Uses stale state if re-entered
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

Fix: use nonReentrant on both functions, or use a single mutex across the contract.

Read-Only Reentrancy

Emerging attack vector where a view function is called during reentrancy and returns inconsistent state that a dependent protocol relies on. Solution: ensure state consistency before any external call, even if those calls appear "safe."

Audit Checklist

Before deploying any contract that handles ETH or ERC-20 tokens:

  1. Every call, transfer, or send -- is state updated before?
  2. Every function that sends value -- does it have nonReentrant?
  3. Are there cross-function reentrancy paths between state-modifying functions?
  4. Do you call external contracts whose implementation you don't control?

Use Slither or Echidna for automated detection: slither . --detect reentrancy-eth

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation