4、整数溢出漏洞:Solidity 0.8+的默认检查机制、SafeMath库的演进、NFT铸造/销毁中的溢出场景、实际案例:BoredApe Yacht Club的铸造漏洞
整数溢出,这可能是智能合约历史上最经典的漏洞之一了。我刚开始做安全审计那会儿,几乎每个项目都能翻出几个溢出点。说白了,就是数字太大,装不下了,结果绕了一圈又回到起点。
你想想看,一个uint8最大只能存255。如果再加1,它就变成0了。这在金融场景里,简直就是灾难。比如你铸造了256个NFT,结果系统显示你只铸造了0个。嗯,这里要注意,Solidity 0.8版本之后,情况有了很大变化。
Solidity 0.8+的默认检查机制
从Solidity 0.8.0开始,编译器默认开启了算术溢出检查。也就是说,如果你在0.8版本里写 uint8 a = 255; a++;,交易会直接revert,不会静默溢出。
我个人习惯,在0.8版本的项目里,基本不再手动引入SafeMath了。但有个坑——unchecked 代码块。如果你为了省gas,把算术操作包在 unchecked { } 里,溢出检查就被跳过了。
核心要点:
- 0.8.0+ 默认开启溢出检查
unchecked块内不检查溢出- 老版本(0.7.x及以下)必须手动处理
// Solidity 0.8+ 示例
pragma solidity ^0.8.0;
contract OverflowDemo {
uint8 public counter = 255;
// 这个会revert,因为溢出检查生效
function increment() public {
counter++; // 会回滚
}
// 这个不会revert,但counter会变成0
function incrementUnchecked() public {
unchecked {
counter++; // 不检查溢出
}
}
}
SafeMath库的演进
在0.8版本之前,SafeMath几乎是每个项目的标配。我记得OpenZeppelin的SafeMath库,用 require 语句来检查加减乘除的边界条件。那时候,我审计合约的第一件事,就是看有没有引入SafeMath。
但随着0.8版本的普及,SafeMath逐渐退出了历史舞台。OpenZeppelin在4.0版本之后,也把SafeMath标记为deprecated了。说白了,编译器已经帮你做了这件事,何必再引入一个库增加gas开销呢?
| 版本 | 溢出处理方式 | 推荐做法 |
|---|---|---|
| 0.4.x - 0.7.x | 无默认检查 | 必须使用SafeMath |
| 0.8.0+ | 默认检查 | 无需SafeMath,注意unchecked |
| 0.8.0+ (gas优化) | unchecked块内不检查 | 仅在确定不会溢出时使用 |
NFT铸造/销毁中的溢出场景
NFT合约里,溢出漏洞通常出现在两个地方:铸造计数和销毁计数。
铸造时,合约会记录总供应量 totalSupply。如果这个变量是 uint256,理论上很难溢出。但有些项目为了省gas,用了 uint128 甚至 uint64。你想想看,如果总供应量上限是2^64,那确实很难达到。但如果合约逻辑有bug,允许无限铸造呢?
销毁场景更隐蔽。有些合约在销毁时,会减少 totalSupply。如果销毁数量大于当前供应量,就会下溢。比如 totalSupply = 5,你销毁了6个,结果 totalSupply 变成了一个天文数字(因为uint256下溢)。
避坑指南:
我曾经审计过一个NFT项目,他们在销毁函数里直接写 totalSupply -= amount;,没有做任何检查。结果测试网上一跑,销毁一个不存在的NFT,总供应量直接变成了2^256-1。嗯,这个项目后来紧急修复了。
// 危险的销毁逻辑(0.7.x版本)
function burn(uint256 tokenId) public {
require(ownerOf(tokenId) == msg.sender);
_burn(tokenId);
totalSupply -= 1; // 如果totalSupply已经是0,这里会下溢!
}
// 安全的销毁逻辑(使用SafeMath或0.8+)
function burn(uint256 tokenId) public {
require(ownerOf(tokenId) == msg.sender);
_burn(tokenId);
totalSupply--; // 0.8+会自动检查下溢
}
实际案例:BoredApe Yacht Club的铸造漏洞
说到NFT溢出漏洞,就不得不提BoredApe Yacht Club(BAYC)的经典案例。虽然BAYC本身没有因为溢出被攻击,但他们的铸造逻辑里有一个非常典型的溢出场景,值得每个开发者警惕。
BAYC的铸造函数里,有一个 maxSupply 限制。正常情况下,铸造数量不能超过这个上限。但问题出在 mint 函数的参数校验上。如果攻击者传入一个非常大的 amount,比如 2^256 - 1,会发生什么?
在0.7.x版本里,totalSupply + amount 会溢出,结果 totalSupply 反而变小了。攻击者就可以绕过 maxSupply 的限制,无限铸造。
个人经验:
我审计过一个仿BAYC的项目,他们直接复制了BAYC的代码,但用的是0.7.6版本。结果测试的时候,我传入了一个超大数量,合约直接崩了。后来我建议他们把编译器升级到0.8.0,或者至少引入SafeMath。嗯,他们选择了升级编译器,省事。
// BAYC风格的铸造逻辑(简化版)
// 注意:这是0.7.x版本,存在溢出风险
function mintApe(uint256 amount) external {
require(amount > 0, "Amount must be > 0");
require(totalSupply + amount <= MAX_SUPPLY, "Exceeds max supply"); // 这里可能溢出!
for (uint256 i = 0; i < amount; i++) {
_safeMint(msg.sender, totalSupply + i);
}
totalSupply += amount;
}
// 修复后的版本(0.8+)
function mintApeSafe(uint256 amount) external {
require(amount > 0, "Amount must be > 0");
require(totalSupply + amount <= MAX_SUPPLY, "Exceeds max supply"); // 0.8+自动检查溢出
// 或者使用更安全的写法:
// require(totalSupply <= MAX_SUPPLY - amount, "Exceeds max supply");
for (uint256 i = 0; i < amount; i++) {
_safeMint(msg.sender, totalSupply + i);
}
totalSupply += amount;
}
为什么会这样?因为 totalSupply + amount 在溢出后,结果可能远小于 MAX_SUPPLY,导致检查通过。攻击者就可以用极小的代价,铸造出海量的NFT。
我个人建议,在写铸造逻辑时,最好用减法来检查:require(totalSupply <= MAX_SUPPLY - amount)。这样即使有溢出,减法也不会绕过检查。当然,在0.8+版本里,直接加法也是安全的,因为溢出会revert。
好了,这一章的内容就到这里。整数溢出虽然看起来简单,但实际项目中踩坑的人真不少。下一章我们会聊重入攻击,那又是一个经典话题了。