Skip to content

Reentrancy

The most considerable threat when invoking external contracts is that the callee seizes control of the flow of operations. The called contract enacts changes in the smart contract system that the calling function did not anticipate, usually by redirecting the control flow so that the callee becomes the caller. The cycle repeats, and the contract is entered again and again. This vulnerability is termed reentrancy, and it takes several forms.

Single-function Reentrancy

Reentrancy within a single function context was the first occurrence of the vulnerability to be discovered and exploited. An external call within the function triggers the function again, restarting the half-completed execution multiple times and producing a cascade of state changes.

State changes and checks placed after the external call are what enable the vulnerability. An example inspired by the infamous DAO hack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pragma solidity ^0.8.17;

contract Vulnerable {
    mapping (address => uint) private balances;

    function withdraw() public {
        uint amount = balances[msg.sender];
        // @audit the external call runs before the balance is zeroed
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
        balances[msg.sender] = 0;
    }
}

The external call transfers ETH value to msg.sender, and the user's balance drops to zero only afterwards. A msg.sender that is itself a smart contract therefore re-enters withdraw through its fallback function.

Because the balance mapping is not updated until after the call, the reentering invocation withdraws the same balance repeatedly and eventually depletes the contract. The attacker only has to watch the target's total ETH balance, since withdrawing more than the contract holds reverts the entire transaction along with its state changes. Execution depth and accumulated gas cost set the second limit on how far the loop runs.

A piece of Ethereum History

On June 17th, 2016, The DAO was compromised, and a staggering 3.6 million Ether (~$6.8B as of July 2023) was stolen using the first single-function reentrancy attack. The Ethereum Foundation was forced to release a critical update to reverse the hack, ultimately leading to Ethereum's fork into Ethereum Classic, advocating "code is law," and Ethereum, professing "consensus is king."

Deferring the external call until after the user balance has been updated fixes the vulnerability. The post-call check reverts if the value transfer failed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
contract Vulnerable {
    mapping (address => uint) private balances;

    function withdraw() public {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
    }
}

A problem remains. Another function calling withdraw is subject to the same attack, so any function invoking an untrusted contract counts as untrusted itself.

Cross-function Reentrancy

The single-function fix closes the vulnerability in that one context while leaving the exploit viable in more complex scenarios. Cross-function reentrancy arises where multiple functions share the same state.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
contract Vulnerable {
    mapping (address => uint) private balances;

    function transfer(address to, uint amount) public {
        if (balances[msg.sender] >= amount) {
            balances[to] += amount;
            balances[msg.sender] -= amount;
        }
    }

    function withdraw() public {
        uint amount = balances[msg.sender];
        // @audit transfer still sees the pre-withdrawal balance during this call
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
        balances[msg.sender] = 0;
    }
}

An attacker calls withdraw and, once invoked for the value transfer, re-enters the contract through transfer instead. The withdraw call has not concluded, so the balance mapping for msg.sender is not yet zero, and the attacker transfers funds on top of the withdrawal amount.

The incorrectly managed balance then moves to an address the attacker controls, and the process repeats with that address even though the withdrawal amount has already been received. The 2016 DAO hack exploited this variant as well.

The remediation matches the single-function case: defer the external call until after all relevant state changes, which underlines how much cautious ordering does for reentrancy.

Cross-contract Reentrancy

The exploit is not confined to shared state and functions within a single contract. A balances mapping marked public, or exposed indirectly through a view function, carries the same risk into any other contract relying on that state. Highly modularized smart contract systems with complex business logic are the natural habitat for this variant, where cross-contract reentrancies are less conspicuous and correspondingly more dangerous.

Read-only Reentrancy

Read-only reentrancy is a specific instance of the cross-contract case. It arises where a smart contract's behavior depends on the state of another contract. Attackers hunting for reentrancy focus on state-changing functions, while views return stale state during a reentrancy across contracts, which exposes third-party infrastructure.

The example below is a banking contract permitting deposits and withdrawals:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

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

    function withdraw() public nonReentrant {
        uint amount = balances[msg.sender];
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success);
        balances[msg.sender] = 0;
    }
}

