W3
高危业务逻辑静态扫描有 1 条规则

拒绝服务(DoS)

Denial of Service

一个本该让所有人都能走通的操作,被某个人(或某个不可避免的状况)永久卡住:循环长度无上限、外部转账失败就整笔 revert、退款通道被恶意占位。

根因

Solidity 里最常见的一类是「一个失败拖垮全部」:在一个遍历所有用户的循环里给每个用户转账,只要有一个人拒收(比如它是一个没有 receive 的合约,或者 gas 故意给高),整笔交易就 revert,谁都领不到钱。

攻击是怎么一步步发生的

  1. 1
    攻击者

    构造一个 receive() 里主动 revert 的合约,或纯粹消耗掉 2300 gas 的合约

  2. 2
    被攻击合约

    把自己加入 recipients 列表

  3. 3
    任何人调用 distribute

    循环走到攻击者地址时 revert,整笔交易回滚

  4. 4
    结果

    所有人永远无法领取,协议表面上还「正常工作」

有漏洞的版本

不要照抄solidity
Solidity21
contract VulnerableAirdrop {
    address[] public recipients;
    mapping(address => uint256) public owed;

    function addRecipient(address who, uint256 amount) external {
        recipients.push(who);
        owed[who] = amount;
    }

    // 致命:在一个循环里主动转账给每个人
    function distribute() external {
        for (uint256 i = 0; i < recipients.length; i++) {
            // 只要有一个收款方 revert,整笔交易回滚 → 所有人都拿不到
            payable(recipients[i]).transfer(owed[recipients[i]]);
            owed[recipients[i]] = 0;
        }
    }

    // 变体:数组无限增长 → 到某一刻 distribute() 的 gas 超过区块上限,
    // 函数永久不可调用(这叫「gas 炸弹」)
}
  • transfer 失败会 revert 整个循环,一个恶意收款方就能让全部人拿不到钱
  • transfer 只转发 2300 gas,接收方是合约且逻辑稍复杂就会失败
  • recipients 无限增长 → 循环越跑越长,最终超过区块 gas 上限,函数彻底用不了
  • 这不是「性能问题」,而是永久性的功能失效

修复版本

推荐写法solidity
Solidity27
contract SafeAirdrop {
    mapping(address => uint256) public owed;

    function record(address who, uint256 amount) external {
        owed[who] += amount;
    }

    // 方案一:pull 模式 —— 谁想领谁自己来,一个人的失败不影响别人
    function claim() external {
        uint256 amount = owed[msg.sender];
        require(amount > 0, "nothing to claim");
        owed[msg.sender] = 0;                       // 先改状态(顺带防重入)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");             // 失败只影响自己
    }

    // 方案二:批量分页 —— 把「一次做完」改成「分多次做完」
    function recordBatch(address[] calldata who, uint256[] calldata amount, uint256 start, uint256 size)
        external
    {
        uint256 end = start + size;
        require(end <= who.length, "out of range");
        for (uint256 i = start; i < end; i++) {
            owed[who[i]] += amount[i];
        }
    }
}
  • pull 优于 push:把「主动转给别人」改成「别人自己来取」,失败被隔离在单个账户
  • 必须分页的循环就显式分页(start + size),单次调用的 gas 才可控
  • 没有循环时不需要遍历:用 mapping 而不是数组来记账,避免 O(n)
  • 给循环设上限,并在超限时把剩余部分标记为「需人工 / 二次处理」
  • 退款到「用户指定的地址」而不是固定的 msg.sender,避免收款方地址本身不可用

怎么防

  • 优先用 pull 模式(claim),而不是在循环里 push
  • 任何无界的遍历都要改成显式分页或换成 mapping 记账
  • 单个外部调用失败不应该导致整批操作失败
  • 给外部调用单独 try/catch,把失败记录下来而不是 revert 全部
  • 警惕「必须遍历全部用户才能完成的」设计,它在用户增长后必然失效

真实案例

2016GovernMental(庞氏合约)合约资金因 gas 耗尽被永久锁住

经典的 gas 炸弹案例,数组膨胀到无法遍历

2017Parity 多签(第二次)15 万 ETH 永久冻结

库合约被删除后,所有 delegatecall 调用的 gas 需求都变成异常值

2022多个空投合约发放失败 / 延长数周

循环转账被单点失败阻塞,只能改用 claim 重发

审计这个合约时要问的问题

  • 1.有没有遍历全量用户 / 全量数组的循环?它的上界是多少?
  • 2.循环里的外部调用失败会导致什么后果?
  • 3.这个函数在用户量增长 100 倍后还能调用成功吗(gas 估算)?
  • 4.有没有「必须所有人都配合才能推进」的流程?

这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。

静态扫描能发现它吗

能——扫描引擎里有 1 条规则与它相关:

assembly

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

延伸阅读

同属「业务逻辑」的其它 1

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