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

2019-04-04 约 4445 字 预计阅读 9 分钟

声明:本文 【区块链安全—详谈合约攻击(四)】 由作者 Pinging 于 2018-11-23 09:03:00 首发 先知社区 曾经 浏览数 2326 次

感谢 Pinging 的辛苦付出!

一、前言

根据我们前文的描述,我们现在已经了解了许多因智能合约代码其本身函数特性的原因而导致的安全隐患。例如call()函数本身就是为了方便开发者进行合约直接的相互调用而开发出来的,然而却被攻击者利用来绕过检查从而进行攻击。而本文我们分析的安全问题也是与Solidity函数语法有关。由于开发人员对某些函数隐含的机制不熟悉,所以导致了代码中过滤不严格或者逻辑上存在漏洞的情况。尤其在转账函数中,此类问题就显得更为严重。

二、Solidity中的转账函数

在介绍安全模型问题之前,我们先简单的介绍一下Solidity中的转账函数。在讲解之前,我们先普及一下相关知识。address表示一个账户地址(20字节) ;其属性值为:.balance,获取余额,单位是wei,1eth(以太币)=10^18wei

第一个转账函数为<address>.transfer()函数,此函数当发送失败时会 调用throw,进行回滚状态。在调用此函数时,我们需要传递部分 Gas 供调用,以防止重入(reentrancy)。

第二个函数为<address>.send()。当发送失败时会返回 false只会传递部分 Gas 供调用。

第三个函数为<address>.call.value()当发送失败时会返回 false。传递所有可用 Gas 供调用,然而此函数不能有效防止重入。

我们在进行合约运行的过程中将使用 msg.sender.call.value(amount)函数,并传递所有可用 Gas 供调用。只有足够量的Gas值才是成功执行合约前提条件。

在函数中,addr.transfer(y)等价于 require(addr.send(y))。然而send()和transfer()使用时有2300Gas限制,当合约接收以太币时,转账容易失败。 对于call()函数而言,它可以调用另外一个合约地址的函数,如下:

addr.call(函数签名,参数):调用对应函数 
addr.call.value(y)()功能上类似addr.transfer(y),但没有gas的限制。 
addr.call.gas():指定gas,指定调用函数消耗的gas。

而在官方文档中是这么对转账函数进行警告声明的:官方文档

这句话说明,使用send函数有许多危险的地方,如果调用堆栈的深度达到1024(参考前文讲述的调用堆深度限制)或者将gas值使用完,则send函数会返回调用失败。所以为了满足以太币转账的安全性,我们需要对send的返回值进行检查,或者我们干脆直接使用transfer来代替send函数。

而我们在使用sendtransfer函数的时候,需要注意定义Fallback函数,不定义回退函数将抛出异常并返回Ether。

三、send()函数问题模型

1 Call函数的返回值不进行检查

根据我们上面的介绍,Solidity 中有很多方法可以执行外部调用。除了transfer()send()功能外,我们对于更多的外部调用可以直接使用CALL 操作码

在执行函数的过程中,call()send()函数为了显示调用是否成功,它们会返回一个布尔值。因此,这些功能有一个简单的警告作用,倘若call()send()初始化失败,那么执行这些函数的交易将不进行回滚操作,而是直接令 call()send()将简单地返回 false。

我们可以看下面的一个例子:

contract CPTest {

    bool public payedOut = false;
    address public winner;
    uint public winAmount;

    // 外部调用合约代码

    function sendToWinner() public {
        require(!payedOut);
        winner.send(amount);
        payedOut = true;
    }

    function withdrawLeftOver() public {
        require(payedOut);
        msg.sender.send(this.balance);
    }
}

在合约中我们可以读出,当某个用户获得胜利并执行了sendToWinner()后,这个胜利者会收到数量为amount的以太币奖励,系统在发送奖励后会将全局变量payedOut设置成为true。并在下面的withdrawLeftOver()中进行验证,通过验证后进行转账操作。

然而读者也许会发现,倘若我系统执行完winner.send(amount)函数后发现由于部分原因导致执行并没有成功(有可能深度超过1024或者由于gas值不足而导致回滚等原因)。在这种情况下,其他用户可以通过withdrawLeftOver() 函数取出属于 winner 的奖金。

