原文链接:郭老师的备课资料 <https://zhuanlan.zhihu.com/p/48451641>


在引入比特币等加密货币时,一个经常提及的概念是支撑着这些加密货币的底层框架——区块链协议非常地安全可靠。各种加密算法保证了区块链的正常运行,区块链中的信息不可篡改、不能删除,基于工作量的证明保证难以有攻击者可以控制网络。在这些良好性质的支持下,加密货币系统得到了快速的发展。


但是,对加密货币的绝对安全的期望是错误的。接下来,我们将看一些具体的例子,查看其中加密货币系统是如何被攻击的。第一个例子,攻击者针对的是区块链的底层运行算法;第二个例子,攻击者利用的则是区块链上智能合约的编写。

<>一、 针对verge的攻击

verge是一种规模相对较小的加密货币。在2018年4月 4 日至 6 日这段时间里,黑客成功地控制了 Verge
网络三次,每次持续几个小时,在此期间,黑客阻止了任何其他用户进行支付。而且在此期间他们能够以 1560 枚每秒(大约 $80)的速度伪造 Verge
币,共伪造价值超百万美元的 Verge 币。

看到这个攻击效果时,我们来想一想,什么样的攻击能够达到这个效果?


其他用户完全不能支付,也就意味着这些用户产生的交易始终不能被打包到区块中,不能获得确定,从而相当于是不能进行支付。如何伪造Verge币呢?持续产生区块就行。那如何能持续产生区块呢?那就要比其他用户更快地产生区块。换言之,就是要控制网络中超过51%的算力。


51%攻击我们在双重支付的时候提到过,如果攻击者能够控制51%的算力,那么他们就可以控制产生的区块,在区块中包括双重支付的交易,或者在包括双重支付的交易之后进行延续。但当时我们也分析了,首先,在比特币系统中,在拥有大量的矿工和矿池的情况下,任何人想要控制51%的算力基本是不可能的;其次,如果有人真正控制了51%的算力,那么他会选择遵守和维护这个秩序,从而避免自己的投资浪费掉。

那这个针对Verge的攻击是怎么回事呢?攻击者其实并没有真正掌握51%的算力。

控制51%的算力实际上就是要比别人更快地进行哈希,找出来满足区块产生难度的哈希值。那如果不能在算力上占优,有没有可能降低区块的难度?


在比特币系统中,产生区块的难度是可以动态调节的。难度值被设定在无论节点计算能力如何,新区块产生速率都保持在每10分钟一个。难度的调整是在每个完整节点中独立自动发生的。每2016个区块,所有节点都会按统一的公式自动调整难度。

在Verge系统中,也有类似的机制。Verge希望维持足够的去中心化,也即让个人计算机这样的小型设备能参与计算;但为了防止过快产生区块,Verge规定每隔
30 秒产生一个区块。为了实现这一点,Verge的挖矿难度是根据区块确认速率动态调整的。如果更多的人决定投入更多的算力产生 Verge
区块,那么挖矿速率会变快,Verge
区块链协议将增加挖矿难度,从而限制区块提交速率。相反,随着挖矿算力下降以及区块产生间隔增加,挖矿会变得更加容易。因此,当网络正常运行时,不管外界环境如何,Verge
网络都能够实时处理,并且引导网络达到目标区块产生速率的均衡。从设计目的而言,这个设计毫无疑问是非常人性化的,用户友好的。

Verge 用来计算密码学难题的共识算法是 Dark Gravity Wave,它对 30
分钟内滑动窗口的区块确认速率取加权平均值。这样的后果是,挖矿难度是最近区块产生速率的函数,而基于区块产生频率进行挖矿难度计算自然需要查看区块时间戳。

这里还涉及到一个问题:在区块链系统里,区块时间戳允许乱序。


在区块链协议中,单笔交易被分组打包到一个区块中,作为整体进行确认。每一个区块都有一个其创建日期的时间戳。即使区块链协议正常运行,在某些情况下这些时间戳也可能是乱序的,即,第
100 个区块的时间戳可能晚于第 101
个区块。这是因为,在去中心化系统中,进行时间同步确实是一件很难的事情。即便所有节点都是诚实的,区块的时间戳也绝对有可能出现“乱序”的情况。换句话说,在去中心化系统中,允许乱序才是正常的;在
Verge 被黑客攻击之前,它允许接收的区块时间戳“窗口”至多为2个小时。在Verge攻击之后,这个窗口被缩小到15分钟。

