📚 本文是「区块链从小白到精通」系列第 10 篇 | 上一篇:《区块链从小白到精通(九):手把手写一个 NFT 合约------ERC-721 与元数据全解析》 | 下一篇:系列完结
前言
传统软件出了 Bug,可以发个补丁;网站被黑了,可以回滚数据库。但智能合约不行------代码一旦部署即冻结,任何人(包括你自己)都无法修改。它锁在链上,直面全世界黑客的火力侦察,而它后面守着的往往是真金白银。
这不是理论风险:区块链历史上因合约漏洞被盗的金额累计已达数十亿美元,单笔最著名的损失就是本篇开头要讲的 The DAO 事件------它甚至改写了以太坊的历史。
所以这一篇是全系列的"毕业考试":重入攻击的原理与修复、高频漏洞清单、Gas 优化技巧、上线前检查清单。读完它,你写出的合约才配得上"敢部署"三个字。
The DAO 事件:一次盗窃,改写一条链的历史
2016 年,一个叫 The DAO 的众筹项目在以太坊上募集资金,短短几周锁进了约 360 万 ETH(当时约占全网 ETH 的 14%)。它本质上就是一个巨大的链上基金:出资人拥有份额,可以随时申请赎回。
当年 6 月,攻击者利用其赎回函数的一个漏洞,反复、递归地把 ETH 转出,卷走约 360 万 ETH。社区激烈争论后,以太坊通过一次有争议的硬分叉回滚了这笔盗窃------部分社区成员拒绝接受,继续维护原链,这就是今天以太坊经典(Ethereum Classic, ETC)的由来。
一笔代码漏洞,直接催生了一条新链。而这背后的漏洞,正是区块链安全的头号通缉犯:重入攻击(Reentrancy)。
重入攻击原理:先转钱,后清账
看一个"标准错误示范"------几乎所有新手都写过这个版本的提款函数:
solidity
// ❌ 漏洞版:先转账,后清账
contract VulnerableVault {
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, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // ① 先转账
require(ok, "transfer failed");
balances[msg.sender] = 0; // ② 后清账
}
}
逻辑看起来无懈可击:查询余额 → 转账 → 清零。漏洞藏在 ①:call 会把控制权交到对方合约手里 ------如果 msg.sender 是一个恶意合约,它的 receive() 会在收到转账的瞬间被执行,此时账还没清(②还没跑),余额依然是 amount。
于是攻击合约写一个 receive,里面再调一次 withdraw:
solidity
// 攻击合约:在收到转账的瞬间再次提款
contract Attacker {
VulnerableVault public target;
constructor(address _target) { target = VulnerableVault(_target); }
function attack() external payable {
target.deposit{value: 1 ether}();
target.withdraw(); // 点火
}
// 每次收到转账都会再触发一次提款,直到 Gas 耗尽/合约余额清空
receive() external payable {
if (address(target).balance >= 1 ether) {
target.withdraw();
}
}
}
执行轨迹变成递归:
text
withdraw → 转账 → 攻击者 receive() → withdraw → 转账 → receive() → ...
(此时 balances 一直没被清零,每次都按原额提款)
存 1 个 ETH,取走 N 个。"重入"这个名字就是"执行中途重新进入合约"的意思。
修复三件套
第一件:Checks-Effects-Interactions 模式(最重要)
把函数体调整为固定的三段顺序:先校验(Checks)、再改状态(Effects)、最后外部调用(Interactions):
solidity
// ✅ 修复版:先清账,后转账
function withdraw() external {
uint256 amount = balances[msg.sender]; // Checks
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // Effects:先清账!
(bool ok, ) = msg.sender.call{value: amount}(""); // Interactions
require(ok, "transfer failed");
}
攻击者再次重入时,balances 已是 0,require(amount > 0) 直接拦截。这一条顺序习惯,能消掉 90% 的重入风险,请形成肌肉记忆。
第二件:重入锁
OpenZeppelin 提供的 ReentrancyGuard 用一个锁变量保证同一函数不会被并发重入:
solidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
function withdraw() external nonReentrant {
// ...
}
}
第三件:优先用内置转账
简单转账场景优先使用 transfer/send(固定 2300 Gas,低到不够对方再重入)或直接调用 call 但坚持第一件套。记住原则:任何把控制权交给外部合约的位置,都要假设对面站着攻击者。
高频漏洞清单:每一条都真实吞过钱
1. 整数溢出
0.8 版本之前,uint256 加减乘会静默回绕(0 - 1 = 2^256 - 1),真实项目因此损失惨重。0.8 起编译器内置了溢出检查,溢出直接 revert------但你仍需知道历史,且注意 unchecked 块里检查是被关闭的。
2. tx.origin 鉴权钓鱼
solidity
// ❌ 用 tx.origin 做权限判断
function withdraw() external {
require(tx.origin == owner); // 危险!
}
tx.origin 是整笔交易的最原始发起人(永远是 EOA),而 msg.sender 才是直接调用者。如果用户被诱导调用了恶意合约,恶意合约再调你的合约,tx.origin 依然是用户本人------鉴权通过,资金被转移。权限判断永远用 msg.sender。
3. 权限中心化
onlyOwner 合约的 owner 能冻结转账、增发代币、提走资金。owner 是不是多签?能不能放弃权限?审计报告里这永远是第一问。生产项目应使用多签钱包(如 Safe)或时间锁管理特权操作。
4. 未检查的外部调用返回值
token.transfer(...) 返回 bool 而不是 revert(部分老代币如此)。不检查返回值,转账失败你的合约却以为成功了,账目就此错乱。用 SafeERC20 的 safeTransfer 一律处理。
5. 前端假地址
合约没问题,但前端页面被换上了假合约地址,或用户没核对就复制了钓鱼地址。这类损失不在合约层,防线是:官方渠道核对地址 + 用户签名前看清目标。
⚠️ 提醒:漏洞清单没有"学完"的一天。保持阅读 SlowMist、rekt 等安全媒体的事故复盘,是合约开发者最低成本的持续学习。
Gas 优化:省下来都是用户的真金白银
EVM 里最贵的资源是 storage(链上存储),SSTORE 写一个新槽约 2 万 Gas,而内存操作只要 3 Gas。实用优化按性价比排序:
1. storage 变量打包(Packaging)
EVM 以 32 字节为一个槽。把小于 32 字节的变量放同一个 struct 里声明时会尽量共用槽位:
solidity
// ❌ 占两个槽:uint256 + uint256
struct Bad { uint256 amount; uint128 price; }
// ✅ 挤进一个槽:uint128 + uint128
struct Good { uint128 amount; uint128 price; }
2. calldata 替代 memory
外部函数的数组参数用 calldata 只读不拷贝,省一份拷贝的 Gas:
solidity
function sum(uint256[] calldata nums) external pure returns (uint256 total) { ... }
3. 自定义 error 替代 require 字符串
字符串会永久存进字节码并在 revert 时消耗大量 Gas:
solidity
// ❌ require(amount > 0, "amount must be greater than zero");
// ✅
error ZeroAmount();
if (amount == 0) revert ZeroAmount();
4. 缓存 storage 读取到内存
循环里反复读 storage 变量,先读进局部变量再用:
solidity
uint256 len = users.length; // 读一次 storage
for (uint256 i = 0; i < len; ++i) { ... }
5. 事件代替存储
只需展示、不需要被合约读取的数据(历史记录类),用 emit 存日志而非写 storage------日志便宜几个数量级,只是合约本身读不到。
上线前检查清单
| 检查项 | 说明 |
|---|---|
| 测试网全流程回归 | 部署 + 全部函数 + 边界值 + 异常路径,手工与脚本各跑一遍 |
| 自动化测试 | Foundry/Hardhat 写单元测试,覆盖正常流、权限、失败路径 |
| 静态分析 | 跑一遍 Slither 等工具,逐条人工确认告警 |
| 权限方案 | owner 是否多签(Safe)?是否需要时间锁、紧急暂停(Pausable)? |
| 事件完整性 | 所有状态变更都有事件,方便链下索引与对账 |
| 源码验证 | 区块浏览器 Verify & Publish,让任何人可审计、钱包可解析调用 |
| Gas 压测 | 关键路径在极端输入下的 Gas 上限是否可控 |
| 冷静期 | 上线前放下代码睡一觉,第二天以攻击者视角再读一遍 |
全系列回顾:从记账本到上线
十篇走完,回看这条路,其实每一步都踩在前一步上:
| 篇目 | 你学会了什么 |
|---|---|
| 一 | 区块链是什么:分布式账本、双花、三大特性 |
| 二 | 内部骨架:哈希、区块结构、链式存储、Merkle 树 |
| 三 | 共识机制:PoW / PoS / DPoS 的取舍 |
| 四 | 比特币:UTXO、挖矿、交易脚本 |
| 五 | 以太坊:账户模型、Gas、EVM、世界计算机 |
| 六 | 第一个合约:Remix、编译部署调用、测试网 |
| 七 | Solidity 语法:类型、数据位置、modifier、event、错误处理 |
| 八 | ERC-20:approve/transferFrom、手写实现、OpenZeppelin |
| 九 | ERC-721:归属模型、tokenURI、IPFS、生产级 NFT |
| 十 | 安全与上线:重入、漏洞清单、Gas 优化、检查清单 |
进阶方向给你四个:① 工程化开发 ------从 Remix 迁移到 Foundry 或 Hardhat,写测试、跑脚本、CI/CD;② 读经典源码 ------Uniswap V2 的核心合约只有几百行,是优雅与安全的教科书;③ Layer2 开发 ------合约层面几乎零成本迁移,生态增长最快;④ 深读 EIP------以太坊的所有演进都发生在 EIP 里,读原始提案是最高效的进阶方式。
写在最后
系列完结,最后送你三句话:
- 区块链的本质是"用机制替代信任"------前五篇的所有数学与共识,都是在回答"没有村长,怎么让大家相信一本账";
- 智能合约的本质是"代码即法律,部署即冻结"------所以安全不是加分项,是及格线;
- 学习的方式是动手------Remix 里那个 Counter 合约你 10 分钟就能部署,别让"等学完再做"偷走你的第一行链上代码。
从第一篇的那个记账本故事,到这里能独立发行代币、铸造 NFT、防御重入攻击------十篇的旅程结束了,你的链上之旅才刚刚开始。部署你的第一个真正合约那天,记得回来留个言。
祝 Gas 长低,永无 revert。(完)