作者:昏鴉
日期: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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1454/