现在,如果有人创建出足够多的错误时间戳
,那么就会影响Verge的区块产生速率的判断,从而降低区块的产生难度。在黑客几次攻击的时间里,每隔一个区块的提交时间戳大约比区块加入区块链的时间早一个小时,这就使得协议的挖矿调整算法输出结果惨不忍睹了。如果协议能够流利地讲英语的话,它将会说:“Oh
no!Not enough blocks have been submitted recently!Mining must be too
difficult——let’s make it
easier!”(哦,不!最近提交的区块数量不够!挖矿算法一定是太难了——让我们调整的简单一些吧!)由于时间戳持续被篡改,协议持续降低挖矿难度,直到挖矿变得非常容易。总的来说,攻击前几个小时的平均难度是
1393093.39131,在攻击期间它的难度降低到 0.00024414,难度降低了超过
99.999999%。更低的挖款难度意味着能够提交更多的区块——在这种情况下,大约每秒产生一个区块。


如果仅仅是这样的话,攻击者并没有捞到什么好处,因为,如果系统调低了产生区块的难度,那么所有矿工的难度都降低了,攻击者还是需要和其他人进行竞争。这时,就需要利用Verge的另一个特点——Verge
使用了五种算法是 Scrypt,X17,Lyra2rev2,myrgroestl 以及 blake2s作为工作量证明的算法。(作为对比,比特币是SHA356)

Verge做出这个决定的出发点也是非常好的。因为,随着时间推移,比特币矿场变得过于专业化和中心化,例如,比特币大部分区块都是由 Bitcoin ASIC
矿机(这种矿机专门设计用于挖比特币)产生,并且许多比特币是由少数矿池挖出来的。Verge开发者认为,如果使用5种不同的算法,任何人想要同时控制5种算法,
使用5种专用硬件,难度应该会高于只用一种算法,从而促进 Verge 挖矿经济朝着更分布式、去中心化的方向发展。

这样,保证系统正常运行的方法是,每个算法都有自己的挖矿难度参数,并且独立于其余四个算法进行调整,这意味着,Scrypt
的挖矿难度将调整到每30秒产生一个区块,X17 及其他三种算法亦然。从而整个 Verge 网络才能保证每 30
秒产生一个区块、保持全部五种算法的收益对于矿工来说都是均衡的,并确保没有一种算法占优势地位。

这意味着伪造的时间戳并没有降低整个网络挖矿难度,而仅仅只是降低了五个算法中的 Scrypt的挖矿难度。因此,当 Scrypt
矿工的挖矿难度很低时,其他四种算法的矿工依旧得像之前一样努力工作,那么它们的哈希算力对于维护网络安全就没用了。更重要的是,攻击者仅需要使用 Scrypt
算法挖矿,并且仅需要与也使用 Srypt 挖矿的人竞争。因此,攻击者控制网络所需的哈希算力从当初的超过50%(在整个网络中占多数),下降到仅需超过10%(在
Scrypt 矿工中占多数),而Reddit 论坛上有人粗略地估计,这个数字甚至低至0.4%。

从中应该得到的教训是:当涉及用户金融资产时,应更倾向于事实证明更行之有效的方法,并防止事情变得过于复杂,从而带来不必要的风险。

<>二、 THE DAO攻击

在区块链技术领域,The DAO项目被攻击时,大家面对黑客利用漏洞源源不断从1.5亿美金的以太币池中拿走资金,毫无办法。

The DAO项目是区块链物联网公司Slock.it发起的一个众筹项目。THE DAO是一个分散自治的组织(Decentralized Autonomous
Organization),它的目标是编写一个组织的规则和决策机构,消除对文件和人员治理的需求,建立一个分散控制的结构。

它的工作流程如下:
1.技术人员编写在组织上运行的智能合约
2.在初始资金募集期,通过购买代表所有权的token代币来将资金注入到DAO项目中,也即ICO,为项目提供资金(THE
DAO众筹约1.5亿美元的资金,是当时最大的众筹项目;远超创建者预期)
3.ICO之后,DAO开始运作
4.创业者可以给DAO项目提出议案,拥有TOKEN的成员享有投票权,通过投票决定是否通过议案

项目运作特点:

