以太坊智能合约漏洞实战详解:整数溢出攻击


0x01 溢出攻击事件

2018年4月22日,黑客对BEC智能合约发起攻击,凭空取出

1
57,896,044,618,658,100,000,000,000,000,000,000,000,000,000,000,000,000,000,000.792003956564819968

个BEC代币并在市场上进行抛售,BEC随即急剧贬值,价值几乎为0,该市场瞬间土崩瓦解。

2018年4月25日,SMT项目方发现其交易存在异常,黑客利用其函数漏洞创造了

1
65,133,050,195,990,400,000,000,000,000,000,000,000,000,000,000,000,000,000,000 + 50,659,039,041,325,800,000,000,000,000,000,000,000,000,000,000,000,000,000,000

的SMT币,火币Pro随即暂停了所有币种的充值提取业务。

2018年12月27日,以太坊智能合约Fountain(FNT)出现整数溢出漏洞,黑客利用其函数漏洞创造了

1
2 + 115792089237316195423570985008687907853269984665640564039457584007913129639935

的SMT币。

让我们一起以沉痛的心情缅怀以上一夜归零的代币。

0x02 整数溢出简介

整数溢出原理

通常来说,在编程语言里由算数问题导致的整数溢出漏洞屡见不鲜,在区块链的世界里,智能合约的Solidity语言中也存在整数溢出问题,整数溢出一般分为又分为上溢和下溢,在智能合约中出现整数溢出的类型包括三种:

  • 乘法溢出
  • 加法溢出
  • 减法溢出

在Solidity语言中,变量支持的整数类型步长以8递增,支持从uint8到uint256,以及int8到int256。例如,一个 uint8类型 ,只能存储在范围 0到2^8-1,也就是[0,255] 的数字,一个 uint256类型 ,只能存储在范围 0到2^256-1的数字。

在以太坊虚拟机(EVM)中为整数指定固定大小的数据类型,而且是无符号的,这意味着在以太坊虚拟机中一个整型变量只能有一定范围的数字表示,不能超过这个制定的范围。

如果试图存储 256这个数字 到一个 uint8类型中,这个256数字最终将变成 0,所以整数溢出的原理其实很简单,为了说明整数溢出原理,这里以 8 (uint8)位无符整型为例,8 位整型可表示的范围为 [0, 255]255 在内存中存储按位存储的形式为下图所示:

img

8 位无符整数 255 在内存中占据了 8bit 位置,若再加上 1 整体会因为进位而导致整体翻转为 0,最后导致原有的 8bit 表示的整数变为 0。

上图即说明了智能合约中整数上溢的原理,同样整数下溢也是一样,如 (uint8)0 - 1 = (uint8)255

溢出简单实例演示,这里以uint256类型演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pragma solidity ^0.4.25;

contract POC{
//加法溢出
//如果uint256 类型的变量达到了它的最大值(2**256 - 1),如果在加上一个大于0的值便会变成0
function add_overflow() returns (uint256 _overflow) {
uint256 max = 2**256 - 1;
return max + 1;
}


//减法溢出
//如果uint256 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成2**256-1(uin256类型的最大值)
function sub_underflow() returns (uint256 _underflow) {
uint256 min = 0;
return min - 1;
}

//乘法溢出
//如果uint256 类型的变量超过了它的最大值(2**256 - 1),最后它的值就会回绕变成0
function mul_overflow() returns (uint256 _underflow) {
uint256 mul = 2**255;
return mul * 2;
}
}

将上述代码在编辑器上编译,部署。这里为了方便起见使用:http://remix.ethereum.org/#optimize=false&version=soljson-v0.4.25+commit.59dbf8f1.js编辑器。

加法溢出

这里将uint256 类型的max变量设置为它的最大值(2**256 - 1),然后在给max变量加上1,导致上溢,最终结果max的值输出为0。

减法溢出

这里将uint256 类型的变量min设置为它的最小值(0),如果在减去一个1,导致下溢,最后min的值便会变成一个很大的值,即2**256-1,也就是uin256类型的最大值。

乘法溢出

这里将uint256 类型的mul变量设置为2**255,然后在给mul变量乘以2,变成2**256,超过最大值导致上溢,最终结果mul的值输出为0。

通过例子我们可以看到uint256当取最大整数值,上溢之后直接返回值为0,uint256当取0下溢之后直接返回值为2^256-1。这是solidity中整数溢出场景的常规情况,其他类型如uint8等也是一样的原理。

整数溢出防护

为了防止整数溢出的发生,一方面可以在算术逻辑前后进行验证,另一方面可以直接使用 OpenZeppelin 维护的一套智能合约函数库中的 SafeMath 来处理算术逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
pragma solidity ^0.4.25;

