Parity是目前以太坊使用最广泛的钱包之一,此次事件是一起因为只能合约代码漏洞导致的以太币被盗事件。

一、Parity合约漏洞事件概述

由于读者可能没有接触过区块链的知识,所以我在开始的时候将简短的介绍下此应用的背景。首先,我们需要介绍一下Parity。

Parity是用 Rust 语言开发的以太坊节点应用,其特点就是速度块、轻量化,性能远优于 Go 语言实现的 Geth 以太坊客户端。Parity 目前为 Web 3.0 基金会成员,由前以太坊联合创始人兼 CTO Gavin Wood 博士掌舵。而我们知道,Parity作为以太坊的一种钱包,有如下的几个特点:

  • 1 因为其为重构的代码,所以跑起来更快,占用系统的资源更少。

  • 2 它的同步功能做得更好,所以其他钱包很久不能同步的的时候,它还是能够很快同步。

  • 3 它所占用的空间资源较小。它虽然是一个全节点钱包,但是它把那些很早的区块只留下了区块头,其他内容删减了,所以同步好的区块的大小也就几个G,而如果是用以太坊的官方全节点钱包,光区块大概就得有40个G。

  • 4 它能够设置定时发送交易,能够在到达某个区块数的时候自动发送转账交易。

多重签名钱包是多个人使用自己的私钥控制的以太坊账号,需要在多数人用私钥签名之后才能转移出资金。

所以这个应用也获得了许多人的使用,如果有爱好者也想尝试使用下此钱包,请参照 以太坊Parity钱包使用教程,ETH Parity钱包教程

好了,现在我们开始步入正题。详细的讲述下这个钱包曾发生的风风雨雨吧。

在2017年7 月 19 日,Parity发布安全警报,警告其钱包软件1. 5 版本及之后的版本存在一个漏洞。据该公司的报告,确认有153,000ETH(大约价值 3000 万美元)被盗。

据Parity所说,漏洞是由一种叫做wallet.sol的多重签名合约出现bug导致。后来,白帽黑客找回了大约377,000 受影响的ETH。

本次攻击造成了以太币价格的震荡,Coindesk的数据显示,事件曝光后以太币价格一度从235美元下跌至196美元左右。此次事件主要是由于合约代码不严谨导致的。我们可以从区块浏览器看到黑客的资金地址:

可以看到,一共盗取了153,037 个ETH,受到影响的合约代码均为Parity的创始人Gavin Wood写的Multi-Sig库代码:

我们大致来看此次事件,本次漏洞同样出现在应用层,是Solidity编程语言的智能合约代码漏洞。与我曾经分析过的THE DAO事件类似,本次漏洞也是代码逻辑不严谨导致的黑客越权攻击行为。我会在结尾将这两次攻击进行一个比较总结,详细文章可以参看区块链的那些事—THE DAO攻击事件源码分析

二、漏洞关键函数剖析

在详细分析此次漏洞前,我将部分合约中涉及到的基础函数进行一个详细的讲解。(有了此铺垫,后面的内容会更容易理解)。

由于项目是与太坊平台相关的项目,所以我们的合约部分均是由Solidity进行编写。

Solidity 是一种用与编写以太坊智能合约的高级语言,语法类似于 JavaScript。Solidity 编写的智能合约可被编译成为字节码在以太坊虚拟机上运行。Solidity 中的合约与面向对象编程语言中的类(Class)非常类似,在一个合约中同样可以声明:状态变量、函数、事件等。同时,一个合约可以调用/继承另外一个合约。

而正是由于可以继承、调用另外的合约,所以才引出了本次漏洞。

在Solidity中我们需要知道几个函数:call、delegatecall、callcode。在合约中使用此类函数可以实现合约之间相互调用及交互。而也正是此类函数向用户开放了DIY的权利,也导致了用户代码的“野蛮生长”,也随之而来的带来了极大的风险。

Solidity的调用函数

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 call、delegatecall 和 callcode 三种方式。由于此漏洞与delegatecall相关,所以我们详细的讲解此函数。下面看一个具体的例子:

pragma solidity ^0.4.0;

contract A {
    address public temp1;
    uint256 public temp2;

    function three_call(address addr) public {
       addr.delegatecall(bytes4(keccak256("test()")));    
    }
}

contract B {
    address public temp1;
    uint256 public temp2;

    function test() public  {
        temp1 = msg.sender;
        temp2 = 100;
    }
}

由例子我们可以知道,合约A中调用了delegatecall ()函数,并使用此函数跨合约调用了合约B的test()函数。