通过智能合约来主导以太币资金的分发利用
参与众筹人按照出资金额(比特币等),获得相应DAO代币,即内部token,具有审查及投票表决权利
投资议案由全体代币持有人投票,每个代币一票
项目收益按照一定规则回馈代币持有人

The
DAO的智能合约中有一个splitDAO函数,splitDAO的本意是要保护投票中处于弱势地位的少数派防止他们被多数派通过投票的方式合法剥削。通过分裂出一个小规模的DAO,给予他们一个用脚投票的机制,同时仍然确保他们可以获取分裂前进行的对外资助产生的可能收益。

攻击者通过此函数中的漏洞重复利用自己的DAO资产来不断从TheDAO项目的资产池中分离DAO资产给自己。

在DAO.sol中,function splitDAO函数有这样一行:

然后,withdrawRewardFor的实现如下:

再看payOut函数调用。rewardAccount的类型是ManagedAccount,在ManagedAccount.sol中可以看到:

关于以上代码为何会导致攻击,我们来分析一下。


既然以太币是作为电子货币,那么自然会有交易,也即send()和receive()。以太坊上的编程语言solidity提供了三种方法实现send功能。分别是address.send,address.transfer和address.call.value。
Solidity 中
.transfer(),.send() 和 .gas().call.vale()() 都可以用于向某一地址发送 ether,他们的区别在于:


* .transfer()
当发送失败时会 throw; 回滚状态
只会传递 2300 Gas 供调用,防止重入(reentrancy)

* .send()
当发送失败时会返回 false 布尔值
只会传递 2300 Gas 供调用,防止重入(reentrancy)

* .gas().call.value()()
当发送失败时会返回 false 布尔值
传递所有可用 Gas 进行调用(可通过 gas(gas_value) 进行限制),不能有效防止重入(reentrancy)


如何理解防止重入呢?基本来说,攻击者要想实现攻击,需要让受害者执行自己的代码。而因为send和transfer都只能使用2300的gas,也即当执行完一小段代码之后,要么成功返回,要么gas耗尽,从而让攻击者所能做的操作相当有限。call.value不存在这个限制。接下来就是要将正常的操作流程导向攻击者的代码。

solidity还提供了一个特性:

<>回退函数 - fallback()

官方文档:

A contract can have exactly one unnamed function. This function cannot have
arguments and cannot return anything. It is executed on a call to the contract
if none of the other functions match the given function identifier (or if no
data was supplied at all).

一个合约可以具有一个匿名函数,该函数没有参数也没有返回值。当该合约被调用时找不到匹配的函数名,或者说被调用时没有提供参数,那么就调用fallback 函数。
pragma solidity ^0.4.0; contract SimpleFallback{ function(){ //fallback
function } }
另外,当使用address.send(ether to
send)向某个合约直接转帐时,由于这个行为没有发送任何数据,所以接收合约总是会调用fallback函数。在这种情况下,一定要定义fallback函数,并且fallback函数还必须使用Payable修饰,否则send会报错。正是因为如此,所以对send函数做了一定的限制,gas只能使用2300。这样,下述行为消耗的gas都将超过fallback函数限定的gas值:

* 向区块链中写数据
* 创建一个合约
* 调用一个external的函数
* 发送ether
所以一般,我们只能在fallback函数中进行一些日志操作: pragma solidity ^0.4.0; contract
FallbackFailOnGasLimit{ uint someStorage; event fallbackTrigged(bytes); function
() payable{ fallbackTrigged(msg.data); //将因为写入操作失败,注释掉下面这行,将会执行成功 someStorage =
1; } function callFallback() returns (bool){ return this.send(0); } }
但是对call.value没有2300
gas的限制。如果在调用过程中没有设置gas的值,那么会一直运行直到耗尽所有的gas。也即,如果想要执行恶意代码,多准备点gas就好。

接下来,我们看一个例子。
EtherStore.sol: contract EtherStore { uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime; mapping(address => uint256)
public balances; function depositFunds() public payable { balances[msg.sender]
+= msg.value; } function withdrawFunds (uint256 _weiToWithdraw) public { require
(balances[msg.sender] >= _weiToWithdraw); // limit the withdrawal require(
_weiToWithdraw<= withdrawalLimit); // limit the time allowed to withdraw require
(now >= lastWithdrawTime[msg.sender] + 1 weeks); require(msg.sender.call.value(
_weiToWithdraw)()); balances[msg.sender] -= _weiToWithdraw; lastWithdrawTime[msg
.sender] = now; } }
以上代码就是被攻击代码。它的功能是充当公共账户,类似于银行,接收用户存款(depositFund),提供用户取现功能(withdrawFunds)。


