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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
The pattern is effective and convenient as a modifier, and it does not cover scenarios involving multiple contracts within the same system.