Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Reentrancy, checks-effects-interactions and pull payments

How the reentrancy attack that hacked The DAO works and how to defend against it: operation order, ReentrancyGuard and deferred payments, using escrow.

6 min readUpdated:
In this article

If smart contracts had a "favourite disease" it would be reentrancy. The most famous losses in blockchain history began with it, and it still regularly surfaces in new projects. The idea is simple and nasty: when a contract sends money it hands control to outside code, and that code can call it back at a moment when state is not yet updated.

The attack by example

Imagine a naive withdrawal contract:

solidity
mapping(address => uint256) public balance;

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

    (bool ok, ) = msg.sender.call{value: amount}("");   // 1. send the money
    require(ok, "failed");

    balance[msg.sender] = 0;                             // 2. zero the balance
}

What is wrong: the balance is zeroed after the money is sent. If the recipient is not a person but a contract, receiving the ETH triggers its receive() function. Inside it the attacker calls withdraw() again. The balance is not zeroed yet - the check passes - the money goes out a second time, a third… until the contract is empty.

solidity
contract Attacker {
    Victim victim;
    receive() external payable {
        if (address(victim).balance >= 1 ether) victim.withdraw();   // re-entry
    }
    function attack() external payable {
        victim.deposit{value: 1 ether}();
        victim.withdraw();
    }
}

This is how The DAO - a project holding tens of millions of dollars in ETH - was hacked in 2016. The consequences were so serious that the Ethereum community split and the Ethereum Classic network appeared.

Defence 1: checks-effects-interactions

The main rule is the order of operations inside a function:

  1. Checks - validate: status, rights, amounts.
  2. Effects - update your own state.
  3. Interactions - only now call external contracts and send money.

The fixed version:

solidity
function withdraw() external {
    uint256 amount = balance[msg.sender];        // check
    require(amount > 0, "nothing");

    balance[msg.sender] = 0;                     // effect: zero first
    (bool ok, ) = msg.sender.call{value: amount}("");   // interaction: send after
    require(ok, "failed");
}

Now re-entry is useless: on the second call the balance is already zero and the check rejects it. In our escrow every payout works this way: the _settle function first moves the deal to Settled and writes the event, and only then sends the money.

Defence 2: ReentrancyGuard

Order discipline is good, but humans make mistakes. So a lock is added: the nonReentrant modifier sets an "inside a call" flag and rejects any re-entry until it is cleared.

solidity
modifier nonReentrant() {
    require(_status != ENTERED, "reentrant call");
    _status = ENTERED;
    _;
    _status = NOT_ENTERED;
}

In our contract nonReentrant sits on every function that moves money: createOffer, acceptOffer, cancelOffer, confirmDelivery, setCancelIntent, claimExpired, openDispute, resolveDispute, claimArbiterTimeout, withdrawCredit. A proven implementation from the OpenZeppelin library is used, not a home-made one.

Reentrancy can be trickier

There are subtler kinds too: cross-function (re-entering a different function that uses the same state) and read-only reentrancy (an outside contract reads your state at a moment when it is temporarily incorrect). The general principle of defence is the same: do not leave state "half-updated" at the moment of an external call and treat any external call as potentially hostile.

The second problem: a recipient can block a payout

Suppose that at deal completion the contract sends money to the seller, and the seller is a contract that always reverts on receiving money. If the payout is done with require(ok) the whole transaction reverts and the deal can never be settled. One dishonest participant freezes everyone else's money.

A similar situation: the recipient's address is blocked by the token issuer (this happens with USDT and USDC). Any transfer attempt reverts.

Defence 3: pull payments

The solution is to flip the model. Instead of "push" (the contract sends money to the recipient) use "pull" (the recipient comes for the money). If a payout fails, the contract does not revert the transaction but records a debt:

solidity
function _payout(address token, address to, uint256 amount) private {
    if (_trySend(token, to, amount)) {
        reserved[token] -= amount;
        emit Paid(token, to, amount);
    } else {
        credits[token][to] += amount;       // the obligation stays in the contract
        emit PaymentDeferred(token, to, amount);
    }
}

The deal closes either way, while the problematic recipient gets an entry "you are owed this much" and can collect the money by hand:

solidity
function withdrawCredit(address token) external nonReentrant {
    uint256 amount = credits[token][msg.sender];
    if (amount == 0) revert NothingToWithdraw();

    credits[token][msg.sender] = 0;         // zero first (CEI)
    reserved[token] -= amount;

    _sendOrRevert(token, msg.sender, amount);
    emit CreditWithdrawn(token, msg.sender, amount);
}

With a manual withdrawal gas is not capped - the recipient initiates the call and pays for it themselves. And if the withdrawal fails again, the transaction reverts and the entries stay untouched.

Defence 4: a gas cap on sending

One more trick: when automatically sending the native coin the contract gives the recipient limited gas (in our contract - 50,000). A recipient with a "greedy" receive() that burns all gas cannot hold up the deal's closing or squeeze anything extra out of it: their payout simply becomes deferred.

solidity
(bool ok, ) = to.call{value: amount, gas: PUSH_GAS_LIMIT}("");

Accounting of obligations

So that "debts" are not lost, the contract keeps a reserved[token] counter - the sum of everything it owes to anyone: the money of live deals, unclaimed credits and accrued fees. The main solvency invariant:

text
balanceOf(contract) >= reserved[token]

The owner can withdraw only the difference - "extra" money accidentally sent to the address. Other people's money is out of reach. How such an invariant is checked - in the article on testing.

Summary

  • An external call hands control to foreign code: treat it as hostile.
  • The order "checks → effects → interactions".
  • A nonReentrant lock as a second line of defence.
  • Pull payments so one recipient cannot block the rest.
  • A gas cap on automatic sending.
  • Strict accounting of obligations and a solvency invariant.

ERC-20 tokens are external contracts too, and they come in all kinds. Which "surprises" they bring and how to cope: non-standard tokens. And a full overview of typical mistakes is in the catalogue of vulnerabilities.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.