2 send()函数未检查所导致的问题

倘若我们要进行以太币的转账,那么最直接的办法就是使用send函数。看下面的代码:

/*** Listing 1 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  winner.send(1000); // send a prize to the winner
  prizePaidOut = True;
}

而此代码是有问题的,当send函数调用失败后,这个被转账一方会得不到以太币,然而下面的prizePaidOut变量则会被置为真。

而这里有两个情况会导致winner.send()失败。第一个是winner地址是一个系统内部合约(不是外部用户合约),之后在调用的时候由于使用了过多的gas导致gas不足而产生抛出异常的情况发生,那么就会产生上述的问题。第二种情况更不容易被发现。在EVM虚拟机中拥有一种有限资源,我们称为“ callstack”,而这个资源不同于Gas机制,它会在交易执行前被消耗。如果“ callstack”已经在执行send函数前被消耗完,那么无论winner被如何定义,那函数也不会顺利的执行。而winner的奖励金就不会如愿的被发送到账户中去。

那么看了这么多安全隐患,那么我们赢入防御呢?

第一是我们要检查send函数的返回值,并查看是否执行成功。倘若没有执行成功,那么需要抛出异常。

/*** Listing 2 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (winner.send(1000))
    prizePaidOut = True;
  else throw;
}

虽然这个方法能够暂时解决这个问题,但是它并不是最正确的解决办法。假设我们有如下场景:

/*** Listing 3 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (winner.send(1000) && loser.send(10))
    prizePaidOut = True;
  else throw;
}

看似我们的if方法能够解决上述的安全问题,但是winnerloser可以对彼此进行互相伤害。因为代码中使用了&&,所以有任何一方无法执行成功那么另外一方就无法拿到对应的奖励。

所以,我们针对“ callstack”的值进行直接检测,来判断其是否是可用的。我们可以定义一个宏函数callStackIsEmpty(),并预先执行一个测试函数来判断是否其值中的内容已近用尽。

/*** Listing 4 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (callStackIsEmpty()) throw;
  winner.send(1000)
  loser.send(10)
  prizePaidOut = True;
}

3 支付中使用send函数带来的问题

在以太坊中,每一次以太币的转移都需要调用代码。而接收地址可以实施一个回滚(fallback)函数,该函数可能会抛出一个错误。因此,我们永远不要相信一个发送调用的执行是没有错误的。解决方案:我们的合约应该支持pull支付超过push支付。下面我们看一个代码:

contract BadPushPayments
 { 
address highestBidder; 
uint highestBid;
function bid() 
{ 
if (msg.value < highestBid) throw; 
if (highestBidder != 0) 
{ 
if (!highestBidder.send(highestBid)) { throw; } } 
highestBidder = msg.sender; 
highestBid = msg.value;
 } 
}

合约调用发送函数并检查其返回值,该值看起来很合理。但它在一个函数中间调用发送,这是不安全的,因为发送可以触发另一个合约中的代码的执行。
想象一下,有人从一个地址出价,每次有人向该地址发送资金,就抛出一个错误。如果有人尝试出价高于这个呢?“send”调用将永远失败,使竞价出现异常。一个以错误结束的函数调用会使状态不会改变(任何更改都滚回)。这意味着没有人可以出价,合约也就失败了。

而为了解决这个问题,我们需要将支付分开到不同的函数中,让用户请求与其他合约独立。

contract GoodPullPayments 
{ 

    address highestBidder; 
    uint highestBid; 
    mapping(address => uint) refunds;
function bid() external 
{ 
    if (msg.value < highestBid) throw;

    if (highestBidder != 0) 
{ refunds[highestBidder] += highestBid; }

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

function withdrawBid() external 
{ 
    uint refund = refunds[msg.sender]; 
    refunds[msg.sender] = 0; 
    if (!msg.sender.send(refund)) 
    { refunds[msg.sender] = refund; } 
} 
}

此代码中,我们使用了一个映射值来为每一位出高价的投标人储存退款值。并提供一个函数来提前他们的资金。而倘若send调用出现问题也只有投标人受到影响。

4 竞赛send相关题目

下面我们看一道相关CTF题目。

某个CTF区块链题目源代码如下:

pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';

contract King is Ownable {

  address public king;
  uint public prize;

  function King() public payable {
    king = msg.sender;
    prize = msg.value;
  }

  function() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }
}

阅读合约代码,我们理解题目为:谁给的钱多谁就能成为 King,并且将前任 King 付的钱归还。而当重新提交实例时,题目会夺回king的地位,所以需要阻止其他人成为king。

回头再看一下代码,当我们成为 King 之后,如果有人出价比我们高,会首先把钱退回给我们,使用的是 transfer()。而我们知道,当 transfer() 调用失败时会回滚状态,那么如果合约在退钱这一步骤一直调用失败的话,代码将无法继续向下运行,其他人就无法成为新的 King。

所以我们需要部署一个新的合约,当收到转账时主动抛出错误。

pragma solidity ^0.4.18;

contract Attack {
    address instance_address = instance_address_here;

    function Attack() payable{}

    function hack() public {
        instance_address.call.value(1.1 ether)();
    }

    function () public {
        revert();
    }
}

最后调用 hack(), 成为新的 King。

题目链接为King
)。

四、send()函数安全事件

而上述模型均为理论上的存在,那么现实合约中是否有相关安全事件发生呢?

1 Etherpot

第一个事件是Etherpot彩票只能合约。这份合约受到未经检查的 Call 返回值的影响:

function cash(uint roundIndex, uint subpotIndex){

        var subpotsCount = getSubpotsCount(roundIndex);

        if(subpotIndex>=subpotsCount)
            return;

        var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);

        if(decisionBlockNumber>block.number)
            return;

        if(rounds[roundIndex].isCashed[subpotIndex])
            return;
        //Subpots can only be cashed once. This is to prevent double payouts

        var winner = calculateWinner(roundIndex,subpotIndex);    
        var subpot = getSubpot(roundIndex);

        winner.send(subpot);

        rounds[roundIndex].isCashed[subpotIndex] = true;
        //Mark the round as cashed
}

其中:

winner.send(subpot);
rounds[roundIndex].isCashed[subpotIndex] = true;
//Mark the round as cashed

怎么样?是不是十分熟悉?这个就是典型的send后并没有检查返回值而直接给复制为ture。从而带来了许多隐患。

2 King of the Ether平台

我们来看一下相关合约平台 King of the Ether。其曾经爆出过相关send函数而引发的漏洞。

King of the Ether合约是一种典型的系统合约账户。我们来看一下它具体的执行步骤:

  • 假设现在想要获得权力需要10个以太币。

  • 假如你想成为国王,所以你支付10个以太币作为交换。

  • 你支付了10个以太币(其中用百分之一的钱做赎金)给上一个权力拥有者。

  • 之后你拥有了国王的权力。

  • 之后新的制度下来,成为国王需要增长费用50%,也就是说需要15个以太币。

  • 下一个人需要支付了15个并成为了新的国王。

然而在这个合约中,上述的send问题也是存在的。

当你准备支付赎金的时候,你的合约中Gas值却不足(少于2300Gas)。所以它不足以执行合约中的代码内容。然而合约失败了,钱并没有转过去,可是系统代码并没有进行验证,也就是说调用send后虽然没给钱,但是仍拿到了东西。

currentMonarch.etherAddress.send(compensation);

这也就是我们上面讲述的king这个题目的来源。

而我们对此内容有何建议呢?

首先我们应该避免使用1.

<address>.send(<amount>)函数,除非我们已经确保执行合约的节点中Gas值充足。

之后我们需要考虑要在遇到send错误后要进行回滚操作,将之前进行的内容返回。

使用<address>.call.value(value).gas(extraGasAmt)()函数对主函数进行支付。

检查啊send()call()函数的返回结果。并针对不同合约的特点指定不同的规则,比如像 the King of the Ether,它会在执行代码时消耗大量的Gas,这会时合约僵持在哪里,所以导致合约平台卡死,没有任何人能够继续申请。

最重要的一点,在合约部署之前,多进行测试工作。

五、参考链接

本稿为原创稿件,转载请标明出处。谢谢。

</amount></address>

关键词:[‘技术文章’, ‘技术文章’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now