由测试我们知道,delegatecall 的执行环境为调用者环境,当调用者和被调用者有相同变量时,如果被调用的函数对变量值进行修改,那么修改的是调用者中的变量。这也就能够达到修改主机上任意代码的可能,也就意味着拿到了主机的root权限

delegatecall()函数的滥用

下面我们看一个例子具体理解代码是如何滥用delegatecall()函数的。

function test(uint256 a) public {
    // 测试代码test
}

function Func() public {
    <A.address>.delegatecall(bytes4(keccak256("test(uint256)")));
}

由上述代码我们知道,Func函数在内部调用了A地址的test()函数。但是许多开发者为了代码的灵活使用,往往用以下的内容来写代码:

function Func(address addr, bytes data) public {
    addr.delegatecall(data);
}

倘若代码中有逻辑漏洞出现会是什么样子的呢?

contract Servers {
    address owner;

    function Func(address addr, bytes data) public {
        addr.delegatecall(data);
        //address(Attack).delegatecall(bytes4(keccak256("Attack_code()")));  
//代码为被攻击者的代码,其使用了delegatecall函数。
    }
}

攻击者对应这种合约可以编写一个 Attack 合约,然后精心构造字节序列(将注释部分的攻击代码转换为字节序列),通过调用合约 Server 的 delegatecall,最终调用 Attack 合约中的函数,下面是 Attack 合约的例子:

contract Attack {
    address owner;

    function Attack_code() public {
        // 任何有威胁的攻击代码。
    }
}

此时我的server端就可能会被攻击者利用,通过delegatecall()代码来执行
Attack_code(),当我的攻击代码中有敏感内容时,攻击就会奏效。

例如被攻击合约的源代码:

三、合约源码详细解读

了解了上面的关键函数后。我们就具体的来看一下7月份的这个Parity多签名合约漏洞的详细解析。

我们将当时的源代码放上enhanced-wallet.sol

我们简单的想一下如何作案。

加入我想要进行攻击,那么我首先应该怎么做呢?我的目的是什么?

简单来说,我的目的肯定是能获得“利益”了。

那么我们应该获得什么利益呢?同学肯定说:答案是肯定的,在以太坊中我肯定想获得以太币呗!

那问题又来了,你想获得以太币,应该怎么获得呢?挖矿?(要是正常挖矿就没有现在的事情了)。所以我们肯定是想“不劳而获”喽。

此时,有的同学就会说:“要是有人养着我,不断给我转钱就好了!”。

问题的解决办法就浮现出来。对呀!要是所有人都给我转钱就好了!我们可以大胆的想,假如我是银行,我把应该的汇款对象都设置成我的账户,让所有人的转账都神不知鬼不觉的转到我自己的账户该有多好!!

在银行中,我们这么做肯定是要被立刻发现的。但是作为区块链项目的以太币,它的匿名性就给这种想法提供了可乘之机。于是我们就尝试去修改“Parity”钱包的汇款地址。让所有的人都汇款给我。

那么我们一步一步的去看合约详细内容。

首先在合约中,我们看到了钱包初始化函数。我们知道“Parity”钱包的机制是由多人的私钥进行签名才能够进行汇款等操作,所以这里的地址类型是一个数组。

// constructor - just pass on the owner array to the multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }

  // kills the contract sending everything to `_to`.
  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }

随着函数的进行,它在初始化的时候会执行initMultiowned(_owners, _required);函数。

// constructor is given number of sigs required to do protected "onlymanyowners" transactions
  // as well as the selection of addresses capable of confirming them.
  function initMultiowned(address[] _owners, uint _required) {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i)
    {
      m_owners[2 + i] = uint(_owners[i]);
      m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
  }

在该函数中,我们首先发现此功能是初始化合约钱包,并对钱包所有者的地址进行更新。

所以我们可以猜测,我们是否可以调用到此函数,初始化整个钱包,将合约拥有者修改为仅我自己一人,随后进行转账操作呢?

可是问题又来了,我们并没有执行此函数的权限。那我们应该怎么办呢?此时就要用到我们在上面所写的delegatecall()函数。

下面是钱包合约的内容:

