This article was originally published on the Cyfrin Blog.
Welcome back to the "Solodit Checklist Explained" series.
Today, we are exploring Reentrancy Attacks.
A reentrancy attack is the most widely known attack vector in smart contracts. It exploits a vulnerability where a function can be repeatedly invoked before its prior execution completes. This enables an attacker to manipulate the contract's state.
In this article, we will dissect two key checklist items related to reentrancy attacks. We’ll explore code examples, detailed scenarios, and proven mitigation techniques.
This is part of the "Solodit Checklist Explained" series. You can find the previous articles here:
Solodit Checklist Explained (1) Denial-of-Service Attacks Part 1
Solodit Checklist Explained (2) Denial-of-Service Attacks Part 2
For the best experience, open a tab with the Solodit checklist to refer to it as you read. Examples are available on my GitHub here.
SOL-AM-ReentrancyAttack-1: Is there any state change after interaction with an external contract?
Description: Untrusted external contract calls could callback, leading to unexpected results such as multiple withdrawals or out-of-order events.
Remediation: Use the Check-Effects-Interactions pattern or reentrancy guards.
The core of this vulnerability lies in the sequence of operations: when a smart contract interacts with an external contract, it creates a potential window for that external contract to call back into the original contract before its initial interaction is complete.
If the original contract defers critical state changes until after the external call, a malicious re-entry can exploit this delay, manipulating the contract's state while it is in an inconsistent, transitional phase.
Consider the simple Bank
contract below. Its withdraw
function is designed to send Ether to a user and then update their balance.
How the attack works
This specific reentrancy attack unfolds in the following steps:
An attacker, via an externally owned account (EOA), initiates the process by funding the
Attacker
contract and then callingAttacker.attack()
.Inside
Attacker.attack()
, the attacker firstdeposit
s 1 Ether into theBank
contract, establishing a legitimate balance.Next, the
Attacker
contract callsbank.withdraw(1 ether)
.Within the
Bank.withdraw()
function:The
require(balances[msg.sender] >= amount)
check passes because the attacker's balance is sufficient.The line
(bool success, ) = msg.sender.call{value: amount}("");
executes, sending 1 Ether to theAttacker
contract.
Crucially, at this point, the
balances[msg.sender] -= amount;
line inBank.withdraw()
has NOT yet executed. TheBank
contract's state regarding the attacker's balance is still1 ether
.Upon receiving Ether, the
Attacker
contract'sfallback()
function is automatically triggered.Inside
Attacker.fallback()
:It checks
if (address(bank).balance >= 1 ether)
. Since theBank
contract still holds funds (e.g., 10 ETH initially plus the 1 ETH deposit), this condition is true.The
fallback()
function recursively callsbank.withdraw(1 ether)
again.
Steps 4-7 repeat. Each recursive call to
bank.withdraw()
succeeds because thebalances[msg.sender]
has not been decremented from the previous call. This allows the attacker to repeatedly withdraw funds.This process continues until the
Bank
contract's Ether reserves are significantly drained, specifically whenaddress(bank).balance
falls below the1 ether
withdrawal amount, which stops the recursion.Only after the
Bank
contract is substantially emptied, the original and all recursive calls toBank.withdraw()
finally complete their execution, and thebalances[msg.sender] -= amount;
lines decrement the attacker's balance for each withdrawal. However, by this point, the attacker has already extracted significantly more Ether than their legitimately deposited amount.
Remediation: Check-Effects-Interactions pattern and reentrancy guards
To address this reentrancy vulnerability, adhering to the Check-Effects-Interactions pattern is paramount. This pattern mandates a strict ordering of operations within a function:
Check: Validate all prerequisites and conditions (e.g., require statements).
Effects: Apply all internal state changes to the contract's variables.
Interactions: Execute external calls to other contracts or addresses.
By updating all internal state variables before making any external calls, the contract ensures that its internal state is consistent and correct, even if a re-entrant call occurs. Any re-entrant call would then operate on the updated, correct state, preventing illicit withdrawals.
Applying this pattern to the Bank
contract involves simply reordering the lines within the withdraw
function:
Now, should a re-entrant call occur, the balances[msg.sender] will already have been decremented. Any subsequent attempt to withdraw more than the adjusted balance will immediately fail the require(balances[msg.sender] >= amount) check, thereby preventing the reentrancy attack.
An alternative defense against reentrancy attacks is the implementation of Reentrancy Guards. This mechanism utilizes a state variable, such as a boolean or enumeration, combined with a modifier to ensure a function can only be executed once at a time. When a protected function is called, the state variable is set to a "locked" state, and any subsequent reentrant call to the same function is reverted, effectively preventing the attack.
OpenZeppelin’s ReentrancyGuard contract, which is widely adopted and thoroughly tested, can be inherited to implement this protection. Additionally, OpenZeppelin offers ReentrancyGuardTransient, which uses transient storage to achieve the same protection with reduced gas costs, making it an efficient alternative for compatible environments.
By prioritizing the Check-Effects-Interactions pattern and employing reentrancy guards, developers can effectively mitigate the risks associated with reentrancy vulnerabilities.
SOL-AM-ReentrancyAttack-2: Is there a view function that can return a stale value during interactions?
Description: Read-only reentrancy occurs when a view function is called during a reentrant execution. If the contract's state is temporarily inconsistent due to an ongoing external call, the view can return inaccurate data. This can mislead dependent protocols that rely on its output.
Remediation: Apply the Check-Effects-Interactions pattern to prevent inconsistent state, and ensure the reentrancy guard state is not
ENTERED
for critical view functions to prevent returning stale data.
This vulnerability is more subtle and frequently overlooked. While view
functions are not designed to modify a contract's state, they can still become a vector for attacks if they are called during an external call that temporarily puts the contract in an inconsistent state. This scenario is known as read-only reentrancy.
The core issue is that a view
function might read state variables that have been partially updated or are in a temporary, inconsistent configuration during an ongoing external call. If other critical contract logic or external protocols rely on the accuracy of these view
function returns, they could make flawed decisions based on "stale" or manipulated data. This could lead to financial losses or protocol compromise.
Consider a simplified lending protocol that integrates with a Vault
contract, using the Vault
's share price to determine collateral value for loans.
Understanding the read-only reentrancy attack
This complex read-only reentrancy attack unfolds through the following sequence:
Vulnerable
Vault.withdraw()
Function:The
withdraw
function in theVault
contract first calculatesethAmount
(the Ether to be sent) based on the currenttotalBalance
andtotalShares
.It then proceeds to update the user's
shares
and, crucially,totalShares
before making the externalcall
tomsg.sender
to transfer Ether.The critical flaw:
totalBalance
is only updated after the external call completes.This creates a specific reentrancy window:
totalShares
has already been reduced, yettotalBalance
still holds its larger, pre-withdrawal value relative to the shares. This temporary inconsistency causes thegetSharePrice()
function to report an inflated share price. For example, if a vault starts with 10 ETH and 10 shares (price 1 ETH/share), and a user withdraws two shares,totalShares
becomes eight shares, buttotalBalance
temporarily remains 10 ETH before the ETH transfer is fully processed. At this point,getSharePrice()
would calculate(10 * 1e18) / 8
, resulting in an inflated price of 1.25 ETH/share.Notably, the public stage-changing functions
deposit()
andwithdraw()
are protected by thenonReentrant
modifier, preventing direct reentrancy into these functions. However, thegetSharePrice()
function is a view function and does not have this guard, allowing it to be called during the reentrancy window.
LendingProtocol.borrow()
reliance:The
LendingProtocol
is designed to allow users to borrow against their collateralized Vault shares.When
LendingProtocol.borrow()
is called, it queriesvault.getSharePrice()
to determine the current value of the collateral.Since
LendingProtocol
relies on an externalview
function for a critical valuation, it becomes susceptible if thatview
function returns a stale or manipulated value.
Attack execution via
Attacker.exploit()
andAttacker.receive()
:Phase 1: Setup (
Attacker.exploit()
):The attacker deposits Ether into the
Vault
to acquireVault
shares.They approve the
LendingProtocol
to transfer a portion of these shares.They then deposit this portion into the
LendingProtocol
as collateral.Finally, the attacker calls
vault.withdraw()
with their remaining shares. This strategic call initiates the reentrancy.
Phase 2: Reentrancy (
Attacker.receive()
triggered byvault.withdraw
's external call):When
vault.withdraw()
sends Ether to theAttacker
's contract address, theAttacker
'sreceive()
function is automatically triggered.At this precise moment, the
Vault
contract is in its inconsistent state, astotalShares
has been reduced, buttotalBalance
has not.Inside
Attacker.receive()
, the attacker immediately callslending.borrow()
.When
lending.borrow()
executes, it fetches thesharePrice
fromvault.getSharePrice()
. Due to the Vault's temporary inconsistent state,vault.getSharePrice()
returns the temporarily inflated share price.lending.borrow()
calculates thecollateralValue
using this artificially inflated price, allowing the attacker to borrow significantly more Ether than their actual collateral should permit.
Phase 3: Cleanup:
After
Attacker.receive()
completes, the originalvault.withdraw()
call resumes and finally updatestotalBalance
. The Vault contract returns to a consistent state.The attacker, having successfully over-borrowed funds, extracts a profit from the lending protocol (if the lending protocol had enough funds).
This sophisticated attack shows why view
functions must return accurate values. When other protocols depend on them, even minor inconsistencies can lead to major exploits.
Remediation: Check-Effects-Interactions pattern and reentrancy guards
Strict adherence to Check-Effects-Interactions (primary mitigation): The inconsistency doesn’t originate in the
view
function itself, but theVault.withdraw()
function. We note again that thewithdraw()
function is protected by thenonReentrant
modifier, which prevents direct reentrancy. The developer may have assumed that this made it safe to ignore the Check-Effects-Interactions pattern. However, as we have seen, this allowed thegetSharePrice()
function to return a stale value during the reentrancy window, leading to the read-only reentrancy attack.
The most direct fix is to correctly implement the Check-Effects-Interactions pattern within withdraw. By moving the totalBalance -= ethAmount; line before the external call, the temporary inconsistency is eliminated, ensuring that getSharePrice() would always return a consistent value even if called recursively.
Extend reentrancy guards (defensive layer): Even after fixing the source of the state inconsistency, adding a reentrancy guard state check to view functions, especially those providing critical data, is a robust defensive measure. While it might seem counterintuitive to guard a function that doesn't modify state, it prevents it from being called during a reentrancy window, ensuring a consistent and accurate return value.
Note that we can not use the nonReentrant modifier onview
functions directly, as it would prevent them from being called in a read-only context. Instead, we can use a custom modifier that checks the reentrancy guard state before allowing access to criticalview
functions. This ensures that if a function is currently executing and has locked the reentrancy guard, any attempt to call theview
function will revert, preventing it from returning stale data.
By correctly implementing the Check-Effects-Interactions pattern in the withdraw
function (moving the totalBalance
update), the temporary state inconsistency is resolved.
Moreover, by adding an additional check _reentrancyGuardEntered()
to the function getSharePrice()
, re-entrant calls are directly prevented. If withdraw
(or any other function with nonReentrant
) is currently executing and thus has locked the reentrancy modifier, any attempt to call getSharePrice()
during that restricted period will revert.
This safeguard prevents getSharePrice() from being called when the contract's state might be temporarily inconsistent, ensuring it returns accurate values during safe execution.
Conclusion
We have explored critical check items related to reentrancy in smart contracts. By understanding how attackers can exploit delayed state changes and temporarily inconsistent data, developers can build stronger defenses. The increasing sophistication of decentralized finance (DeFi) protocols requires robust security measures integrated from the ground up.
Key takeaways:
Classic reentrancy: Updating state variables after an external call creates a window for re-entry, leading to exploits like illicit fund draining.
Check-Effects-Interactions pattern: This is the primary defense strategy, ensuring all state changes occur before external calls.
Reentrancy guards: Use a state variable, like a boolean or enum, with a modifier to lock a function during execution. This reverts any reentrant calls and effectively blocks reentrancy attacks.
Read-only reentrancy: Even view functions can become vulnerable if they return data from a contract in a temporarily inconsistent state due to an ongoing external call initiated by another function. Other protocols relying on such "stale" values can make incorrect decisions.
Protecting view functions: Fixing the root cause with Check-Effects-Interactions is essential. Moreover, reentrancy guard checks can be applied to critical view functions to prevent access during unsafe state windows.
Adopting these practices greatly reduces the threat of reentrancy attacks and helps build a more secure and dependable decentralized landscape.
In our next piece, we’ll uncover additional layers of smart contract security through an attacker’s perspective. Stay sharp, code with precision, and think like an adversary.