W3
严重重入静态扫描有 3 条规则

重入攻击

Reentrancy

合约在「已经把钱转出去、但还没更新自己的账本」的那个瞬间,被转账接收方回头再调用了一次。于是同一个余额被反复支取。

根因

根本原因是状态更新与外部交互的顺序错了。EVM 是单线程的,但 call 会把控制权交给对方合约,对方在返回前可以做任何事——包括再调一次你的 withdraw。这不是并发问题,而是「一次调用里套了很多次调用」。

攻击是怎么一步步发生的

  1. 1
    攻击者合约

    存入 1 ETH,此时 balances[attacker] = 1 ETH

  2. 2
    VulnerableBank.withdraw

    读取 amount = 1 ETH,通过 require 校验

  3. 3
    call{value:}

    转账 1 ETH,EVM 进入攻击者的 receive(),控制权易主

  4. 4
    攻击者 receive()

    再次调用 withdraw —— 此时余额仍是 1 ETH,因为清零还没执行

  5. 5
    递归

    重复上述过程,直到合约余额被抽干或达到 gas 上限

  6. 6
    逐层返回

    每一层的 balances[attacker] = 0 才依次执行,但钱已经没了

有漏洞的版本

不要照抄solidity
Solidity22
// 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

攻击者是怎么写的

仅供理解攻击原理solidity
Solidity19
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

修复版本

推荐写法solidity
Solidity20
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

但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。

延伸阅读

下一步:想知道这条在手上的合约里有没有出现,用Rug Pull 扫描器,它会检查前面列出的 3 条相关规则。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。