Docs / Application security

Field guide · Smart contract security

Smart contract security

How reentrancy drains a contract

By Abhimanyu Gupta, Founder & Principal Operator

On this page

On Ethereum, sending ether hands control to the receiver’s code, mid-function, before your next line runs. If you pay out before you update your books, the receiver can call back in and be paid again. That is reentrancy, and it drained the DAO.

Read with a purpose. Update your state before you make an external call, add a reentrancy guard for the cross-function cases, and remember that even a view function can hand out a wrong answer mid-call.

At a glance

The bug
A contract sends ether before it updates its own records, so the receiver can call back in and be paid again
Why it works
An external call hands control to code the attacker wrote, and that code runs before your function has finished
The fix in one line
Change your state before you make the external call. Checks, then effects, then interactions
The belt and braces
A reentrancy guard: a lock that refuses a second entry while the first is still running
The one people miss
Read-only reentrancy, where a view function returns a stale value mid-call and another contract trusts it

What reentrancy actually is

On Ethereum, when your contract sends ether to an address, the receiving contract gets to run code. Not later, not in another transaction: right then, in the middle of your function, before the line after the transfer has executed. If that receiver is hostile, the code it runs can call back into your contract while your first call is still open. That is reentrancy, and it is the vulnerability that drained the DAO of 3.6 million ether in 2016 and forced the hard fork that split Ethereum in two.

The mistake is almost always the same shape: a function pays out, and then updates the balance it just paid. Between those two steps the contract is lying about who owns what, and an external call is exactly the window an attacker needs to act on the lie.

The classic vulnerable withdraw

Here is a bank contract that lets people deposit and withdraw. Read the withdraw function closely, and in particular the order of the last two lines.

Vulnerable.solsolidity
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");

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

        // EFFECT: happens too late
        balances[msg.sender] = 0;
    }
}

The balance is set to zero after the ether is sent. For a normal wallet that is harmless, because a wallet has no code to run. For a contract it is fatal, because the call runs the receiver's receive() function, and that function can call withdraw() again while the balance is still the original amount.

The attacker's contract

The attack is a short contract with a payable fallback that re-enters until the bank is empty.

Attacker.solsolidity
interface IBank {
    function deposit() external payable;
    function withdraw() external;
}

contract Attacker {
    IBank public bank;

    constructor(address bankAddress) {
        bank = IBank(bankAddress);
    }

    function pwn() external payable {
        bank.deposit{value: 1 ether}();
        bank.withdraw();          // starts the loop
    }

    // Called every time the bank sends ether. Re-enter while funds remain.
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }
}

The call graph is a loop that never lets the bank finish a single withdrawal before starting the next one.

Attacker Bank 1. withdraw() 2. send ether (balance still not zero) 3. withdraw() again

The fix that costs nothing: checks, effects, interactions

The whole problem is the order of the last two lines. Move the effect before the interaction and the loop breaks on the second pass, because the balance is already zero when the attacker re-enters.

Fixed.sol · checks-effects-interactionssolidity
function withdraw() external {
    uint256 amount = balances[msg.sender];   // CHECK
    require(amount > 0, "nothing to withdraw");

    balances[msg.sender] = 0;                // EFFECT (before the call)

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

This is the pattern to internalise: read what you need and validate it, write every state change your function is going to make, and only then talk to the outside world. When the attacker re-enters at the interaction step, the state already reflects the withdrawal, so amount is zero and the require stops it. No extra gas, no library, just discipline about ordering.

Why not just send with transfer? The old advice was to use address.transfer, which forwards only 2300 gas and so cannot re-enter. It is no longer recommended: gas costs change with network upgrades, and a 2300-gas limit breaks legitimate receiving contracts, including some smart-contract wallets. Order your code correctly and use call, which forwards all gas, rather than relying on a gas stipend to save you.

The belt and braces: a reentrancy guard

Correct ordering fixes the single-function case. A guard defends the whole contract against the cases you did not think about, especially cross-function reentrancy, where the attacker re-enters through a different function that shares the same state. A guard is a lock: set it on entry, refuse if it is already set, clear it on exit.

Guard.solsolidity
contract Guarded {
    uint256 private locked = 1;   // 1 = open, 2 = locked

    modifier nonReentrant() {
        require(locked == 1, "reentrant call");
        locked = 2;
        _;
        locked = 1;
    }

    function withdraw() external nonReentrant {
        // ... checks-effects-interactions still applies inside
    }
}

OpenZeppelin ships this as ReentrancyGuard, and inheriting it and tagging the state-changing functions with nonReentrant is the standard belt-and-braces move. It stores 1 and 2 rather than true and false on purpose: flipping a storage slot between two non-zero values is cheaper than flipping to and from zero. A guard is not a substitute for correct ordering; it is a second layer for the reentrancy paths you did not anticipate.

The one people miss: read-only reentrancy

Here is the trap that catches teams who think a guard has finished the job. A reentrancy guard protects functions that change state, so it is usually applied only to those. But during an external call, your contract's state can be half-updated, and a view function that reads that state will return a wrong answer. If another protocol calls your view function for a price or a share count during that window, it acts on the wrong number. No state of yours is corrupted; someone else's is.

This has drained real money. A lending pool reads a curve pool's virtual price while the curve pool is mid-callback, gets an inflated number, and lends against collateral that is worth far less. The fix is to make the view path aware of the lock too: expose the reentrancy status, and have integrators refuse to trust a reading taken while the lock is held.

The short version. An external call in Ethereum runs the receiver's code inside your function, so anything you have not finished is fair game. Update your state before you make the call, every time. Add a reentrancy guard for the cross-function cases you did not foresee, and remember that even read-only functions can hand out a wrong answer mid-call. The bug is not exotic; it is an ordering mistake, and the fix is to put your own house in order before you open the door.

References & further reading

  1. Ethereum community, Smart contract security. The official developer documentation, including reentrancy and the checks-effects-interactions pattern.
  2. OpenZeppelin, ReentrancyGuard. The reference implementation of the lock, and notes on when to reach for it.
  3. ConsenSys Diligence, Reentrancy. A longer treatment covering single-function, cross-function and cross-contract variants.
  4. Solidity documentation, Security Considerations. The language authors' own list, with reentrancy first.
All guides Web app penetration testing

Want this tested on you?

Reading about it is one thing. Seeing it proven on your own systems is another.