depositFund中,简单增加用户账户的余额,在用户取现的时候,首先做检查,确保用户账户的余额超出所要提取的金额,然后检查取现金额的最大值;接下来再对取现的时间进行限制,确保一周支取一次。然后将钱转给调用者账户,随后修改调用者msg.send的余额,并更新最新的取现时间。

这段合约的漏洞就在于:require(msg.sender.call.value(_weiToWithdraw)());


当被攻击代码执行到这一句的时候,它会调用msg.send代码中的fallback函数。如果是正常的fallback函数,譬如写日志,那没有任何问题;但是如果是攻击者精心准备的恶意代码,会有各种效果。

譬如,下面一段攻击代码:
Attack.sol: import "EtherStore.sol"; contract Attack { EtherStore public
etherStore; // intialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) { etherStore = EtherStore(
_etherStoreAddress); } function pwnEtherStore() public payable { // attack to
the nearest ether require(msg.value >= 1 ether); // send eth to the
depositFunds() function etherStore.depositFunds.value(1 ether)(); // start the
magic etherStore.withdrawFunds(1 ether); } function collectEther() public { msg.
sender.transfer(this.balance); } // fallback function - where the magic happens
function() payable { if (etherStore.balance > 1 ether) { etherStore.
withdrawFunds(1 ether); } } }
攻击者首先准备好 etherStore变量,并将作为被攻击代码的地址作为参数传入。

etherStore = EtherStore(_etherStoreAddress);


攻击者准备好pwnEtherStore(),这个函数首先向被攻击合约中存入一定以太币,假设是1以太币(因为EtherStore.sol要求取现是账户里有一定余额)。

etherStore.depositFunds.value(1 ether)()

然后进行取现,调用 etherStore.withdrawFunds(1 ether);

因为withdraw函数中使用了call.value(),所以会调用攻击者合约中的匿名函数,而在攻击者的匿名函数中,又再次调用

etherStore.withdrawFunds(1 ether)。


因为受害者的代码中withdrawFunds在call.value之后才修改攻击者的余额,而call.value触发fallback,再次执行withdraw,所以相当于攻击者的余额一直没有机会被修改。这样,只要攻击者的gas足够多,它会一直将公共账户也即Bank里的钱全部移走。

因为攻击者的代码使得受害者合约一遍遍地重新执行攻击代码,所以也叫“重入”攻击。

<>防御措施

有许多常用技术可以帮助避免智能合约中潜在的重入漏洞:

1.在将 Ether 发送给外部合约时使用内置的 transfer() 函数 。transfer转账功能只发送 2300 gas
不足以使目的地址/合约调用另一份合约(即重入发送合约)。
2.确保所有改变状态变量的逻辑发生在 Ether 被发送出合约(或任何外部调用)之前。在这个 EtherStore 例子中,EtherStore.sol
中对账户余额和账户时间的修改应该在发送以太币之前。将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为
检查效果交互(checks-effects-interactions) 模式。
3.引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用;这样在本次发送–修改余额这一整套操作完成之前,不能再次执行发送操作。

譬如修改代码如下:
contract EtherStore { // initialise the mutex bool reEntrancyMutex = false;
uint256 public withdrawalLimit= 1 ether; mapping(address => uint256) public
lastWithdrawTime; mapping(address => uint256) public balances; function
depositFunds() public payable { balances[msg.sender] += msg.value; } function
withdrawFunds(uint256 _weiToWithdraw) public { require(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw); // limit the withdrawal require
(_weiToWithdraw <= withdrawalLimit); // limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks); balances[msg.sender] -=
_weiToWithdraw; lastWithdrawTime[msg.sender] = now; // set the reEntrancy mutex
before the external call reEntrancyMutex = true; msg.sender.transfer(
_weiToWithdraw); // release the mutex after the external call reEntrancyMutex =
false; } }
使用reEntrancyMutex可以保证代码不可重入。类似于多线程的互斥锁。

友情链接
ioDraw流程图
API参考文档
OK工具箱
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:637538335
关注微信