The Bank contract protects itself with OpenZeppelin's ReentrancyGuard. A third-party smart contract consumes the bank's public balances mapping for its own business logic, for instance to manage shares based on a user's investment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
contract BankConsumer {
    Bank private bank;

    constructor(address _bank) {
        bank = Bank(_bank);
    }
    function getBalance(address account) public view returns (uint256) {
        // @audit the balance is read mid-withdrawal and is stale
        return bank.balances(account);
    }
}

The guard on withdraw covers the contract itself and does not extend to other systems. An attacker takes over the execution flow of the external call in withdraw and crafts a smart contract that presents a misleading balance while interacting with other projects.

BankConsumer is such a project, exposing a view function for illustration. A view of this kind is used internally elsewhere, for assigning shares or for a check preventing users from liquidating more shares than they own. A malicious receive function exploits the outdated balance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract Attacker {
    event Checkpoint(uint256 balance);

    Bank private bank;
    BankConsumer private consumer;

    constructor(address _bank, address _consumer) payable {
        bank = Bank(_bank);
        consumer = BankConsumer(_consumer);
    }

    function attack() public {
        emit Checkpoint(consumer.getBalance(address(this)));
        bank.deposit{value: 1 ether}();
        bank.withdraw();
        emit Checkpoint(consumer.getBalance(address(this)));
    }

    receive() external payable  {
        emit Checkpoint(consumer.getBalance(address(this)));
        // more malicious code here
    }
}

The Attacker contract records Checkpoint events showing that the balances visible to BankConsumer are outdated. Setting the system up in Remix and executing attack logs the following events:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "0",
            "balance": "0"
        }
    },
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "1000000000000000000",
            "balance": "1000000000000000000"
        }
    },
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "0",
            "balance": "0"
        }
    }
]

The second Checkpoint event shows the Bank contract still reporting a balance of 1 ETH for the Attacker address during the receive function, after the funds have already been transferred out. That outdated information misleads third-party infrastructure built on the Bank contract, such as BankConsumer.

Protection Shortcomings

Reentrancy reaches a single function, spans multiple functions, or extends across distinct smart contracts, which means any protection scoped to a single function is insufficient.

OpenZeppelin's ReentrancyGuard and comparable solutions store their state inside the contract they are incorporated in and therefore cannot shield against cross-contract reentrancy. The checks-effects-interactions pattern applies more generally, and applied meticulously it eradicates reentrancy vulnerabilities, since no state change occurs after an external call.

Additional function calls need the same scrutiny:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
contract Vulnerable {
    mapping (address => bool) private claimed;
    mapping (address => uint) private rewards;

    function withdraw(address recipient) public {
        uint amount = rewards[recipient];
        rewards[recipient] = 0;
        (bool success, ) = recipient.call{value: amount}("");
        require(success);
    }

    function withdrawBonus(address recipient) public {
        // Each recipient should only be able to claim the bonus once
        require(!claimed[recipient]);

        rewards[recipient] += 100;
        withdraw(recipient);
        // @audit the claim is recorded after an external call has already run
        claimed[recipient] = true;
    }
}

withdrawBonus engages no external contract directly, and the call inside withdraw is enough to render it susceptible. withdraw therefore counts as untrusted in internal call contexts too. Applying checks-effects-interactions produces a more robust version:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
contract Vulnerable {
    mapping (address => bool) private claimed;
    mapping (address => uint) private rewards;

    function withdraw(address recipient) public {
        uint amount = rewards[recipient];
        rewards[recipient] = 0;
        (bool success, ) = recipient.call{value: amount}("");
        require(success);
    }

    function withdrawBonus(address recipient) public {
        require(!claimed[recipient]); // Each recipient should only be able to claim the bonus once
        claimed[recipient] = true;
        rewards[recipient] += 100;
        withdraw(recipient);
    }
}

The other broadly applicable protection is a Mutex, which establishes a lock state that only the lock's owner changes. OpenZeppelin's ReentrancyGuard uses it to offer a general solution for individual contracts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 modifier nonReentrant() {
 _nonReentrantBefore();
 _;
 _nonReentrantAfter();
 }

 function _nonReentrantBefore() private {
 // On the first call to nonReentrant, _status will be _NOT_ENTERED
 if (_status == _ENTERED) {
 revert ReentrancyGuardReentrantCall();
 }

 // Any calls to nonReentrant after this point will fail
 _status = _ENTERED;
 }

The pattern is effective and convenient as a modifier, and it does not cover scenarios involving multiple contracts within the same system.