contract Wallet is WalletEvents {

  // WALLET CONSTRUCTOR
  //   calls the `initWallet` method of the Library in this context
  function Wallet(address[] _owners, uint _required, uint _daylimit) {
    // Signature of the Wallet Library's init function
    bytes4 sig = bytes4(sha3("initWallet(address[],uint256,uint256)"));
    address target = _walletLibrary;

    // Compute the size of the call data : arrays has 2
    // 32bytes for offset and length, plus 32bytes per element ;
    // plus 2 32bytes for each uint
    uint argarraysize = (2 + _owners.length);
    uint argsize = (2 + argarraysize) * 32;

    assembly {
      // Add the signature first to memory
      mstore(0x0, sig)
      // Add the call data, which is at the end of the
      // code
      codecopy(0x4,  sub(codesize, argsize), argsize)
      // Delegate call to the library
      delegatecall(sub(gas, 10000), target, 0x0, add(argsize, 0x4), 0x0, 0x0)
    }
  }

  // METHODS

  // gets called when no other function matches
  function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }

  // Gets an owner by 0-indexed position (using numOwners as the count)
  function getOwner(uint ownerIndex) constant returns (address) {
    return address(m_owners[ownerIndex + 1]);
  }

  // As return statement unavailable in fallback, explicit the method here

  function hasConfirmed(bytes32 _operation, address _owner) external constant returns (bool) {
    return _walletLibrary.delegatecall(msg.data);
  }

  function isOwner(address _addr) constant returns (bool) {
    return _walletLibrary.delegatecall(msg.data);
  }

  // FIELDS
  address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe;

  // the number of owners that must confirm the same operation before it is run.
  uint public m_required;
  // pointer used to find a free slot in m_owners
  uint public m_numOwners;

  uint public m_dailyLimit;
  uint public m_spentToday;
  uint public m_lastDay;

  // list of owners
  uint[256] m_owners;
}

在此合约中,我们能看到支付函数中存在_walletLibrary.delegatecall(msg.data);。而我们知道倘若我们令其系统执行了此函数,那么我们就可以随心所欲的执行所有_walletLibrary中的内容了。

function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }

此时,我们通过往这个合约地址转账一个value = 0, msg.data.length > 0的交易,以执行_walletLibrary.delegatecall分支。

并将msg.data中传入我们要执行的initWallet ()函数。而此类函数的特性也就帮助我们将钱包进行了初始化。又由于钱包初始化函数 initMultiowned()未做校验,可以被多次调用。所以尽管钱包在最初的时候进行了合法的初始化,但是我攻击者可以将其系统中进行修改,迫使系统代码自行将所有的地址更变为攻击值的地址值。

流程图如下:

之后,攻击者执行execute()函数。

// Outside-visible transact entry point. Executes transaction immediately if below daily spend limit.
  // If not, goes into multisig process. We provide a hash on return to allow the sender to provide
  // shortcuts for the other confirmations (allowing them to avoid replicating the _to, _value
  // and _data arguments). They still get the option of using them if they want, anyways.
  function execute(address _to, uint _value, bytes _data) external onlyowner returns (bytes32 o_hash) {
    // first, take the opportunity to check that we're under the daily limit.
    if ((_data.length == 0 && underLimit(_value)) || m_required == 1) {
      // yes - just execute the call.
      address created;
      if (_to == 0) {
        created = create(_value, _data);
      } else {
        if (!_to.call.value(_value)(_data))
          throw;
      }
      SingleTransact(msg.sender, _value, _to, _data, created);
    } else {
      // determine our operation hash.
      o_hash = sha3(msg.data, block.number);
      // store if it's new
      if (m_txs[o_hash].to == 0 && m_txs[o_hash].value == 0 && m_txs[o_hash].data.length == 0) {
        m_txs[o_hash].to = _to;
        m_txs[o_hash].value = _value;
        m_txs[o_hash].data = _data;
      }
      if (!confirm(o_hash)) {
        ConfirmationNeeded(o_hash, msg.sender, _value, _to, _data);
      }
    }
  }

而我们可以看到函数中的external onlyowner

// simple single-sig function modifier.
modifier onlyowner {
    if (isOwner(msg.sender))
        _;
}

此函数使攻击者不能轻易的进行转账操作,但是我们之前所有的操作均是为了将此函数绕过(通过修改owner地址)。

此时我们的黑客就可以收钱了hhh。

四、总结

简单来说,此次攻击存在代码过滤不严格的情况。首先是没有代码去检查钱包初始化是否执行过,导致初始化函数可以多次使用。除此之外,我们给与用户的权限过于大,也就是自由发展带来的损失。所以为了生态圈的“野蛮生长”,我们既要放开绳子,又要对核心层进行严格把关。

如何解决上述问题呢?我们可以定义一下限制器函数。

// throw unless the contract is not yet initialized.
modifier only_uninitialized {
    if (m_numOwners > 0) throw; 
        _;
}

并在上述初始化函数中进行使用,以便使这些函数无法多次调用。

本稿件的分析是我经过大量阅读已经自己的分析后进行的详细总结,因为我想将内容讲到更细节的地方,所以分析的内容较多。如果大家有什么想法进行交流讨论,可以在下方评论。谢谢!

五、参考链接

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

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