本篇为原创稿件,首稿发在先知社区 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;
_;
}
并在上述初始化函数中进行使用,以便使这些函数无法多次调用。
本稿件的分析是我经过大量阅读已经自己的分析后进行的详细总结,因为我想将内容讲到更细节的地方,所以分析的内容较多。如果大家有什么想法进行交流讨论,可以在下方评论。谢谢!
五、参考链接
-
1 https://baijiahao.baidu.com/s?id=1608675086808181221&wfr=spider&for=pc
-
3 https://blog.csdn.net/xuguangyuansh/article/details/80786691?utm_source=blogxgwz5
本稿为原创稿件,转载请标明出处。谢谢。
网友评论