Skip to content

Reentrancy

One of the most considerable threats when invoking external contracts is that the callee smart contract can seize control of the flow of operations. This means that the called contract could enact changes in the smart contract system that the calling function did not anticipate. This manipulation often materializes by redirecting the control flow, effectively turning the callee into the caller. This cycle can repeat itself, allowing the smart contract to enter the system again and again. This notorious vulnerability is termed reentrancy, which can adopt various forms. This section aims to elucidate the types of reentrancy and provide guidance on identifying such vulnerabilities within the Solidity code.

Single-Function Reentrancy

Reentrancy within a singular function context marked the first occurrence of this vulnerability being discovered and exploited. In such a scenario, an external call within the function triggers the function again, initiating the half-completed execution anew multiple times, leading to a cascade of state changes.

The primary takeaway from single-function reentrancy issues is that state changes and checks occurring after the external call enable the vulnerability. Let's consider an example inspired by the infamous DAO hack:

 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];
    (bool success, ) = msg.sender.call.value(amount)("");
    require(success);
    balances[msg.sender] = 0;
    }
}

In this example, the external call aims to transfer ETH value to the msg.sender. It's crucial to note that the user's balance is updated to zero only after the external call. Therefore, if the msg.sender happens to be another smart contract, it can, through its fallback function, recall the withdraw function.

Since the user balance mapping isn't updated until after the call, the reentering invocation of withdraw can repeatedly withdraw the designated balance, eventually depleting the smart contract of its total funds. In the final step, the attacker merely needs to check the overall ETH balance of their target smart contract to ensure they don't attempt to withdraw more ETH than the contract holds, as that would revert the entire transaction along with its state changes. Additionally, the attacker needs to consider the execution depth and its accumulated gas cost to not run out of gas during the repeated calls.

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."

The reentrancy vulnerability in the initial code example can be fixed by deferring the external call until after the user balance has been updated. Post-call, the code also includes a check to revert if the value transfer was unsuccessful:

 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);
    }
}

However, this solution still presents a potential issue. If another function were to call withdraw, it could potentially be subjected to the same attack. Therefore, any function that invokes an untrusted contract should be treated as untrusted. We'll explore potential solutions to this problem below.

Cross-Function Reentrancy

While the fix for single-function reentrancy effectively mitigates the security vulnerability within that context, the exploit is still be viable in more complex scenarios. For instance, if another function were to call withdraw, reentrancy could still be a potential threat. This is known as cross-function reentrancy, arising when multiple functions share the same state.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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];
        (bool success, ) = msg.sender.call.value(amount)("");
        require(success);
        balances[msg.sender] = 0;
    }
}

In this example, an attacker could initially call the withdraw function and, once they are called for the value transfer, they could reenter the contract, but this time calling the transfer function. As the withdraw call hasn't yet concluded when the attacker reenters via the transfer function, the balance mapping for the msg.sender hasn't been set to zero. As a result, the attacker can transfer funds in addition to their withdrawal amount.

The attacker can then transfer the incorrectly managed balance from the transfer function to an address they control, thereby repeating the process with that address, even though they already received their withdrawal amount. This type of vulnerability was also exploited during the 2016 DAO hack.

Just like in the single-function reentrancy example, the remediation here also involves deferring the external call until after all relevant state changes have occurred. This again emphasizes the importance of cautious programming practices to prevent reentrancy vulnerabilities.

Cross-contract Reentrancy

It is vital to highlight that the exploit described earlier is not strictly confined to shared state and functions within a single contract. For instance, if the balances mapping was set to public or was indirectly or directly exposed through a view function, and another contract relied on that state, the exploit could still be carried out successfully. This becomes particularly pertinent in highly modularized smart contract systems, where complex business logic rules. Cross-contract reentrancies can be less conspicuous and, thus, far more dangerous in these contexts.

Read-only Reentrancy

Read-only reentrancy is a specific instance of cross-contract reentrancy attacks. This type of vulnerability arises when a smart contract's behavior depends on the state of another contract. Attackers looking for reentrancy typically focus on state-changing functions. Nevertheless, views may yield outdated state information in the context of a reentrancy across contracts. Such a situation could lead to the exploitation of third-party infrastructure.

In the example below, a banking contract is presented that permits users to deposit and withdraw:

 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 against reentrancy issues by using OpenZeppelin's ReentrancyGuard. Additionally, a third-party smart contract exists that uses the bank's publicly available balances mapping for its own business logic, e.g. managing shares based on a user's investment in the bank:

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

    constructor(address _bank) {
        bank = Bank(_bank);
    }
    function getBalance(address account) public view returns (uint256) {
        return bank.balances(account);
    }
}

The bank's withdraw function is safeguarded against reentrancy. Still, this guard only applies to the contract itself and does not extend to other systems. The execution flow of the external call in withdraw can be taken over by an attacker, who can then craft a smart contract that presents a misleading balance while interacting with other projects.

In this case, BankConsumer is such a project, merely exposing a view function for illustration purposes. Such a view function could be internally utilized elsewhere, e.g. for assigning shares and a check preventing users to liquidate more shares than they own. An outdated balance exposed by the Bank contract can be exploited by designing a malicious receive function:

 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, illustrating that the balances visible to the BankConsumer contract are outdated. When the system is set up in Remix, and the attack function is executed, the following events are logged by the Attacker contract:

 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 indicates that, during the receive function, the Bank contract still presents a balance of 1 ETH for the Attacker contract address despite the funds having been transferred to the attacker. This outdated information can be manipulated to mislead third-party infrastructure building on the Bank contract, such as BankConsumer.

Protection Shortcomings

As shown above, reentrancy can impact a single function, span multiple functions, or even extend across distinct smart contracts, effectively breaching the smart contract system. Hence, any protective measures against reentrancy restricted to a single function fall short of providing sufficient safeguarding.

Take, for example, solutions like OpenZeppelin's ReentrancyGuard. They store their state within the contract they are incorporated in and, thus, cannot shield against cross-contract reentrancy. The checks-effects-interactions pattern should be adhered to more generally. If this principle is meticulously applied, reentrancy vulnerabilities can be eradicated since no state change occurs following external calls.

It's crucial to consider that additional function calls must also be scrutinized. The following example emphasizes this point:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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);
    claimed[recipient] = true;
    }
}

In this case, even though withdrawBonus does not directly engage an external contract, the call within withdraw is sufficient to render it susceptible to a reentrancy attack. Hence, withdraw must also be regarded as untrusted in internal call contexts. Applying the checks-effects-interactions pattern again, a more robust version of the above code would look like this:

 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(amountToWithdraw)("");
    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);
    }
}

One more approach to protection that is generally more applicable involves using a Mutex. This technique leads to a "lock" state, allowing only the lock's owner to change it. OpenZeppelin's ReentrancyGuard utilizes this to offer a universal solution to reentrancy protection 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;
 }

This pattern proves effective and convenient as it can be employed as a modifier. However, it falls short of covering scenarios involving multiple contracts within the same system.