10分钟智能合约:进阶实战-3.5 整型上下溢

335 阅读5分钟

欢迎订阅专栏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.0OpenZeppelin 的 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 或具有有效检查。

对于历史漏洞的学习,理解溢出原理有助于识别更隐蔽的算术陷阱(如价格计算中的中间结果溢出)。