欢迎订阅专栏:10分钟智能合约:进阶实战
整型溢出与下溢:定义、原理与防御
整型溢出(Overflow) 和 整型下溢(Underflow) 是智能合约中因数值计算超出数据类型表示范围而导致的严重漏洞。在 Solidity 中,这类漏洞曾造成数十亿美元损失(如 BEC、SMT 代币事件),但在 Solidity 0.8.0 版本之后,默认内置了溢出检查,使得新代码自动免疫。然而,理解其原理对于审计旧合约、使用 unchecked 块或编写低版本合约依然至关重要。
1. 核心概念
| 术语 | 定义 | 示例(uint8 范围 0~255) |
|---|---|---|
| 上溢(Overflow) | 数值超过类型最大值,回绕到最小值。 | 255 + 1 = 0 |
| 下溢(Underflow) | 数值低于类型最小值,回绕到最大值。 | 0 - 1 = 255 |
⚠️ 注意:在 Solidity 中,uint 类型(无符号整数)无法表示负数,因此减法下溢会绕回极大值,这是最危险的场景之一。
2. 漏洞原理
EVM 对整数运算是模运算的,即 (a + b) mod 2**n。开发者通常期望计算结果是数学上的真实值,但若未进行范围检查,就会产生非预期结果。
常见错误模式:
- 使用
+=、-=、*=等运算符时未检查边界。 - 使用低版本编译器(< 0.8.0)且未引入
SafeMath库。
3. 典型漏洞示例
3.1 上溢:代币转账中的总供应量绕过
solidity
// 漏洞合约(Solidity 0.7.6,无溢出检查)
contract Token {
mapping(address => uint) public balanceOf;
uint public totalSupply;
uint8 count = 255;
function mint(address to, uint amount) public {
totalSupply += amount; // 若 totalSupply + amount > 2**256 - 1,会溢出
balanceOf[to] += amount; // 同样可能溢出
}
// 内联汇编
function increment() public view returns(uint8 result) {
assembly {
// 溢出
result := add(sload(count.slot), 1)
}
}
}
攻击:攻击者调用 mint 时传入一个极大的 amount,使 totalSupply 溢出变为一个很小的值,但攻击者账户余额却获得了巨额代币(因为 balanceOf[to] += amount 也会溢出,但攻击者可以控制 amount 使其正好绕回期望值)。
真实案例:2018 年 BEC(Beauty Chain) 代币的 batchTransfer 函数中,amount 与用户数量相乘时未检查溢出,导致攻击者可构造参数使乘法结果为 0,从而绕过余额检查转出巨量代币。
3.2 下溢:余额扣减绕过
solidity
// 漏洞合约
contract Vault {
mapping(address => uint) public balances;
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // 若 balances 和 amount 相等,相减结果为 0,安全。但若 balances < amount 呢?
// 前面有 require,所以正常情况下不会下溢。但若 require 被绕过或逻辑错误,下溢就会发生。
// 常见错误:先计算余额变化,后检查。
}
}
更典型的错误是先操作后检查:
solidity
function transfer(address to, uint amount) public {
balances[msg.sender] -= amount; // 若余额不足,此处下溢,余额变成极大的数
balances[to] += amount;
require(balances[msg.sender] >= 0); // 总是 true,无效检查
}
后果:攻击者可以将余额变为天文数字,然后转账给任意地址。
真实案例:2018 年 PoWHCoin(Proof of Weak Hands)团队在实现中未使用 SafeMath,导致攻击者通过 transfer 函数中的下溢凭空制造大量代币并抛售获利。
4. 不同 Solidity 版本的处理
| Solidity 版本 | 溢出行为 | 说明 |
|---|---|---|
| < 0.8.0 | 无默认检查,静默回绕。 | 必须使用 SafeMath 库或手动检查。 |
| >= 0.8.0 | 默认检查,溢出/下溢会 revert。 | 若需旧行为,必须使用 unchecked { ... } 块。 |
solidity
// Solidity 0.8.x
function dangerous() public pure returns (uint8) {
uint8 x = 255;
// x + 1 会自动回滚,不会返回 0
return x + 1; // 编译警告,运行时会 revert
}
function uncheckedExample() public pure returns (uint8) {
uint8 x = 255;
unchecked {
return x + 1; // 返回 0,允许回绕
}
}
5. 防御措施
| 方式 | 适用版本 | 说明 |
|---|---|---|
| 升级到 Solidity 0.8+ | ≥0.8.0 | 最彻底的解决方案,内置溢出检查。 |
| 使用 SafeMath 库 | <0.8.0 | OpenZeppelin 的 SafeMath 提供带溢出检查的加减乘除。 |
| 手动检查 | 任何版本 | 对每个运算前后进行条件检查(如 require(a + b >= a))。 |
| 限制输入范围 | 通用 | 对用户可控的数值参数进行上限限制。 |
使用 unchecked 有意识选择 | ≥0.8.0 | 仅在明确需要回绕且安全的情况下使用(如实现某些密码学算法)。 |
SafeMath 示例(Solidity 0.7.x):
solidity
import "@openzeppelin/contracts/math/SafeMath.sol";
contract SafeToken {
using SafeMath for uint256;
function transfer(address to, uint amount) public {
balances[msg.sender] = balances[msg.sender].sub(amount);
balances[to] = balances[to].add(amount);
}
}
6. 现代审计注意事项
尽管 Solidity 0.8+ 默认安全,但仍需注意:
unchecked块:开发者可能为了节省 Gas 主动关闭溢出检查,须人工审计其安全性。- 类型转换:将大类型转换为小类型时仍可能溢出(如
uint256->uint8),编译器不会对此检查。 - 乘法与幂运算:
x * y仍可能溢出,即使 0.8+,也要确保x和y在合理范围。 - Yul / 内联汇编:汇编中的运算无任何保护,需格外小心。
总结
整型溢出/下溢曾是智能合约的“头号杀手”,如今已被编译器默认防御机制基本消除。但在开发过程中:
- 始终使用 Solidity 0.8+ 开启自动检查。
- 只有在充分理解后果且为性能优化时才使用
unchecked。 - 审计旧合约时,重点排查所有算术运算是否使用了 SafeMath 或具有有效检查。
对于历史漏洞的学习,理解溢出原理有助于识别更隐蔽的算术陷阱(如价格计算中的中间结果溢出)。