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