从solidity说话个性深度解读以太坊智能合约缝隙道理和攻击利用

颁布功夫 2018-08-02
1 概述

        随着区块链、以太坊技术的鼓起和不休成熟 ,安全问题也随之而来 ,今年智能合约缝隙已经让多个区块链项目价值瞬间归零。智能合约的开发说话、设计模式、运行机造都与传统利用有较大差距 ,它寂仔传统的安全风险(如整数溢出等) ,又有怪异的新型风险(如私有变量不“私佑妆和特殊类型变量覆盖等)。研发人员若是不能深刻理解这些主题道理 ,则很容易编写出存在缝隙的智能合约;恶意合约也能够通过这种步骤留下荫蔽缝隙 ,糊弄合约投资人并暗地里收割。本文以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中的变量存储布局如下:

宝运莱·(中国区)最新官方网站


        对于在公链部署的合约 ,可通过以太坊web3接口web3.eth.getStorageAt(co ntractAddress, index)获取某个合约指定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