智能合约拒绝服务之不安全的 “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.0pragma 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: MITpragma 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