整数溢出漏洞:溢出原理、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。
六、知识体系图:整数溢出全景
下面这张图帮你理清整数溢出的知识脉络:
这张图把整数溢出的几个关键点串起来了。从原理出发,到两种解决方案(SafeMath和0.8+内置检查),再到实际案例和最佳实践。你写合约时,对照这张图检查一遍,基本不会漏。
好了,整数溢出这块就聊到这。记住一句话:别相信任何数值,尤其是用户传进来的。该检查就检查,该用库就用库。安全第一,gas优化第二。
公众号:蓝海资料掘金营,微信deep3321