区块链安全—详谈合约攻击(五)

作者:Pinging(先知社区)

一、前言

在上文中,我们详细介绍了sened()函数,并且用相关实例介绍了send()函数。而倘若Solidity代码开发者进行编写时没有注意相关逻辑,那么就有可能导致变量覆盖顺序不当而产生安全问题。尤其是当函数失败回滚但系统函数却没有发觉,仍然继续执行后续代码。

而本文我们将讲述合约安全中经典的“重入攻击”。简单来说,此类型攻击带来的危害极大,并且开发者在开发智能合约的时候很容易产生此问题。所以我们不得不对这个问题进行详细的分析以便我们能够在后续的开发中有所避免。

除此之外,我们还将视野移至一些容易出问题的函数中,不过这些语句看似常用,但是其中蕴含一些安全问题。我们对此进行安全讨论,并针对部分真实安全事件进行分析。

二、重入攻击—区块链安全的经典问题

我们知道,以太坊合约的最典型的特点之一就是能够利用外部合约的代码。而根据我们对安全事件的分析,合约调用通常会对以太币进行处理,包括转账、提取等操作。而调用外部合约或者转账操作也需要提交外部函数调用。而在调用期间,这些函数就可能被攻击者利用,迫使合约执行进一步的代码。而最为经典的调用模式就是通过回退函数,回调函数自身。这也就是我们今天要讲述的重入攻击部分。

下面,我们针对部分代码实例进行攻击讲解。

1 简单函数调用模型

倘若函数中的某些语句可以进行回调操作从而再次调用自己,那么此函数的危害将是分严重。

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // 此时,调用者代码被执行,并且又调用了自身代码
    userBalances[msg.sender] = 0;
}

根据代码我们能够发现,由于用户的余额并没有在执行if判断前被设置为0,所以之后的第二次函数回调可以一遍一遍的提取余额,直到系统中的以太币被提取完或者Gas值用尽。根据我们的分析,曾经的DAO事件就是由于此原因而产生的。区块链安全—THE DAO攻击事件源码分析

倘若我们要修复这个问题,那么最好的办法就是使用send()来代替call.value()()。这会阻止任何外部代码的执行。然而,如果你不能够全面禁止外部函数的调用(事务需要),那么为了安全考虑,你就要保证你在函数执行完之前不进行外部函数调用。

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // 
用户的余额提前被设置为0,所以回调函数再次执行此函数将无法提取任何东西。

}

2 多函数调用模型

在上文中,我们介绍了单个函数的用法,而单函数直接的调用十分有限。在真实项目的代码中,大多都是多个函数代码互相调用。下面我们就看一看相关实例。

mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
    if (userBalances[msg.sender] >= amount) {
       userBalances[to] += amount;
       userBalances[msg.sender] -= amount;
    }
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // 此时,调用者代码执行,并且调用transfer()函数

    userBalances[msg.sender] = 0;
}

在例子中,攻击者在执行到withdrawBalance()函数中的if语句时,回调了transfer()函数。由于userBalances[msg.sender] = 0语句仍未执行,所以攻击者的余额没有被系统设置为0,也就是说攻击者仍然可以在提前钱的基础上再次提取。

上面的函数存在于一个合约中,然而我们的攻击并不仅限于此。多个合约中的函数直接也是可以进行互相调用。

3 多合约函数调用

由于这些相互调用的方法存在于单函数、多函数、多合约函数中,所以一些防范手法都是不安全的。

例如:

mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function withdraw(address recipient) public {
    uint amountToWithdraw = userBalances[recipient];
    rewardsForA[recipient] = 0;
    if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}

function getFirstWithdrawalBonus(address recipient) public {
    if (claimedBonus[recipient]) { throw; } //每一个接受者在接收转账前都要提前进行声明操作

    rewardsForA[recipient] += 100;
    withdraw(recipient);
    getFirstWithdrawalBonus again.
    claimedBonus[recipient] = true;
}

