3、算术溢出与下溢:整数溢出原理、SafeMath库的使用、Solidity 0.8+内置检查
3.1 整数溢出:一个老生常谈的坑
说起整数溢出,我脑子里立刻浮现出几年前的一个项目。当时我们审计一个DeFi合约,里面有个计算用户奖励的函数。你猜怎么着?一个简单的加法操作,让整个资金池差点归零。嗯,这就是溢出漏洞的威力。
说白了,整数溢出就是数字超出了变量类型的存储范围。比如你用 uint8 存一个数,它的范围是0到255。如果你给它加1,它不会变成256,而是变成0。这就是上溢。反过来,0减1会变成255,这就是下溢。
为什么会这样?因为计算机底层是用二进制表示的。255的二进制是 11111111,再加1就变成 100000000,但 uint8 只取低8位,所以结果是 00000000,也就是0。
核心要点:
- 上溢(Overflow):最大值 + 1 = 最小值
- 下溢(Underflow):最小值 - 1 = 最大值
- 常见于
uint类型,int类型也有类似问题
3.2 一个经典的溢出案例
我见过最典型的漏洞代码长这样:
// 有漏洞的合约
pragma solidity ^0.4.24;
contract VulnerableToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
// 这里存在下溢风险
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // 如果amount大于余额,会下溢
balances[to] += amount; // 如果余额接近最大值,会上溢
}
}
你看,这个 require 检查看起来没问题,但 balances[msg.sender] -= amount 这行,如果 amount 大于余额,结果会变成一个巨大的数。我曾经在审计报告里写过:这种漏洞能让攻击者凭空造出天文数字的代币。
避坑指南:
我曾经遇到一个项目方,他们觉得 require(balances[msg.sender] >= amount) 已经够安全了。结果呢?攻击者利用下溢,把0.1个代币变成了2^256-1个。嗯,这个教训值几百万美金。
3.3 SafeMath:老项目的救命稻草
在Solidity 0.8之前,OpenZeppelin的SafeMath库几乎是每个项目的标配。我个人习惯是:只要涉及数学运算,一律用SafeMath。你想想看,多写几个字母,能避免多少麻烦?
// 使用SafeMath
import "@openzeppelin/contracts/math/SafeMath.sol";
contract SafeToken {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
// SafeMath会自动检查溢出
balances[msg.sender] = balances[msg.sender].sub(amount);
balances[to] = balances[to].add(amount);
}
}
SafeMath的原理很简单:每个运算函数内部都加了检查。比如 add 函数会判断结果是否小于加数,如果小于就说明溢出了,直接 revert。我建议你记住这个思路,因为即使不用库,自己写检查也是这个逻辑。
| SafeMath函数 | 作用 | 溢出检查方式 |
|---|---|---|
add(a, b) |
安全加法 | 检查结果 >= a |
sub(a, b) |
安全减法 | 检查 a >= b |
mul(a, b) |
安全乘法 | 检查 a == 0 或 结果 / a == b |
div(a, b) |
安全除法 | 检查 b != 0 |
个人经验:
我建议你在使用SafeMath时,尽量用链式调用。比如 a.add(b).sub(c).mul(d),这样代码更简洁,也不容易漏掉检查。不过要注意,链式调用会消耗更多gas,但安全第一嘛。
3.4 Solidity 0.8+:内置检查来了
从Solidity 0.8.0开始,编译器默认对算术运算做了溢出检查。这意味着你不需要再手动引入SafeMath了。我个人觉得这是Solidity团队做的最正确的决定之一。
// Solidity 0.8+ 内置检查
pragma solidity ^0.8.0;
contract BuiltInCheck {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
// 0.8+ 会自动检查溢出
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount; // 如果下溢,自动revert
balances[to] += amount; // 如果上溢,自动revert
}
}
但是!这里有个但是。内置检查虽然方便,但它会消耗更多gas。为什么呢?因为每次运算都要多执行几条校验指令。我做过测试,一个简单的加法在0.8+版本里比0.7版本多消耗大约20%的gas。
重要提醒:
- 0.8+ 的
unchecked块可以关闭检查,用于节省gas - 只在确定不会溢出的场景使用
unchecked - 比如循环计数器、已知范围的运算
// 使用unchecked节省gas
function sumArray(uint256[] memory arr) public pure returns (uint256) {
uint256 total = 0;
// 循环计数器不会溢出,可以用unchecked
for (uint256 i = 0; i < arr.length; i++) {
unchecked {
total += arr[i]; // 这里还是建议保留检查
}
}
return total;
}
3.5 我的建议
如果你用的是Solidity 0.8+,我建议你:
- 默认信任内置检查:除非你明确知道不会溢出,否则不要用
unchecked - 对关键运算保持警惕:比如代币转账、奖励计算、价格计算等
- 写单元测试:测试边界值,比如0、最大值、最小值
- 考虑gas成本:如果合约对gas敏感,可以在安全的地方用
unchecked
我记得有一次,一个朋友问我:「既然0.8+有内置检查,是不是就不用管溢出了?」我告诉他:内置检查只是最后一道防线。你想想看,如果业务逻辑本身就有问题,比如允许用户随意设置参数,那溢出检查也救不了你。
最后提醒:
溢出漏洞虽然老套,但每年仍有大量合约因此被攻击。不要因为有了内置检查就掉以轻心。我见过太多「我以为不会溢出」的案例了。嗯,安全这件事,永远不要「我以为」。