主页 > 苹果下载imtoken教程 > 区块链安全——谈合约攻击(一)

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

苹果下载imtoken教程 2023-02-25 06:23:46

一、为什么合约是智能的?

在上一篇文章中,我们详细介绍了Pos、DPos、BFT等常见落地项目中的一些共识机制。而读者在了解了共识机制的具体流程后,应该和我一样惊叹于共识协议的美妙之处。除了共识机制,区块链中还有一个很吸引人的技术,那就是“智能合约”。智能合约的引入提升了区块链的发展轨迹,也为区块链技术带来了更多活力。

智能合约的重要性是什么?我们应该如何看待智能合约?

谈到智能合约,我们首先要说明的是,在早期,智能合约和区块链是两种独立的技术。区块链的诞生晚于智能合约。也就是说,在区块链1.0诞生的时候,智能合约还没有被引入到区块链技术中。随着区块链的发展,人们发现区块链在价值传递过程中需要一套规则来描述价值传递的方式。这种方法在最早的比特币中并不存在,随着以太坊的出现,这种假设在智能合约的帮助下成为可能。

根据历史发展,智能合约最早出现于 1995 年,这意味着它们几乎与互联网同时出现。本质上,只有合约类似于计算机语言中的 if-then 语句。智能合约与现实世界的交互方式如下:当一个预编程条件被触发时以太坊区块链被滥用,智能合约执行相应的条款,系统通过相应的条款执行交易。

区块链2.0时代到来后,区块链正式与智能合约结合。这也使得区块链技术真正摆脱了数字货币的束缚,成为一种独立的技术。由于智能合约的引入,区块链的应用场景突然变得更广泛。区块链现在可以在许多行业中看到。

那么什么是智能合约?智能合约的本质实际上是用计算机语言编写的程序。该程序可以在区块链系统提供的容器中运行,也可以在一定的外部和内部条件下激活该程序。该特性与区块链技术的结合,不仅避免了人为篡改规则,还发挥了智能合约在效率和成本方面的优势。

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

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

1 Solidity 的三个调用函数

在讲解第二次Parity安全事件之前,我们先来研究分析一些相关的安全功能。我们在之前的手稿中已经详细描述了 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的值会变为调用者,而执行环境是被调用者的运行环境(合约的存储)。

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的调用方法,观察变量的值,发现合约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的值会被修改给调用者,但执行环境是调用者的运行环境。

之后,我们就可以分析第二次奇偶校验攻击事件了。

2、事件分析

在Parity钱包中,为了方便用户,提供了一个多重签名合约模板,用户可以使用该模板制作自己的多重签名合约,而不需要大量的代码。 Parity 钱包中的实际业务将通过 delegatecall 函数内联交付给库合约。相当于把我的shutdown核心代码部署在服务端,不需要用户自己部署。由于多签合约的主要逻辑(代码量大),合约部署一次即可,否则用户全部部署在本地是非常不合理的行为。此外,这也可以为用户在部署多重签名合约时节省大量 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用来限制签名者的数量。

钱包合约基本上会通过委托调用将所有调用传递给 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 转移到这个合约地址。长度 > 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 合约并且不包含更改引用的方法,因此 WalletLibrary 合约会丢失其所有功能(包括检索 Ether 的能力)。

自杀后,唯一可用的功能是:

// 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、防御措施

此级别的奇偶校验事件包括: 有几种预防方法。首先,智能合约放弃了自杀功能,这样即使黑客获得了高级权限,也无法移除合约。二是在initWallet、initDaylimit和initMultiowned中进一步增加内部限制类型,禁止外部调用:

// 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 这样的危险功能。

四、参考文献