智能合约拒绝服务之不安全的 “SafeMath”

作者:昏鸦@知道创宇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的溢出校验而回滚,最终导致拒绝服务。

以上是 智能合约拒绝服务之不安全的 “SafeMath” 的全部内容, 来源链接: utcz.com/p/199827.html

回到顶部