智能合约安全 101 — 重入 & 常见 AI 生成错误
Source: Dev.to
到 Web3 之旅的第 30 天时,“安全”不再是一个令人害怕的审计词汇,而是变得非常真实。
因为第一次“无辜的” withdraw 函数把整个合约的资金抽干时,它根本不像一次黑客攻击。它看起来像……一次正常的付款。
- 用户点击 Withdraw。
- 合约向他们发送了一些 ETH。
- 一切看起来都正常——只是付款实际上并没有结束。它一直在回调。一次又一次地回调。
等到合约“意识到”发生了什么时,它的余额已经被抽空。
这就是 重入攻击——而在 AI 帮助我们比以往更快编写 Solidity 的今天,意外地将此类漏洞发布变得极其容易。
重入攻击到底是什么
把合约想象成自动售货机:
- 你投币。
- 机器检查你的余额。
- 然后它更新余额 并且掉落你的零食。
现在想象一个 bug:在零食门仍然打开时,你可以一次又一次地按“发放”按钮,在机器更新你的余额之前不断取零食。机器仍然以为你只拿了一件,却让你一直拿走零食。
这就是 Web3 中的重入攻击:
- 智能合约向另一个地址/合约发送 ETH 或代币。
- 在该转账进行时,接收方会运行代码。
- 该代码在原合约的余额或状态更新 之前 调用回原合约的函数。
- 攻击者重复这个循环,耗尽资金。
关键点: 合约 信任 外部调用只是一次“转账”。但在以太坊中,接收方可以是拥有自己逻辑的合约。
经典重入陷阱(以及 AI 为何喜欢它)
以下是 AI 工具在你请求“简单的 withdraw 函数”时常生成的易受攻击模式:
// 1️⃣ 检查 msg.sender 是否有足够的余额
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2️⃣ 发送 ETH
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
// 3️⃣ 更新余额
balances[msg.sender] = 0;
这在普通语言里看起来完全合乎逻辑,但 操作顺序 却致命:
- 外部调用被发出(指向攻击者合约)。
- 攻击者的 fallback 函数执行并再次调用
withdraw()。 - 原合约仍认为攻击者还有余额,因为余额尚未被设为 0。
- 资金不断流出,直至合约余额耗尽。
AI 模型常常:
- 使用
call{value: …}("")却未提及重入风险。 - 忘记使用重入保护(reentrancy guard)或 checks‑effects‑interactions 模式。
- 在你要求“简单”“最小”或“省 gas”代码时,优先给出“可运行示例”,而非“安全示例”。
代码可以编译,测试可以通过,但主网不会宽恕。
Checks‑Effects‑Interactions: Your First Shield
要防止重入攻击,翻转自动售货机的逻辑:
- 原来是: “先发送零食 → 再更新余额”
- 改为: “先更新余额 → 再发送零食”
在 Solidity 中,这种做法称为 checks‑effects‑interactions(检查‑效果‑交互):
| 阶段 | 你要做的事 |
|---|---|
| Checks(检查) | 验证条件(require 语句)。 |
| Effects(效果) | 更新内部状态(余额、映射等)。 |
| Interactions(交互) | 最后调用外部合约或发送 ETH。 |
为什么有效: 当攻击者尝试重入时,你的合约状态已经变为 “你没有余额”。即使他们再次调用,require 也会失败。
只要看到 call、delegatecall 或 transfer 后面紧跟状态更改,你的 “重入雷达” 就应该响起。
可重入性防护与安全模式
现代 Solidity 提供了额外的安全网,几乎在所有情况下都应当使用:
ReentrancyGuard– 一个简单的“锁定”标志,阻止函数在完成之前再次被调用。- Pull‑over‑push 支付 – 让用户在受控的最小函数中自行提取,而不是自动发送 ETH。
- 最小化外部调用 – 外部调用越少,潜在的可重入攻击面就越少。
可以把 ReentrancyGuard 想象成在自动售货机门上挂了一个 “忙碌中” 的标志:当一件零食正在出货时,任何人都不能再按按钮——包括试图通过后门刷按钮的同一个人。
作为初学者(尤其是使用 AI 时):
- 默认在任何发送 ETH 或调用不可信合约的函数上使用
ReentrancyGuard。 - 只有在完全理解为何在没有它的情况下也是安全的情况下才移除它。
Common AI‑Generated Security Mistakes (Beyond Reentrancy)
Reentrancy 只是一类问题。AI 往往会重复几种危险的模式:
| Mistake | Why it’s risky |
|---|---|
Missing access control – admin‑only functions (setPrice, pause, withdrawAll) left as public. | Anyone can call them. |
Blind trust in msg.sender – no role checks (onlyOwner, AccessControl). | No protection against unauthorized actors. |
| Unbounded loops over arrays – “simple” loops over large user lists that can run out of gas. | Perfect for griefing or DoS. |
Ignoring return values – not checking if token transfer or call actually succeeded. | Silent failures that leave funds stuck. |
所有这些在表面上看起来 合理。正是因为如此,它们才危险:问题不在编译器,而是在实际运行时出现。
作为初学者,你的任务不是写出“完美”的代码,而是要注意哪些地方 可能 出错,并在此放慢脚步。
为什么这对开发者很重要
重入不是“仅仅是另一个 bug”。它是一种会:
- 把金库掏空。
- 破坏用户信任。
- 永久在链上追随你的名字。
思考这个(小挑战)
下次你向 AI 工具提问时:
“编写一个简单的 Solidity 合约,允许用户存入和提取 ETH。”
请按以下步骤操作:
- 扫描外部调用(
call、transfer、send)。 - **检查顺序:**函数是在发送 ETH 之前还是 之后 更新状态?
- 问问自己:“如果接收方是一个在此处回调的合约,会发生什么?”
如果你感到哪怕一点点怀疑,那就说明你的安全直觉已经被唤醒了。
关键要点
重入攻击并不是魔法。它只是 “我在锁好自己的房子之前就信任了外部代码。”
随着 AI 开始编写我们更多的智能合约代码,请确保你始终:
- 应用 checks‑effects‑interactions(检查‑效果‑交互)原则。
- 使用
ReentrancyGuard(或等效的方案)。 - 仔细审查每一次外部调用。
保持安全,保持好奇,并负责任地继续构建。
Solidity,你的优势不会是打字更快。
而是 发现 AI 留下的陷阱。
不要以成为房间里最聪明的审计员为目标。
而是要成为那个永远不发布明显漏洞的开发者。
因为在 Web3 中,一个“无辜的”withdraw函数可能决定:
- “你部署的那个不错的小 dApp。”
- “还记得那个被抽干的合约吗?是的……那是我的。”
深入资源
-
Solidity 文档 — 安全注意事项 – Reentrancy section
官方语言文档解释了为何外部调用是危险的,以及如何安全地组织状态更改。 -
Consensys Diligence — 重入(智能合约最佳实践) – Link
攻击模式、检查‑效果‑交互以及常见陷阱的经典参考。 -
OpenZeppelin 合约 — ReentrancyGuard – Link
事实上的重入锁标准实现;对于想实际使用你所描述模式的读者来说是完美的后续。 -
其他资源:
-
关注系列:
-
加入 Telegram 上的 Web3ForHumans,我们一起头脑风暴 Web3。