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。

好了,这一章的内容就到这里。整数溢出虽然看起来简单,但实际项目中踩坑的人真不少。下一章我们会聊重入攻击,那又是一个经典话题了。