即使getFirstWithdrawalBonus()函数不能直接调用外部合约,但是它内部具有withdraw()函数可以调用。所以也就意味着,我们可以通过函数1调用函数2,并且由函数2中的回调函数进行恶意操作。

所以如果我们想要对这些函数进行修复,那么我们应该先将变量声明为true,之后再调用函数。

mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function untrustedWithdraw(address recipient) public {
    uint amountToWithdraw = userBalances[recipient];
    rewardsForA[recipient] = 0;
    if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}

function untrustedGetFirstWithdrawalBonus(address recipient) public {
    if (claimedBonus[recipient]) { throw; } // Each recipient should only be able to claim the bonus once

    claimedBonus[recipient] = true;
    rewardsForA[recipient] += 100;
    untrustedWithdraw(recipient); // claimedBonus已经被设置为TRUE,所以重入攻击失败

}

4 互斥量设计模式

为了防御重入攻击,我们可以这样进行分析:因为我们针对一个用户只能进行一次提取操作。而为了将这一次提取操作锁定,我们可以进行联想。我们会发现在OS领域中有PV操作这么一说,也就是对这个资源添加互斥锁。

增加互斥锁后,我们就可以锁住一些资源,并且只能在解除互斥锁后才能调用函数。下面我们看一个简单的例子:

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

function deposit() public returns (bool) {
    if (!lockBalances) {
        lockBalances = true;
        balances[msg.sender] += msg.value;
        lockBalances = false;
        return true;
    }
    throw;
}

function withdraw(uint amount) public returns (bool) {
    if (!lockBalances && amount > 0 && balances[msg.sender] >= amount) {
        lockBalances = true;

        if (msg.sender.call(amount)()) { // 这个地方容易出现问题,但是我们这里使用了互斥锁,所以可以进行保护
          balances[msg.sender] -= amount;
        }

        lockBalances = false;
        return true;
    }

    throw;
}

这种思想类似于操作系统的概念。当用户执行withdraw ()函数的时候,我们首先会对全局变量lockBalances进行判断,倘若其为True,则可以进一步执行,否则就不允许进入。(在deposit ()与withdraw ()中,我们有lockBalances = true;balances[msg.sender] += msg.value;lockBalances = false;。这也保证了互斥的进行。)

原理如下图:

如果用户在第一次函数执行未结束之前又执行了一遍withdraw (),那么会被信号量挡在外面。

然而,这个防御措施虽然看起来万无一失,但是他在多合约合作的模型下却有一些绕过技巧。

我们看下面的代码:

contract StateHolder {
    uint private n;
    address private lockHolder;

    function getLock() {
        if (lockHolder != 0) { throw; }
        lockHolder = msg.sender;
    }

    function releaseLock() {
        lockHolder = 0;
    }

    function set(uint newState) {
        if (msg.sender != lockHolder) { throw; }
        n = newState;
    }
}

攻击者可以首先调用getLock()函数,然后不进行releaseLock()函数的调用。如果这么做,那么合约将会被永远的锁住。所以倘若要使用互斥量去保护你的系统,那么工程师需要确保这种锁住无法被打开的情况。

三、Throw—区块链安全核心问题

我们经常在编写函数的时候添加判断语句,并在判断语句后添加终端语句,例如throw,return等。

下面我们看一个代码例子:

contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() {
        if (msg.value <= highestBid) { throw; }

        if (!currentLeader.send(highestBid)) { throw; }

        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

倘若一个新的节点想选举成为新的leader时,新节点需要向老节点Send()一些手续费,倘若判断失败后则throw掉。

然而这也意味着倘若一个恶意节点成为一个leader,并且会使用一些恶意的手段使自己失去联系。使用这种方法,他们能够阻止任何人调用bid()方法,也就是说如果有人使用了if (!currentLeader.send(highestBid)) { throw; }函数,那么由于leader的不在而导致send函数失败,也就是所有想成为新leader的人都无法转账。

除此之外,我们还有另一种函数有相同的情况。此类函数中会向多个用户支付佣金,他们需要确保每一个用户都收到钱才能保证函数成功。而这时问题就来了,倘若其中有部分用户是恶意节点会出现什么情况?这样会影响整个系统的执行,也就意味着一个节点不成功,那么久会进行回环运行,也就说明函数转账无法成功。

address[] private refundAddresses;
mapping (address => uint) public refunds;

// bad
function refundAll() public {
    for(uint x; x < refundAddresses.length; x++) { // 此函数觉得了要转账对象的数目
        if(refundAddresses[x].send(refunds[refundAddresses[x]])) {
            throw; // 倘若有一个节点转账失败,那么就会阻塞所有节点

doubly bad, now a single failure on send will hold up all funds
        }
    }
}

然而,我们根据上文会发现,我们在函数中进行了循环转账。而根据以太坊的知识,循环转账需要我们消耗大量的Gas值,而倘若转账的用户数目过多,也就说明我们将消耗超出承受的Gas值。这样会导致我们的交易失败。

如果攻击者能够操控Gas值的量,例如我们上述的代码中所述的那样。攻击者倘若增加了地址函数的数量,即使每一个地址函数消耗的Gas值很少,但是其综合也是十分巨大的,总会超过限制。如果你的代码中涉及到必须进行循环的部分,并且对循环部分无法估计其大小,那么你需要记录你循环的次数,并对其进行计算、判断。例如下面的函数:

struct Payee {
    address addr;
    uint256 value;
}
Payee payees[];
uint256 nextPayeeIndex;

function payOut() {
    uint256 i = nextPayeeIndex;
    while (i < payees.length && msg.gas > 200000) {
      payees[i].addr.send(payees[i].value);
      i++;
    }
    nextPayeeIndex = i;
}

只有在while函数中增加了msg.gas > 200000这种判断,才能保证在Gas值超过限度后跳出循环。

// bad
contract auction {
    address highestBidder;
    uint highestBid;

    function bid() payable {
        require(msg.value >= highestBid);

        if (highestBidder != address(0)) {
            highestBidder.transfer(highestBid); // if this call consistently fails, no one else can bid
        }

       highestBidder = msg.sender;
       highestBid = msg.value;
    }
}

// good
contract auction {
    address highestBidder;
    uint highestBid;
    mapping(address => uint) refunds;

    function bid() payable external {
        require(msg.value >= highestBid);

        if (highestBidder != address(0)) {
            refunds[highestBidder] += highestBid; // record the refund that this user can claim
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdrawRefund() external {
        uint refund = refunds[msg.sender];
        refunds[msg.sender] = 0;
        msg.sender.transfer(refund);
    }
}

四、总结

在上文中,我们对重入问题进行了详细的分析,包括了重入问题的简单函数、多函数、多合约函数等等。并且在后面交代了使用互斥量进行把控的方法。之后我们又对throw函数进行了代码分析,包括了一些安全模块的分析。下面我们给读者总结一些方法便于开发人员对编程函数更好的理解。

我们在编写合约的时候,可以有意的参照下面的方法进行。

  • 首先,我们需要检查所有的预先条件。

  • 然后尝试改变合约的状态。

  • 之后将本合约与其他合约进行交互,相互作用。

我们按照“条件、行动、相互作用”这种模式进行函数的结构设计将避免很多的问题。我们看一个例子:

function{
    //条件

    if(now <= a + b)  throw;
    //行动未结束

    if(ended)  throw;
    //函数被调用过,跳出

    ended = true;
    ActionEnded(highestBodder , highestbid);
    //函数调用,相互作用
}

五、参考资料

*文章为作者独立观点,不代表BSCEC立场

转载此文章须经作者同意,并请附上出处(先知社区)及本页链接。原文链接 https://xz.aliyun.com/t/3413

白帽汇安全研究院转载2018-11-27 10:08:18 先知社区 1063