整数溢出漏洞:溢出原理、SafeMath库使用、Solidity 0.8+内置检查、实际案例分析

整数溢出,说白了就是数字“撑爆了”。

我刚开始写Solidity时,觉得这玩意儿挺简单的。不就是数字加加减减嘛,能出什么问题?直到我在一个DeFi项目中亲眼看到,一笔交易因为溢出漏洞,让攻击者凭空多出了几百万个代币。嗯,从那以后,我对整数溢出再也不敢掉以轻心了。

一、溢出原理:为什么数字会“爆”

Solidity里的整数类型,比如uint8、uint256,都有固定的位数。uint8最大能存255,uint256最大能存2^256-1。你想想看,如果往一个uint8里加1,它已经255了,再加1会怎样?

它会变成0。这就是溢出。

反过来,如果从0里减1,它会变成255。这叫下溢。

核心原理:整数溢出是因为Solidity的整数类型是模运算的。比如uint8,所有运算结果都会对256取模。255 + 1 = 256,256 % 256 = 0。

我举个例子,你一看就明白:

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

contract OverflowExample {
    uint8 public balance = 255;
    
    function addOne() public {
        // 这里会发生溢出
        balance += 1;
        // balance 会变成 0,而不是 256
    }
    
    function subtractOne() public {
        // 这里会发生下溢
        balance -= 1;
        // balance 会变成 255,而不是 -1
    }
}

为什么会这样?因为Solidity 0.8版本之前,默认不检查溢出。它觉得你应该自己管好自己。但现实是,很多开发者根本没意识到这个问题。

二、SafeMath库:老项目的救星

在Solidity 0.8之前,OpenZeppelin的SafeMath库几乎是每个项目的标配。我个人习惯,只要用0.8以下的版本,第一件事就是引入SafeMath。

SafeMath的原理很简单:每次运算前先检查会不会溢出,如果会,就直接revert。

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/math/SafeMath.sol";

contract SafeMathExample {
    using SafeMath for uint256;
    
    uint256 public balance;
    
    function deposit(uint256 amount) public {
        // SafeMath的add方法会检查溢出
        balance = balance.add(amount);
    }
    
    function withdraw(uint256 amount) public {
        // SafeMath的sub方法会检查下溢
        balance = balance.sub(amount);
    }
}

你看,代码几乎没变,只是把普通的加减改成了.add()和.sub()。但安全性天差地别。

我的建议:如果你还在维护0.8之前的合约,赶紧用SafeMath。别嫌麻烦,一次漏洞可能让你损失几百万。

SafeMath里每个方法都长这样:

function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    require(c >= a, "SafeMath: addition overflow");
    return c;
}

function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    require(b <= a, "SafeMath: subtraction overflow");
    return a - b;
}

说白了,就是加完以后检查结果是不是比加数小。如果小了,说明溢出了。

三、Solidity 0.8+:内置检查,省心多了

从Solidity 0.8.0开始,编译器默认对所有算术运算进行溢出检查。这意味着你不需要再手动引入SafeMath了。

我刚开始用0.8时还有点不习惯,总觉得少了点什么。后来发现,嗯,真香。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BuiltInCheck {
    uint256 public balance;
    
    function deposit(uint256 amount) public {
        // 0.8+ 会自动检查溢出
        balance += amount;
        // 如果溢出,会自动revert
    }
    
    function withdraw(uint256 amount) public {
        // 0.8+ 会自动检查下溢
        balance -= amount;
        // 如果下溢,会自动revert
    }
}

但是,这里有个坑。0.8+的溢出检查只对普通算术运算有效。如果你用了unchecked块,编译器就不会检查了。

注意:unchecked块里的运算不会做溢出检查。只有在你明确知道不会溢出,或者需要故意利用溢出行为时,才应该使用unchecked。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract UncheckedExample {
    function uncheckedAdd(uint8 a, uint8 b) public pure returns (uint8) {
        unchecked {
            // 这里不会检查溢出
            return a + b;
        }
    }
    
    function checkedAdd(uint8 a, uint8 b) public pure returns (uint8) {
        // 这里会检查溢出
        return a + b;
    }
}

你想想看,什么时候需要用unchecked?比如在循环里做大量累加,每次检查溢出会消耗更多gas。如果你确定不会溢出,可以用unchecked来省点gas。

四、实际案例分析:一个价值百万的教训

我记得有一个著名的漏洞案例——BeautyChain(BEC)代币。这个项目因为整数溢出漏洞,导致攻击者凭空转出了天量代币。

漏洞代码大概长这样:

function batchTransfer(address[] memory _receivers, uint256 _value) public returns (bool) {
    uint256 cnt = _receivers.length;
    uint256 total = cnt * _value;  // 这里!乘法可能溢出
    require(total <= balances[msg.sender]);  // 如果total溢出了,这个检查就废了
    
    balances[msg.sender] = balances[msg.sender].sub(total);
    for (uint256 i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
    }
    return true;
}

问题出在哪?cnt * _value这个乘法。如果cnt很大,_value也很大,乘积可能溢出。比如cnt=2,_value=2^255,乘积是2^256,对uint256取模后变成0。

然后require(total <= balances[msg.sender])就通过了,因为0肯定小于等于余额。攻击者就能用很少的余额,转出大量代币。

教训:任何算术运算都可能溢出,尤其是乘法和加法。不要假设“这个数字不会那么大”。攻击者会故意构造边界条件。

我曾经在一个审计项目中遇到过类似问题。项目方在计算手续费时用了乘法,但没有检查溢出。我提醒他们后,他们还不以为然,说“手续费比例很小,不会溢出”。我说,你想想看,如果攻击者传入一个极大的数值呢?他们这才意识到问题。

五、避坑指南:我踩过的那些坑

我曾经在写一个拍卖合约时,用uint64来存储出价。我觉得64位够大了,结果用户出价超过2^64-1时,直接溢出了。嗯,从那以后,我统一用uint256,除非有特殊理由。

这里总结几个实用建议:

  • 能用uint256就别用小的——除非你明确知道上限,比如时间戳用uint40就够了。
  • 0.8+版本优先用内置检查——别自己写require,编译器帮你做了。
  • unchecked块要谨慎——只在确定不会溢出时使用,并且加注释说明原因。
  • 外部输入要校验——用户传进来的数值,先检查范围再运算。
  • 乘除法顺序要注意——先乘后除可以避免精度损失,但要注意中间结果可能溢出。

一个小技巧:如果你不确定会不会溢出,可以用OpenZeppelin的SafeCast库。它可以把uint256安全地转换成小类型,如果超出范围就revert。

六、知识体系图:整数溢出全景

下面这张图帮你理清整数溢出的知识脉络:

整数溢出知识体系 整数溢出漏洞 溢出原理 上溢:最大值+1=最小值 下溢:最小值-1=最大值 模运算:结果对2^n取模 SafeMath库(0.8-) add/sub/mul/div require检查结果 Solidity 0.8+ 内置检查 默认开启溢出检查 unchecked块可绕过 实际案例分析 BEC代币:乘法溢出漏洞 PoWH Coin:下溢漏洞 最佳实践与避坑指南

这张图把整数溢出的几个关键点串起来了。从原理出发,到两种解决方案(SafeMath和0.8+内置检查),再到实际案例和最佳实践。你写合约时,对照这张图检查一遍,基本不会漏。

好了,整数溢出这块就聊到这。记住一句话:别相信任何数值,尤其是用户传进来的。该检查就检查,该用库就用库。安全第一,gas优化第二。


公众号:蓝海资料掘金营,微信deep3321