从solidity说话个性深度解读以太坊智能合约缝隙道理和攻击利用
颁布功夫 2018-08-02随着区块链、以太坊技术的鼓起和不休成熟,安全问题也随之而来,今年智能合约缝隙已经让多个区块链项目价值瞬间归零。智能合约的开发说话、设计模式、运行机造都与传统利用有较大差距,它寂仔传统的安全风险(如整数溢出等),又有怪异的新型风险(如私有变量不“私佑妆和特殊类型变量覆盖等)。研发人员若是不能深刻理解这些主题道理,则很容易编写出存在缝隙的智能合约;恶意合约也能够通过这种步骤留下荫蔽缝隙,糊弄合约投资人并暗地里收割。本文以WCTF2018的一路智能合约缝隙赛题[1]为例,从solidity说话个性启程,深度解读以太坊智能合约缝隙道理和攻击利用。
2 缝隙合约分析
该合约是一个银行类合约,用户能够存入eth到该合约,并在存入到期之后取出。原题对该合约描述如下:
该合约中存在缝隙,攻击者利用缝隙能够盗取合约中的所有余额。缝隙涉及到整数溢出、变量覆盖以及由变量覆盖导致的变量相互影响。
合约源码如下:
要提取合约的全数合约余额,confiscate 函数是关键,但该函数挪用成功必须满足:
? msg.sender == owner
? secret == _secret
? now >= balances[account].deposit_term + 1 years
攻击者能够通过合约存储接见、整数溢出和变量覆盖来顺次机关上述前提。
2.1 solidity全局变量存储
在BelluminarBank合约中,一共有4个全局变量,别离是balances、head、owner、secrete。它们的默认接见属性是private,看上去只有合约自己可能接见这些变量。事实上,合约的所有变量数据都是公开存储在链上的区块中,任何人都能够通过接见存储数据来获得这些变量的值[2]。在solidity说话中,全局变量都存储在storage中,凭据solidity的变量存储规定,定长的变量在storage中是挨次存储的,数组变量在storage中其索引地位存放的是其数组长度(拜见[3])。该合约storage中的变量存储布局如下:
因而,secrete并不是一个不成获取的私罕见据,攻击者只必要接见该合约storage中的数据就能够机关confiscate 函数的secret == _secret前提。
2.2 solidity全局变量覆盖
BelluminarBank合约中的confiscate函数要求挪用者必须是合约占有者能力够进行余额提取操作,看上去攻击者是无法提取的。然而,由于solidity说话的部门变量存储个性,导致本合约的owner变量能够被批改,覆盖问题呈此刻 invest 函数中。
首先来看solidity部门变量覆盖全局storage的问题。solidity说话的变量存储有一个个性,即数组、映射、结构体类型的部门变量默认是引用合约的storage [4],而全局变量默认存储在storage中。因而,若是这些部门变量未被初始化,则它们将直接指向storage,批改这些变量就是在批改全局变量。
以如下的单一合约test为例,函数test1中界说了一个部门结构体变量x,但是没有对其进行初始化。凭据solidity的变量存储规定,这时辰x是存储在storage中的,并且是从索引0起头,那么对其成员变量x,y赋值之后,刚好覆盖了全局变量a和b。有兴致能够在 remix 中在线对本合约进行调试。
pragma solidity 0.4.24;
contract test {
struct aa{
uint x;
uint y;
}
uint public a = 4;
uint public b = 6;
function test1() returns (uint){
aa x;
x.x = 9;
x.y = 7;
}
}
在invest函数的else分支中,使用了一个部门结构变量investment。该部门变量在当前执行分支中并没有被初始化,默认指向合约的storage。执行中对该变量的成员赋值就会直接覆盖全局变量,覆盖关系为:
同时,在变量覆盖之前必须满足如下前提,即存款期限是最末一个存款纪录的期限后一年:deposit_term >= balances[balances.length - 1].deposit_term + 1 years。由于deposit_term是用户提供的,轻松就能够满足。
所以,通过精心机关invest函数的参数就能够覆盖stroage中的sender,从而扭转该合约的占有者为攻击者,突破confiscate 函数的msg.sender == owner限度。
2.3 整数溢出
在BelluminarBank合约源码的confiscate函数还有另表一个如下的功夫限度,即必须在存款满一年后能力提取,now >= balances[account].deposit_term + 1 years。
上一节用于全局变量覆盖的存款操作使得balances中最末一个存储纪录的期限已经是1年后,即攻击者至少在2年后能力挪用confiscate函数进行提款。与此同时,deposit_term在赋值给部门变量的时辰会把全局变量head覆盖为超大的数,这也使得后续的for (uint256 i = head; i <= account; i++)循环处置无法提取全数的存款,由于head不为0。
显然,必须把head覆盖为0能力提取全数的存款,即invest函数的deposit_term参数必须为0。但若是该参数为0,又无法满足invest函数的全局变量覆盖执行的前提deposit_term >= balances[balances.length - 1].deposit_term + 1 years。
仔细分析可发现,若是balances[balances.length - 1].deposit_term+ 1 years刚好蹬宗0,则上述的前提恒为真。显然,balances[balances.length - 1].deposit_term只有取值为(uint256_max – 1 years + 1),就会导致相加后的值为uint256_max+1。这个了局会超过uint256的表白空间,产生溢出导致最后的值为0。
因而,攻击者先做第一次存款,把balances最后一项的deposit_term设置为特殊值;而后做第二次存款,deposit_term传入0值,就能触发整数溢出,绕过变量覆盖前提限度并批改head为0值。
2.4 “变量纠缠”的副作用
在全局变量覆盖中,很容易产生“变量纠缠”景象,从而触发一些容易被忽视的副作用。这里以一个单一合约test为例,函数testArray中依然存在结构体部门变量a覆盖全局变量x的情况。但由于x是数组变量,其直接索引的storage存储地位仅存储其数组长度,也就是a.x只会覆盖x的数据长度,而a.y将覆盖变量num。
在testArray函数中,赋值操作a.x = 5时,由于x.length与变量a.x处于统一存储地位,赋值后数组x的长度造成了5。接下来,赋值a.y,并将变量a参与到数组x。所以变量a现实上参与到了数组x索引为5的地位。若是调试testArray函数执行,会发此刻函数执行结束之后,x[5].x = 6, x[5].y = 7。
这是为什么呢?明明代码中赋值写的是 a.x = 5,a.y = 7。这就是全局变量x和部门变量a形成了“纠缠”,首先是部门变量a批改导致全局变量x扭转,而后是全局变量x批改导致终部门变量批改,最后把批改后的部门变量又存储到批改后的全局变量。这里即是,赋值操作a.x = 5时,把数组x的长度造成了5; 接下来x.push操作,现实上是先将该数组x的长度加1,此时a.x = 6; 最后再把a.x = 6, a.y=7参与到x[5]。所以,存入数据的x就是新数组的长度6。
pragma solidity 0.4.24;
contract test {
struct aa{
uint x;
uint y;
}
aa [] x;
uint public num = 4;
function testArray() returns (uint){
aa a;
a.x = 5;
a.y = 7;
x.push(a);
}
}
3 缝隙利用方式
在第2节中对合约 BelluminarBank存在的几个缝隙进行了分析,下面将注明若何利用这个缝隙提取合约的全数余额,这里在Remix在线编译环境中部署该合约,并演示其利用方式。
首先部署合约,在部署参数中设置secrete 为“0x01”,deposit_term为1000,msg.value为 31337 wei。
部署合约后,合约的全局变量如下图所示:
这样,合约目前的余额是 31337 wei,合约占有者的地址为:0xca35b7d915458ef54 0ade6068dfe2f44e8fa733c。
下面起头必要机关前提使得攻击者能够成功挪用confiscate函数。
步骤1: 覆盖owner并机关整数溢出前提
要想转走合约余额,首先必须批改合约的owner。利用部门结构体 investment 批改合约owner,需满足前提:
(1)account < head or account >= balances.length
(2)deposit_term >= balances[balances.length – 1].deposit_term + 1 years
设置攻击者(0x1472…160C)的invest挪用参数如下:
? msg.value = 1 wei (由于在合约初始化时owner已经存入一笔金额,所以此时balances数组长度为1,为了不扭转balances数组长度,这里依然将其设置为1 we i
? depositsit_term = 2^256 - 1 years = 115792089237316195423570985008687907853269984665640564039457584007913098103936 (在步骤2中必要利用这个数值机关溢出,同使剽个值能够使源码中 require 前提得到满足)
? account = 1 (满足前提 account >= balances.length)
挪用之后,新的存款纪录数据将存放在balances数组索引为1的地位。此时的balances数组情况和全局storage变量情况如下图所示。
能够发现,owner已经批改为攻击者地址,同时head被传入的deposit_term覆盖为一个超大值。
而提取余额是从balances数组中head索引起头的存款纪录起头推算数额的。显然,为了提取到合约owner的余额,即balances[0]账户的余额,head必须被覆盖为0。因而,必要进行第二次storage变量覆盖,批改head。
步骤2: 复原head并绕过deposit_term限度
持续设置攻击者挪用invest的参数:
? msg.value = 2wei (同样保障balances的长度覆盖后不出现谬误)
? deposit_term = 0: 复原head
? account = 2 (满足前提 account >= balances.length 即可)
由于在步骤 1 中,已经将balances[1].deposit_term 设置为 2^256 -1 years,因而在第二次挪用 invest 函数时,由于balances[balances.length - 1].deposit_term + 1 years”溢出为0满足了require前提,所以能够成功进行第二次覆盖。
这样即满足了挪用confiscate函数的前提msg.sender == owner,通过读取storage很容易获得secrete,前提secret == _secret 也能够满足,同时还沉新覆盖了head使之变为0 。
覆盖之后全局storage变量和balances数组如下图所示:
能够发现head已经批改为0了。
此刻来看看第三个前提:
now >= balances[account].deposit_term + 1 years
account是传入的数据,目前合约中account数量为3。在前面的invest挪用后, balances[2].deposit_term = 0。 显然前提 now >= balances[2].deposit_term + 1 years 成立,所以在复原head数据的同时,也绕过了confiscate函数中对于存款期限的判定。接下来只有挪用函数confiscate时,设置account 为 2,便可使功夫判断前提满足,同时也能提取所有账户的余额。
步骤3: 增长合约余额
经过步骤1和步骤2,似乎攻击者已经能够挪用confiscate函数提取所有余额了,然而现实上是不能的。买卖会产生回滚,这是为什么呢?
仔细分析前面的数据就会发现,步骤1中msg.value为 1 wei,但是最后balances数组中的balances[1].amount 却造成了 2 wei。这是由于变量覆盖过程中产生了“纠缠”副作用,由于msg.value覆盖balances数组的长度,balances更新前增长了数组长度,数组长杜字扭转了msg.value,最后导致存入的amount造成了新的数组长度,即2。
所以,每次挪用invest函数进行变量覆盖,存款纪录的账目金额都比挪用者现实支付的msg.value大。下图是两次挪用invest之后的balances数组情况。
从图中能够看出,存款纪录中的账面值会比现实买卖的msg.value多 1 wei。通过confiscate函数推算得到的所有账户总额为31342 wei,而现实的合约账户总余额为 31340 wei。
为了可能将合约中所有余额提取出来,必要增长合约的真实余额,使其同存款纪录中的余额相称。然而,通过invest方式增长的余额城市被计入账面余额,那么怎么在不通过invest函数的情况下增长合约的真实余额呢?
答案是selfdestruct函数。
selfdestruct函数会将该合约的余额转到指定账户,而后从区块链中销毁该合约的代码和storage。该函数的官方文档注明[5]如下:
因而,能够机关一个合约,而后在合约中挪用selfdestruct函数将合约的余额转给BelluminarBank合约。为此,机关如下合约:
contract donar{
function donar() public payable{
selfdestruct(contractAddr);
}
}
该合约创建后顿时销毁,同时将自己的余额转给银行合约。
在 remix 中 编译该合约,同时将 contractAddr代替为银行合约地址。而后 在deploy该合约时,设置 msg.value 为2 wei。当合约创建又销毁之后,其余额(2wei)将转给银行账户,使银行合约的账面余额和现实余额一致,这样confiscate函数挪用就可能正确执行。
Donar合约部署设置如下:
合约部署完之后,BelluminarBank 合约余额如下图:
步骤4:挪用confiscate提取合约余额
经过上面的操作之后,设置confiscate函数的参数为[2,“0x01”]即可将合约的全数余额转走。
参考链接:
【1】https://github.com/beched/ctf/tree/master/2018/wctf-belluminar
【2】https://solidity.readthedocs.io/en/v0.4.24/security-considerations.html#private-information-and-randomness
【3】https://medium.com/aigang-network/how-to-read-ethereum-contract-storage-44252c8af925
【4】 http://solidity.readthedocs.io/en/v0.4.24/frequently-asked-questions.html
【5】https://solidity.readthedocs.io/en/v0.4.24/introduction-to-smart-contracts.html?highlight=selfdestruct


京公网安备11010802024551号