作者:昏鸦@知道创宇404区块链安全研究团队
日期:2021年1月13日

前言

溢出是一种常见的安全漏洞,智能合约中也不例外,在智能合约的编写中尤其需要注意防范溢出的产生,因为溢出造成的危害将是十分巨大的。在Solidity 0.8.0之前,算术运算总是会在发生溢出的情况下进行“截断”,从而得靠引入额外检查库来解决这个问题(如 OpenZepplin 的 SafeMath)。

什么是溢出

以太坊虚拟机(EVM)为整数指定了固定大小的数据类型,像大部分静态编译型语言一样,一个整型变量只能表示一定范围的数字。例如,uint8只能存储0-255范围内的数值,若超过该范围将产生溢出。

而溢出产生的危害是相当大的,可能造成一些数值校验的绕过,或者资产、奖励金额等分配错误等等问题。

Solidity 0.8.0

当对无限制整数执行算术运算,其结果超出结果类型的范围,就会发生上溢出或下溢出。而从Solidity 0.8.0开始,所有的算术运算默认就会进行溢出检查,将不再必要额外引入库。

如果想要之前“截断”的效果,可以使用 unchecked 代码块:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >0.7.99;
contract C {
    function f(uint a, uint b) pure public returns (uint) {
        // 溢出会返回“截断”的结果
        unchecked { return a - b; }
    }
    function g(uint a, uint b) pure public returns (uint) {
        // 溢出会抛出异常
        return a - b;
    }
}

调用 f(2, 3) 将返回 2**256-1, 而 g(2, 3) 会触发失败异常。

unchecked 代码块可以在代码块中的任何位置使用,但不可以替代整个函数代码块,同样不可以嵌套。

此设置仅影响语法上位于unchecked块内的语句,在块中调用的函数不会此影响。

为避免歧义,不能在 unchecked 块中使用 ‘ _;’ 。

下面的这些运算操作符会进行溢出检查,如果上溢出或下溢会触发失败异常。 如果在非检查模式代码块中使用,将不会出现错误:

++, --, +, binary -, unary -, *, /, %, ** +=, -=, *=, /=, %=

注意:除0(或除 0取模)的异常是不能被 unchecked 忽略的

SafeMath护驾

SafeMath是solidity合约中最常见的一个库,是著名的OpenZeppelin智能合约安全开发库的其中之一,用于安全的算术运算的一个库。

SafeMath库的代码很少,如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;

library 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) {
        return sub(a, b, "SafeMath: subtraction overflow");
    }
    function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b <= a, errorMessage);
        uint256 c = a - b;

        return c;
    }

    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        // Gas optimization: this is cheaper than requiring 'a' not being zero, but the
        // benefit is lost if 'b' is also tested.
        // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522
        if (a == 0) {
            return 0;
        }

        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");

        return c;
    }

    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        return div(a, b, "SafeMath: division by zero");
    }
    function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b > 0, errorMessage);
        uint256 c = a / b;
        // assert(a == b * c + a % b); // There is no case in which this doesn't hold

        return c;
    }

    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        return mod(a, b, "SafeMath: modulo by zero");
    }
    function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b != 0, errorMessage);
        return a % b;
    }
}

实际上就是通过require语句在算术运算时做校验,若运算结果存在问题则会回滚并抛出错误信息。

在使用SafeMath安全算法的情况下,算术运算的正确性得到了保证,能很有效地防止数值溢出的发生。

不安全的"SafeMath"

使用了SafeMath安全算法就一定有安全保障吗?也不一定,具体情况还是得视具体业务场景而定。

最近遇到的一个案例就是,虽然使用了SafeMath安全算法,但由于算式本身存在巨大缺陷,导致最终在特定时间后合约因SafeMath而无法正常运作。

下面详细分析一下这个案例

问题代码

