作者:Pinging(先知社区)
最近在对区块链漏洞进行一些代码研究,在实例复现以及竞赛题目的研究中,我发现整数溢出是现在区块链比较火的漏洞之一。但是整数漏洞的技术难度并不大,容易理解。但是它常常出现在Solidity代码中,容易被攻击者利用。
本篇文章中,我们就针对整数溢出漏洞进行原理上的分析,并对部分实例以及竞赛题目进行线上实验,并提出一些预防措施。
在介绍整数溢出漏洞前,我们需要先简单介绍一下Solidity中的部分语法知识。
在solidity中,我们知道在变量类型中有int/uint
(变长的有符号与无符号整形)类型。这类变量支持以8递增,支持从uint8到uint256,以及int8到int256。而需要我们注意的时,uint与int默认表示uint256与int256 。
我们知道,无符号整形是计算机编程中一种数值类型,其只能表示非负数(0以及正数)。然而有符号整形(int)可以表示任何规定范围内的整数。
有符号整数能够表示负数的代价是能够存储正数的范围缩小,因为其约一半的数值范围需要表示负数。如:uint8的存储范围为0~255,然而int8的范围为-127~127.
如果用二进制表示的话:
uint8 :0b00000000 ~ 0b1111111 每一位都存储相关内容,其范围为0~255 。
int8 :0b1111111 ~ 0b0111111 最左边一位表示符号,1表示为负数,0表示为正,范围为 -127~127 。
而整数溢出的概念是什么呢?我们来看一个简单的例子:
pragma solidity ^0.4.10; contract Test{ // 整数上溢 //如果uint8 类型的变量达到了它的最大值(255),如果在加上一个大于0的值便会变成0 function test() returns(uint8){ uint8 a = 255; uint8 b = 1; return a+b;// return 0 } //整数下溢 //如果uint8 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成255(uin8 类型的最大值) function test_1() returns(uint8){ uint8 a = 0; uint8 b = 1; return a-b;// return 255 } }
在这个例子中,我们知道uint8的范围为——0~255,所以我们的测试代码中定义变量使用了uint8 。
在test()
中,我们赋值了a,b两个变量,令a+b。
按照我们的理解,a+b = 255+1 =256才对。现在我们进行测试。
我们得到的并不是256,而是0 。
类似的,我们对test1()
函数进行测试。0 -1 应该为-1,而uint并不包括负数的部分。所以结果应该是什么样子的呢?
我们发现,得到的值为255 。
这就是我们所提及的上下溢。假设我们有一个 uint8, 只能存储8 bit数据。这意味着我们能存储的最大数字就是二进制 11111111
(或者说十进制的 2^8 - 1 = 255) 。下溢(underflow)也类似,如果你从一个等于 0 的 uint8 减去 1, 它将变成 255 (因为 uint 是无符号的,其不能等于负数)。
而上面的例子介绍了原理。下面我们将逐步向读者介绍溢出漏洞是如何在生产环境中进行恶意攻击的。
下面我们看一个真实环境中的真实场景问题。
pragma solidity ^0.4.10; contract TimeLock { mapping(address => uint256) public balances; mapping(address => uint256) public lockTime; function deposit() public payable { balances[msg.sender] += msg.value; lockTime[msg.sender] = now + 1 weeks; } function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] += _secondsToIncrease; } function withdraw() public { require(balances[msg.sender] > 0); require(now > lockTime[msg.sender]); balances[msg.sender] = 0; msg.sender.transfer(balances[msg.sender]); } function getTime() public constant returns(uint256) { return lockTime[msg.sender]; } }
上述合约描述了一个银行存定期的合约。
我们在合约中针对每个用户设置了一个mapping
用于存储定期时间。而键为address
类型,值为uint256
类型。
之后我们拥有三个函数--deposit ()
为存钱函数,并且存储时间至少为一个礼拜;increaseLockTime ()
为增加存钱时间的函数,用户可以自行增加存款时间;withdraw ()
为取钱函数,当用户拥有余额并且现在的时间>存款时间后变可以将所有的钱提取出来。
此时,我们可以对漏洞进行分析。倘若用户存钱后想要将钱提取取出来可行吗?根据我们对合约的设置来讲,我们并不希望用户可以提取将钱取出。但是我们来看下面的函数:
function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] += _secondsToIncrease; }
这个函数中我们可以自行传入变量,并更新lockTime[]的值。而我们知道其值的类型为uint256
,即我们可以使用整数溢出漏洞,传入2^256- userLockTime
。以进行溢出使变量的值变成0 。下面我们进行测试:
首先我们对合约进行部署:
之后我们存入部分钱:
我们可以查看存入的钱的数量以及时间(一周)。
下一步我们看看能不能提出钱:
发现失败了emmmm。
所以我们现在想办法,传入2^256- userLockTime
即:115792089237316195423570985008687907853269984665640564039457584007913129639936 -1546593080
为115792089237316195423570985008687907853269984665640564039457584007911583046856
。
传入数据,之后我们查看剩余时间:
之后我们就可以把钱取出来了。
SmartMesh Token是基于Ethereum的合约代币,简称SMT。Ethereum是一个开源的、公共的分布式计算平台,SmartMesh代币合约SmartMeshTokenContract基于ERC20Token标准。漏洞发生在转账操作中,攻击者可以在无实际支出的情况下获得大额转账。
合约源码地址为:https://etherscan.io/address/0x55f93985431fc9304077687a35a1ba103dc1e081#code
我们在这里放上关键函数:
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; }
这里我们简单的介绍一下这个函数的内容。
这里我们要介绍一下代理的概念。这个转账函数需要一个中间代理来帮助用户A与用户B进行转账操作。也许这个代理就类似于代理矿工机制,挖矿成功的人才能够进行tx的打包操作(这是个人想法)。
首先这个函数会传入几个关键的参数:address _from, address _to, uint256 _value, uint256 _feeSmt
。这里分别代表了用户A的地址(转账人)、用户B的地址(收款人的地址)、转账金额、手续费。
之后我们进入第一层判断:balances[_from] < _feeSmt + _value
。即转账人是否能支付得起手续费+转账费用。
之后的一些其他判断就省略了。直到后面。
balances[_to] += _value; //收款人账户添加上转账金额 Transfer(_from, _to, _value); //函数调用者账户增加上手续费金额 balances[msg.sender] += _feeSmt; Transfer(_from, msg.sender, _feeSmt); balances[_from] -= _value + _feeSmt;
而我们的攻击具体发生在if(balances[_from] < _feeSmt + _value) revert();
处。
因为_feeSmt
和_value
参数是我们可控的,可以手动进行传输的。所以我们可以控制参数的传入。而我们发现参数定义为uint256,所以2^256。所以如果我们传入的_feeSmt + _value
的值等于 2^256
+h
。所以_feeSmt + _value
= h。所以当我们的h设置的很小的时候,balances[_from] < h
便可以绕过(即使我并没有_feeSmt + _value这么多钱)。
例如:_feeSmt = 8fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff , value = 7000000000000000000000000000000000000000000000000000000000000001 // _feeSmt和value均是uint256无符号整数,相加后最高位舍掉,结果为0。
之后_from的账户就要向_to账户转账7000000000000000000000000000000000000000000000000000000000000001
;向msg.sender
转账8fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
。瞬间没有钱了就。
那我们如何对代码进行修复呢?这里参考一篇博客的做法:
// 在这里做整数上溢出检查 if(balances[_to] + _value < balances[_to] || balances[msg.sender] + _feeSmt < balances[msg.sender]) revert(); // 在这里做整数上溢出检查 ,防止交易费用 过大 if(_feeSmt + _value < _value ) revert(); // 在这里做整数上溢出检查 ,防止交易费用 过大 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;
增加判断来避免上述情况产生。
这个漏洞利用跟上述内容类似,均属于控制传入内容来达成利用。
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; } }
这里我们可控的内容为_receivers
与转账金额value
。
所以我们可以通过控制传入cnt的大小来控制amount
的值。(uint256 amount = uint256(cnt) * _value;
)
使amount向上溢出,成为一个极小值。之后绕过require(_value > 0 && balances[msg.sender] >= amount)
。从而使系统向所有的_receivers[]转账。具体的代码大家可以参考:
https://etherscan.io/address/0xc5d105e63711398af9bbff092d4b6769c82f793d#code
我们可以查看题目https://ethernaut.zeppelin.solutions/level
题目代码:
pragma solidity ^0.4.18; contract Token { mapping(address => uint) balances; uint public totalSupply; function Token(uint _initialSupply) public { balances[msg.sender] = totalSupply = _initialSupply; } function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; } function balanceOf(address _owner) public view returns (uint balance) { return balances[_owner]; } }
这个题目是十分简单的。根据我们上面的分析,这个题目存在整数溢出。题目要求我们获得额外的大量token。
我们根据代码知道合约起始会给用户20个token,所以我们起始是拥有20金币的。我们再看内部的转账函数是在transfer ()
中。在这里我们需要balances[msg.sender] - _value >= 0
。且为 20 - value >=0所以我们可以传入21 。根据溢出使balances[msg.sender] -= _value
=>‘20 - 21’ ---->上溢。
所以我们将合约部署。
之后我们调用函数:
我们可以查看我们player的账户金额。
之后提交合约。