library SafeMath {
function mul(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a * b;
assert(a == 0 || c / a == b);
return c;
}

function div(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a / b;
return c;
}

function sub(uint256 a, uint256 b) internal constant returns (uint256) {
assert(b <= a);
return a - b;
}

function add(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}

contract POC{
using SafeMath for uint256;

//加法溢出
//如果uint256 类型的变量达到了它的最大值(2**256 - 1),如果在加上一个大于0的值便会变成0
function add_overflow() returns (uint256 _overflow) {
uint256 max = 2**256 - 1;
return max.add(1);
}


//减法溢出
//如果uint256 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成2**256-1(uin256类型的最大值)
function sub_underflow() returns (uint256 _underflow) {
uint256 min = 0;
return min.sub(1);
}

//乘法溢出
//如果uint256 类型的变量超过了它的最大值(2**256 - 1),最后它的值就会回绕变成0
function mul_overflow() returns (uint256 _underflow) {
uint256 mul = 2**255;
return mul.mul(2);
}
}

使用Safemath方法后,不再产生溢出漏洞:

通过使用Safemath封装的加法,减法,乘法接口,使用assert方法进行判断,可以避免产生溢出漏洞。

0x03 案例分析

而上面的例子介绍了原理。下面我们将通过实际案例介绍溢出漏洞是如何在生产环境中进行恶意攻击的。

案例一 BEC

漏洞原理分析

BEC合约地址:0xC5d105E63711398aF9bbff092d4B6769C82F793D

在etherscan上的地址为:https://etherscan.io/address/0xc5d105e63711398af9bbff092d4b6769c82f793d#code

存在溢出漏洞的合约代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; //溢出点,这里存在整数溢出
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);

balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}

可以看到batchTransfer函数中,有如下代码uint256 amount = uint256(cnt) * _value,没有使用safemath library,直接使用乘法运算符,产生整数溢出的地方就在合理。

其中变量cnt为转账的地址数量,可以通过外界的用户输入_receivers进行控制,_value为单地址转账金额,也可以直接进行控制。

外界可以控制_receivers_value的数值,那么我们就可以控制amount变量的值,让其产生非预期的值,导致向上溢出。如cnt = _receivers.length = 2_value = 2**255,这样amount = uint256(cnt) * _value = 2**255*2超过uint256表示的最大值,导致溢出,最终amount = 0

紧接着下面有一句对amount进行条件检查的代码require(_value > 0 && balances[msg.sender] >= amount);其中balances[msg.sender]代表当前用户的余额,amount代表要转的总币数。代码意思为确保单用户转账金额大于0,并且当前用户拥有的代币余额大于等于本次转账的总币数才进行后续转账操作。因为amount溢出后可以为一个很小的数字或者0(这里变成0),很容易绕过balances[msg.sender] >= amount的检查代码。从而产生巨大_value数额(这里为2**255)的恶意转账。

实际攻击的恶意转账记录:https://etherscan.io/tx/0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f

从转账记录中可以看到分别给两个账户转了很大一笔代币。

攻击模拟演示

为了真实模拟黑客攻击过程,下面进行演示。

实战操作视频链接:https://v.qq.com/x/page/w082246wiuo.html

下面我们将BEC合约的代码在remix编辑器编译,部署,然后调用batchTransfer函数。

转入两个需要转账的账户:[“0x14723a09acff6d2a60dcdf7aa4aff308fddc160c”,”0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db”]

