高危业务逻辑静态扫描有 1 条规则
拒绝服务(DoS)
Denial of Service
一个本该让所有人都能走通的操作,被某个人(或某个不可避免的状况)永久卡住:循环长度无上限、外部转账失败就整笔 revert、退款通道被恶意占位。
根因
Solidity 里最常见的一类是「一个失败拖垮全部」:在一个遍历所有用户的循环里给每个用户转账,只要有一个人拒收(比如它是一个没有 receive 的合约,或者 gas 故意给高),整笔交易就 revert,谁都领不到钱。
攻击是怎么一步步发生的
- 1攻击者
构造一个 receive() 里主动 revert 的合约,或纯粹消耗掉 2300 gas 的合约
- 2被攻击合约
把自己加入 recipients 列表
- 3任何人调用 distribute
循环走到攻击者地址时 revert,整笔交易回滚
- 4结果
所有人永远无法领取,协议表面上还「正常工作」
有漏洞的版本
不要照抄soliditySolidity21 行
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 上限,函数彻底用不了- 这不是「性能问题」,而是永久性的功能失效
修复版本
推荐写法soliditySolidity27 行
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但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。