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

一、合约何以智能?

在前文中,我们详细的讲述了Pos、DPos、BFT等常用的落地项目中的一些共识机制。而读者在了解了共识机制的具体流程后也应该会向我一样惊共识的协议之美。在区块链中,除了共识机制以外,还有另外一种富含魅力的技术,那就是“智能合约”。智能合约的引入增强的区块链的发展轨迹,也为区块链技术带来了更多生机。

而智能合约的重要性到底是如何呢?我们应该如何看待智能合约?

提及智能合约,我们首先要说明的是在早期的时候,智能合约与区块链本是两个独立的技术。而区块链诞生要晚于智能合约。也就是说,区块链1.0出世的时候智能合约还没有被采纳入区块链技术。而随着区块链的发展,人民发现区块链在价值传递的过程中需要一套规则来描述价值传递的方式,这套规则应该令机器进行识别和执行而不是人为。在最早的比特币中还没有出现这种方法,而随着以太坊的出现,这种假设在智能合约的帮助下成为了可能。

按照历史的发展,智能合约最早出现在了1995年,也就是说几乎与互联网同时代出现的。从本质上讲,只能合约类似于计算机语言中的if-then语句。智能合约通过如下方式与真实世界进行交互:当一个预先编好的条件被触发时,智能合约执行相应的条款,而系统通过相应的条款进行交易的执行。

在区块链2.0时代到来后,区块链正式与智能合约相结合。这也使区块链技术真正的脱离了数字货币的枷锁,成为一门独立的技术。由于智能合约的引入,区块链的应用场景一下子广泛了起来。现在在许多行业中都可以看到区块链的身影。

那么智能合约是什么呢?智能合约的本质其实就是一段使用计算机语言而编程的程序,这段程序可以运行在区块链系统所提供的容器中,同时这个程序也可以在某种外在、内在的条件下被激活。这种特性与区块链技术相融合不仅避免了人为对规则的篡改,而且发挥了智能合约在效率和成本方面的优势。

在安全方面,由于智能合约代码放在了区块链中并且在区块链系统提供的容器中运行的,在结合密码学技术的前提下,区块链具有了天然的防篡改以及防伪造的特性。

二、以太坊第二次Parity安全事件

1 Solidity 的三种调用函数

在讲解第二次Parity安全事件之前,我们要对一些相关的安全函数进行研究分析。我们在之前的稿件中曾经对delegatecall()函数进行过详细的讲述。而今我们对其他三种函数进行更多的分析。

delegatecall()函数的滥用

在Solidity中我们需要知道几个函数:call()、delegatecall()、callcode()。在合约中使用此类函数可以实现合约之间相互调用及交互。而两次Parity安全事件都是由于类似的几个函数出现了问题而导致以太币被盗。所以掌握此类调用函数的正确用法也是分析区块链安全所必不可少的。

而我们知道,msg中保存了许多关于调用方的一些信息,例如交易的金额数量、调用函数字符的序列以及调用发起人的地址信息等。然而当上述三种函数在调用的过程中, Solidity 中的内置变量 msg 会随着调用的发起而改变。

下面我们就详细的讲解一下此类三种函数的异同点以及安全隐患。

contract D {
  uint public n;
  address public sender;

  function callSetN(address _e, uint _n) {
    _e.call(bytes4(sha3("setN(uint256)")), _n); // E's storage is set, D is not modified 
  }

  function callcodeSetN(address _e, uint _n) {
    _e.callcode(bytes4(sha3("setN(uint256)")), _n); // D's storage is set, E is not modified 
  }

  function delegatecallSetN(address _e, uint _n) {
    _e.delegatecall(bytes4(sha3("setN(uint256)")), _n); // D's storage is set, E is not modified 
  }
}

contract E {
  uint public n;
  address public sender;

  function setN(uint _n) {
    n = _n;
    sender = msg.sender;
    // msg.sender is D if invoked by D's callcodeSetN. None of E's storage is updated
    // msg.sender is C if invoked by C.foo(). None of E's storage is updated

    // the value of "this" is D, when invoked by either D's callcodeSetN or C.foo()
  }
}

contract C {
    function foo(D _d, E _e, uint _n) {
        _d.delegatecallSetN(_e, _n);
    }
}

delegatecall: 对于msg方面,其函数被调用后值不会修改为调用者,但是其执行在调用者的运行环境中。这个函数也经常爆出很严重的漏洞,例如我曾经讲述的第一次Parity的安全漏洞就是因为此函数将调用者环境中的函数跨合约执行。

call: 此函数为最常用的调用方式,与delegatecall不同的是,而此时msg的值将修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。

callcode: 同call函数一样,调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境。

pragma solidity ^0.4.0;

contract A {
    address public temp1;
    uint256 public temp2;

    function three_call(address addr) public {
        addr.call(bytes4(keccak256("test()")));                 // call函数
        addr.delegatecall(bytes4(keccak256("test()")));       // delegatecall函数
        addr.callcode(bytes4(keccak256("test()")));           // callcode函数
    }
}