然后转入单用户转账金额57896044618658097711785492504343953926634992332820282019728792003956564819968(2**255

最后转账后,转出账户:0xca35b7d915458ef540ade6068dfe2f44e8fa733c代币不变,转入的两个账户余额都变为很大的值。

漏洞修复

在代码中可以看到,在语句balances[msg.sender] = balances[msg.sender].sub(amount)balances[_receivers[i]] = balances[_receivers[i]].add(_value)中,调用Safemath库中的安全函数来完成加减操作,所以这里在进行乘法运算时也需要使用Safemath来运算,将代码uint256 amount = uint256(cnt) * _value改为uint256 amount = uint256(cnt).mul(_value);,如图所示,使用Safemath库之后,攻击的转账失败。

案例二 SMT

BEC合约地址:0x55F93985431Fc9304077687a35A1BA103dC1e081

在etherscan上的地址为:https://etherscan.io/address/0x55f93985431fc9304077687a35a1ba103dc1e081#code

存在溢出漏洞的合约代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function transferProxy(address _from, address _to, uint256 _value, uint256 _feeSmt,
uint8 _v,bytes32 _r, bytes32 _s) public transferAllowed(_from) returns (bool){

if(balances[_from] < _feeSmt + _value) revert(); //溢出点,这里存在整数溢出

uint256 nonce = nonces[_from];
bytes32 h = keccak256(_from,_to,_value,_feeSmt,nonce);
if(_from != ecrecover(h,_v,_r,_s)) revert();

if(balances[_to] + _value < balances[_to]
|| balances[msg.sender] + _feeSmt < balances[msg.sender]) revert();
balances[_to] += _value;
Transfer(_from, _to, _value);

balances[msg.sender] += _feeSmt;
Transfer(_from, msg.sender, _feeSmt);

balances[_from] -= _value + _feeSmt;
nonces[_from] = nonce + 1;
return true;
}

从代码中可以看到这里的加、减、乘都没有使用安全处理,直接进行算数运算,而且_from, _to, _value, _feeSmt都可控,导致在if(balances[_from] < _feeSmt + _value) revert();这里, 将_value, _feeSmt构造为相加刚好溢出为0的值

1
2
3
4
5
 8fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff _value
+
7000000000000000000000000000000000000000000000000000000000000001 _feeSmt
=
10000000000000000000000000000000000000000000000000000000000000000

如上面的valuefeeSmt相加然后去掉最高位,结果为0,小于balances[_frome],使得if条件不成立,绕过判断。

通过攻击的交易信息中可以看到,黑客构造的内容:

上图可以看出,_value_feeSmt相加刚好溢出为0,绕过if条件判断,从而给to账户和msg.sender账户分别转入valuefeeSmt的代币。

案例三 FNT

FNT合约地址:0x82Cf44bE0768A3600c4BDeA58607783A3A7c51AE

在etherscan上的地址为:https://etherscan.io/address/0x82cf44be0768a3600c4bdea58607783a3a7c51ae#code

此漏洞跟案例一的BEC漏洞,如出一辙。存在溢出漏洞的合约代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function batchTransfers (address[] receivers, uint256[] amounts) public whenRunning returns (bool) {
uint receiveLength = receivers.length;
require(receiveLength == amounts.length);

uint receiverCount = 0;
uint256 totalAmount = 0;
uint i;
address r;
for (i = 0; i < receiveLength; i ++) {
r = receivers[i];
if (r == address(0) || r == owner) continue;
receiverCount ++;
totalAmount += amounts[i]; ////溢出点,这里存在整数溢出,计算需要转出的全部数额
}
require(totalAmount > 0);
require(canPay(msg.sender, totalAmount)); //这里判断msg.sender的余额大于totalAmount

wallets[msg.sender] -= totalAmount;
uint256 amount;
for (i = 0; i < receiveLength; i++) {
r = receivers[i];
if (r == address(0) || r == owner) continue;
amount = amounts[i];
if (amount == 0) continue;
wallets[r] = wallets[r].add(amount);
emit Transfer(msg.sender, r, amount); //逐一转账
}
return true;
}

从上述代码中可以看到变量receiversamounts可控,首先计算需要转出的全部数额totalAmount,然后判断totalAmount大于0,并且msg.senders的余额大于totalAmount的数值,最后从receiversamounts中一一转账。

这里的溢出点在计算转账总额的地方totalAmount += amounts[i];,当amount的元素相加产生溢出时,可以构造溢出时为一个很小的值比如1或者2,构造的amounts如下:

1
2
3
4
5
2                                                                               amount[1]
+
115792089237316195423570985008687907853269984665640564039457584007913129639935 amount[2]
=
1 totalAmount

所以通过上面的构造,我们成功绕过了判断,最后给两个账户分别转了2个代币和115792089237316195423570985008687907853269984665640564039457584007913129639935个(2*256-1)的代币。

通过攻击的交易信息可以看到结果跟我们分析的一致:

0x04 总结

我们在开发智能合约时,开发人员如果不加注意的话,只要没有检查用户输入内容,而且最终将输入带入执行计算,导致计算结果数字超出存储它们的数据类型允许的范围,那么此智能合约的输入内容就可以被用来组织攻击,导致安全漏洞。

所以为了防止整数溢出的发生,一方面可以在算术逻辑前后进行验证,另一方面可以直接使用 OpenZeppelin 维护的一套智能合约函数库中的 SafeMath 来处理算术逻辑。

很多项目在合约中已经导入了SafeMath库,但是因为开发者的粗心大意导致部分运算忘记添加本来已经using的safemath库,出现溢出漏洞。所以除了开发者自己提高安全开发意识之外,找专业的安全团队对合约进行全面的审计也是非常必要的。