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

一、合约何以智能?

在前文中,我们详细的讲述了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这样的危险函数就尽量不要出现。

四、参考资料

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
设计模式是指在软件开发过程中,经过总结和归纳后形成的针对某类问题的解决方案。设计模式可以帮助开发人员更好地组织和管理代码,提高代码的可重用性和可维护性。常见的设计模式包括: 1. 创建型模式:用于处理对象创建的模式,包括单例模式、工厂方法模式、抽象工厂模式、建造者模式和原型模式。 2. 结构型模式:用于处理对象间关系的模式,包括适配器模式、装饰器模式、代理模式、组合模式、桥接模式和享元模式。 3. 行为型模式:用于处理对象间通信的模式,包括观察者模式、模板方法模式、命令模式、迭代器模式、责任链模式、访问者模式、策略模式和状态模式。 下面对常用的几种设计模式进行详细介绍: 1. 单例模式:确保一个类只有一个实例,并提供全局访问点。 2. 工厂方法模式:定义一个用于创建对象的接口,让子类决定实例化哪个类。 3. 抽象工厂模式:提供一个接口,用于创建相关或依赖对象的家族,而不需要指定具体类。 4. 建造者模式:将一个复杂对象的构建与其表示分离,使得同样的构建过程可以创建不同的表示。 5. 原型模式:通过复制现有的实例来创建新的实例。 6. 适配器模式:将一个类的接口转换成客户端所期望的另一种接口,使原本不能一起工作的类可以协同工作。 7. 装饰器模式:动态地给一个对象添加一些额外的职责,就增加功能来说,装饰器模式比生成子类方式更为灵活。 8. 代理模式:为其他对象提供一种代理以控制对这个对象的访问。 9. 组合模式:将对象组合成树形结构以表示”部分-整体”的层次结构,使得客户端对单个对象和组合对象的使用具有一致性。 10. 桥接模式:将抽象部分与它的实现部分分离,使它们都可以独立地变化。 11. 享元模式:运用共享技术有效地支持大量细粒度的对象。 12. 观察者模式:定义了对象之间的一对多依赖,当一个对象状态改变时,它所有依赖者都会收到通知并自动更新。 13. 模板方法模式:定义一个算法的骨架,将一些步骤延迟到子类中实现,使得子类可以在不改变算法结构的情况下重新定义算法的某些特定步骤。 14. 命令模式:将请求封装成对象,从而可以用不同的请求对客户进行参数化,使得请求排队或记录请求日志,以及支持可撤销操作。 15. 迭代器模式:提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示。 16. 责任链模式:为解除请求的发送者和接收者之间耦合,而使多个对象都有机会处理这个请求,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 17. 访问者模式:表示一个作用于某个对象结构中的各元素的操作,它可以使你在不改变各元素的类的前提下定义作用于这些元素的新操作。 18. 策略模式:定义一系列算法,将每个算法都封装起来,并使它们之间可以互换。 19. 状态模式:允许一个对象在其内部状态改变时改变其行为,对象看起来似乎修改了其所属的类。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值