严重重入静态扫描有 3 条规则
重入攻击
Reentrancy
合约在「已经把钱转出去、但还没更新自己的账本」的那个瞬间,被转账接收方回头再调用了一次。于是同一个余额被反复支取。
根因
根本原因是状态更新与外部交互的顺序错了。EVM 是单线程的,但 call 会把控制权交给对方合约,对方在返回前可以做任何事——包括再调一次你的 withdraw。这不是并发问题,而是「一次调用里套了很多次调用」。
攻击是怎么一步步发生的
- 1攻击者合约
存入 1 ETH,此时 balances[attacker] = 1 ETH
- 2VulnerableBank.withdraw
读取 amount = 1 ETH,通过 require 校验
- 3call{value:}
转账 1 ETH,EVM 进入攻击者的 receive(),控制权易主
- 4攻击者 receive()
再次调用 withdraw —— 此时余额仍是 1 ETH,因为清零还没执行
- 5递归
重复上述过程,直到合约余额被抽干或达到 gas 上限
- 6逐层返回
每一层的 balances[attacker] = 0 才依次执行,但钱已经没了
有漏洞的版本
不要照抄soliditySolidity22 行
// 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 ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// ...回到这里时,上面那笔已经被对方重入取过很多次了
balances[msg.sender] = 0;
}
}call{value:}会执行对方合约的 receive / fallback,控制权在那一瞬间属于攻击者balances[msg.sender] = 0写在转账之后,重入发生时它还没执行- 注意:
call本身不是错,顺序才是错。很多修复版本依然用call
攻击者是怎么写的
仅供理解攻击原理soliditySolidity19 行
contract ReentrancyAttacker {
VulnerableBank private immutable bank;
constructor(address bankAddr) {
bank = VulnerableBank(bankAddr);
}
function attack() external payable {
bank.deposit{value: msg.value}();
bank.withdraw();
}
// 收到 ETH 时被调用 —— 这就是重入的入口
receive() external payable {
if (address(bank).balance >= msg.value) {
bank.withdraw();
}
}
}receive()里再次调用 withdraw,形成递归if (address(bank).balance >= msg.value)是止损条件:余额不够时停手,避免整笔交易 revert- 一笔攻击交易里可以把合约余额掏空,因为每次 withdraw 都读到同一个未清零的 balance
修复版本
推荐写法soliditySolidity20 行
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 两道防线同时上,因为单独任何一道都有失效的场景
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0; // 1) 先改完状态
(bool ok, ) = msg.sender.call{value: amount}(""); // 2) 再和外部交互
require(ok, "transfer failed");
}
}- CEI 顺序:Checks(校验)→ Effects(改状态)→ Interactions(外部调用)。这是根治手段
nonReentrant是额外保险:即使未来有人插了新代码破坏了 CEI,锁还在- 只上
nonReentrant不改顺序也可以挡住这一例,但如果加了新的跨函数调用路径,锁的作用域就未必覆盖得到
怎么防
- CEI 顺序:先把状态改到最终态,最后才做外部调用
- 加 ReentrancyGuard 锁(OpenZeppelin 的 nonReentrant)
- 对外转账优先用 pull(对方自己来取)而不是 push(主动转给它)
- 只给必要的 gas:
call{value: x, gas: 30000}("")限制对方能做的事,但这条很脆弱,不能当主防线 - 警惕「跨函数重入」:A 函数调用外部 → 外部回调 B 函数,两个函数共享同一份状态
真实案例
2016The DAO约 360 万 ETH
最著名的重入攻击,直接导致以太坊硬分叉为 ETH / ETC 两条链
2021CREAM Finance约 1.3 亿美元
重入与闪电贷组合,攻击者在一个交易内反复放大借贷额度
2023Curve(Vyper 编译器漏洞)约 7000 万美元
编译器缺陷导致重入锁失效——说明「写好代码」还不够,工具链也要锁版本
审计这个合约时要问的问题
- 1.每个 external 函数里,「状态更新」和「外部调用」谁在前?
- 2.外部调用之后还有没有依赖旧状态的逻辑?
- 3.重入锁是否覆盖了全部入口,包括 receiver 回调触发的路径?
- 4.有没有跨函数共享状态的重入路径(A 改一半 → 外部 → B 读到中间态)?
这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。
静态扫描能发现它吗
能——扫描引擎里有 3 条规则与它相关:
reentrancyunchecked-callpartial-reentrancy-guard但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。