从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号