作者:昏鴉
日期: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的溢出校驗而回滾,最終導致拒絕服務。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1454/
暫無評論