contract B {
    address public temp1;
    uint256 public temp2;

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

在实验开始前,部署合约后查看合约A、B中的变量均为temp1 = 0, temp2 = 0

现在调用语句1 call 方式,观察变量的值发现合约 A 中变量值为0,而被调用者合约 B 中的 temp1 = address(A), temp2 = 100。即msg中的地址为调用者(address(A)),而环境为被调用者B(temp2 = 100)。

下面使用调用语句2 delegatecall 方式,观察变量的值发现合约 B 中变量值为 0,而调用者合约 A中 temp2 = 100。即调用函数后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境。

现在调用语句3 callcode 方式,观察变量的值发现合约 B 中变量值为 0,而调用者合约 A 中的temp1 = address(A), temp2 = 100。即调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境。

之后我们就可以分析第二次Parity攻击事件了。

2、 事件分析

在Parity钱包中为了方便用户的使用提供了多签合约模板,而用户使用此模板可以生产自己的多方签名合约并且不需要很大的代码量。而在Parity钱包的实际业务中都会通过delegatecall函数内嵌式地交给库合约。相当于我的关机核心代码部署在服务器方,不用用户自行部署。由于多签合约的主逻辑(代码量较大),所以合约部署一次即可,不然用户全部都要在本地部署是一个很不理智的行为。除此之外,这还可以为用户节省部署多签合约所耗费的大量Gas。

下面我们看一下问题代码:代码

Parity 多签名钱包第二次被黑事件是一个例子,说明了如果在非预期的环境中运行,良好的库代码也可以被利用。我们来看看这个合约的相关方面。这里有两个包含利益的合约,库合约和钱包合约。

contract WalletLibrary is WalletEvents {

  ...
 // constructor - stores initial daily limit and records the present day's index.
  function initDaylimit(uint _limit) internal {
    m_dailyLimit = _limit;
    m_lastDay = today();
  }
  // (re)sets the daily limit. needs many of the owners to confirm. doesn't alter the amount already spent today.
  function setDailyLimit(uint _newLimit) onlymanyowners(sha3(msg.data)) external {
    m_dailyLimit = _newLimit;
  }
  // resets the amount already spent today. needs many of the owners to confirm.
  function resetSpentToday() onlymanyowners(sha3(msg.data)) external {
    m_spentToday = 0;
  }

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

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

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

}

再看钱包合约

contract Wallet is WalletEvents {

  ...

  // 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);
  }

  ...  

  // FIELDS
  address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe;
}
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
    initDaylimit(_daylimit);
    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) only_uninitialized {
    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;
}

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

根据上述代码我们知道,此时为了防止第一次的Parity中的问题,这里的几段函数都增加了only_uninitialized来限制签名人的数量。

Wallet 合约基本上会通过 delegate call 将所有调用传递给 WalletLibrary。此代码段中的常量地址 _walletLibrary,即是实际部署的 WalletLibrary 合约的占位符。

而我们可以使用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);
  }

倘若我们能够执行上述合约中的_walletLibrary.delegatecall(msg.data);,此时,我们通过往这个合约地址转账一个value = 0, msg.data.length > 0的交易,以执行_walletLibrary.delegatecall分支。并将msg.data中传入我们要执行的initWallet ()函数。而此类函数的特性也就帮助我们将钱包进行了初始化。

function initMultiowned(address[] _owners, uint _required) only_uninitialized {
    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;
}

这个函数假定创建者会调用initWallet函数,但是initWallet的only_uninitialized ()函数在内部被执行,所以攻击者成为了所谓的owner(可以控制系统运行相应函数)。

第一次调用initWallet交易結果:

Function: initWallet(address[] _owners, uint256 _required, uint256 _daylimit)
MethodID: 0xe46dcfeb
[0]:0000000000000000000000000000000000000000000000000000000000000060
[1]:0000000000000000000000000000000000000000000000000000000000000000
[2]:0000000000000000000000000000000000000000000000000000000000000000
[3]:0000000000000000000000000000000000000000000000000000000000000001
[4]:000000000000000000000000ae7168deb525862f4fee37d987a971b385b96952

之后攻击者拿到了系统的控制权限,调用了kill函数:

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

随后调用 kill() 功能。因为用户是 Library 合约的所有者,所以修改传入、Library 合约自毁。因为所有现存的 Wallet 合约都引用该 Library 合约,并且不包含更改引用的方法,因此其所有功能(包括取回 Ether 的功能)都会随 WalletLibrary 合约一起丢失。

自杀之后,唯一可以用的函数只有:

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

这种类型的 Parity 多签名钱包中的所有以太都会立即丢失或者说永久不可恢复。

流程图如下:

3、防御措施

这档次的Parity事件有几种预防的方式,一是智能合约摒弃自杀函数,这样的话即使黑客获得了高级权限也无法将合约移除。第二是可以进一步对initWallet、initDaylimit及initMultiowned添加internal限定类型,以禁止外部调用:

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

// constructor - stores initial daily limit and records the present day's index.
function initDaylimit(uint _limit) internal only_uninitialized {
    m_dailyLimit = _limit;
    m_lastDay = today();
}

// 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) internal only_uninitialized {
    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;
}

可以增加internal()函数来增强限制函数的作用。

三、总结

本次事件是在第一次Parity事件的基础之上衍生出来的。攻击者属于无意识的误操作。

而针对此次事件,我认为官方需要引起相应的思考,其实在问题曝光之前就有网友提到过此类问题,但是并没有引起相关的关注。所以Parity管理者应该对此进行关注。其次,在漏洞修补方面,我认为应该更加严格的赋给权限,做到完全禁止外部陌生用户的访问。在合约设计方面,我认为类似于KILL这样的危险函数就尽量不要出现。

四、参考资料

猜你喜欢

转载自blog.csdn.net/Fly_hps/article/details/84296643