美文网首页区块链故事
区块链的那些事—白话parity多签名合约漏洞

区块链的那些事—白话parity多签名合约漏洞

作者: CPinging | 来源:发表于2018-10-22 19:52 被阅读35次
    image.png
    本篇为原创稿件,首稿发在先知社区 https://xz.aliyun.com/t/2943
    
    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美元左右。此次事件主要是由于合约代码不严谨导致的。我们可以从区块浏览器看到黑客的资金地址:

    image.png

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

    image.png

    我们大致来看此次事件,本次漏洞同样出现在应用层,是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(),当我的攻击代码中有敏感内容时,攻击就会奏效。

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

    image.png

    三、合约源码详细解读

    了解了上面的关键函数后。我们就具体的来看一下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()未做校验,可以被多次调用。所以尽管钱包在最初的时候进行了合法的初始化,但是我攻击者可以将其系统中进行修改,迫使系统代码自行将所有的地址更变为攻击值的地址值。

    流程图如下:

    image.png

    之后,攻击者执行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; 
            _;
    }
    

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

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

    五、参考链接

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

    相关文章

      网友评论

        本文标题:区块链的那些事—白话parity多签名合约漏洞

        本文链接:https://www.haomeiwen.com/subject/julmzftx.html