uint256 DURATION = 1 days;
int128 dayNums = 0;
uint256 public base = 20*10e3;
uint256 public rateReward = 1;
uint256 public rateRewardbase = 100;
......
function update_initreward() private {
    dayNums = dayNums + 1;
    uint256 reward = base.mul(rateReward).mul(10**18).mul((rateRewardbase.sub(rateReward))**(uint256(dayNums-1))).div(rateRewardbase**(uint256(dayNums)));
    _initReward = uint256(reward);
}

reward的计算公式整理如下: 其中 代入公式(1)化简可得:

分析

可以看到公式中存在99^(dayNums-1)100^(dayNums),数值大小是呈指数级增长的,这是个非常恐怖的数量级。

dayNums到40时,99^(dayNums-1)整体将大于2^(256)即uint256的大小,造成数值溢出。

99^(dayNums-1)还只是公式中的一个小因子,在分子中,前面同样还有2*10^(23)这样一个大因子。

计算分子整体的溢出情况,可以发现分子的算式在dayNums到28的时候就已经发生溢出了。

虽然公式中已经使用了SafeMath安全算法,但由于SafeMath安全算法中存在require的溢出校验语句,而导致整个调用失败而回滚,最终表现为拒绝服务。

该函数在合约启动后仅由修饰器checkHalve调用,而checkHalve修饰了很多函数,其中包括取款函数,于是最终会导致在合约运行第28天后,用户不能提取合约中质押的代币,合约大半个功能瘫痪,无法运作。

修复思路

问题的本质是算式分子计算过程中产生的数值过大导致溢出,进而触发SafeMath的溢出校验而回滚,造成了拒绝服务的危害。

那么修复自然是围绕公式做思考,通过上面的分析可以清楚这么几点:

一是公式的计算目的是按天数逐渐累乘计算出奖励数额,这是一个规律性渐进的特点;

其二,进一步化简整理公式(2),可得: 从公式(3)中可以看出,这个公式实际上就是在2*10^(21)的基础上逐天取99%,而2*10^(21)并未超过uint256的大小,所以公式的计算结果必定是逐渐变小的,并不会产生溢出。

从公式的计算角度来看,reward的计算结果是并不大的,而计算过程的中间值过大,产生了溢出。

从公式的算法逻辑来看,问题代码对于reward的计算是直接使用天数从0累乘到当前天数来获取结果,简单粗暴,计算数值庞大。

那么修复思路就很清晰了,拆分累乘

初始化定好第一次的reward数值,后面的每一次调用仅在上一次的reward的数值基础上乘以99%就行。

所以需要多定义一个变量用于每次存储上一次的reward的值。

修改后的新函数示例如下:

uint256 DURATION = 1 days;
int128 dayNums = 0;
uint256 public base = 20*10e3;
uint256 public rateReward = 1;
uint256 public rateRewardbase = 100;
//knownsec// lastReward用于存储上一次的thisrewrad的值
uint256 lastReward = base.mul(rateReward).mul(10**18).div(rateRewardbase);
......
//knownsec// 原函数,存在拒绝服务风险
function update_initreward_old() private {
    dayNums = dayNums + 1;
    uint256 reward = base.mul(rateReward).mul(10**18).mul((rateRewardbase.sub(rateReward))**(uint256(dayNums-1))).div(rateRewardbase**(uint256(dayNums)));
    _initReward = uint256(reward);
}
//knownsec// 新函数
function update_initreward() private {
    dayNums = dayNums +1;
    if (dayNums == 1){
        return lastReward;
    } else {
        uint256 reward = lastReward.mul(rateRewardbase.sub(rateReward)).div(rateRewardbase);
        lastReward = reward;
        return reward;
    }
}

经测试,不再存在风险,并且数额匹配(存在少量精度丢失)。

总结

总而言之,为了防范数值溢出的发生,一定要使用SafeMath安全算法,在正确使用了SafeMath的情况下,能保证算术运算的正确性。另一方面,即使使用了SafeMath,也需确保算法的安全性和可行性,在计算数值由系统内部产生时,若这些数值不可控地增大,就可能触发SafeMath的溢出校验而回滚,最终导致拒绝服务。


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1454/