文章目录
简介
以太坊智能合约在执行时,交易发送者需要支付一定量的手续费(这个手续费的被称为燃料(gas))。本文详细研究了以太坊交易的计费规则,发现计费规则中存在一些不合理的地方,比如:燃料的的消耗与合约执行所需的CPU、内存消耗几乎没有关系。作者经过进一步研究发现,这是由于燃料主要是被存储资源消耗掉了(存储也需要收费)。基于此发现,本文提出了一种针对以太坊的资源耗尽型DoS攻击,该攻击可以生成一种平均吞吐量比典型智能合约慢50倍的攻击合约,从而可以让节点忙于执行合约验证而无法进行其它工作。
背景知识
以太坊虚拟机(EVM)及燃料(gas)费
以太坊的智能合约在EVM中运行,用户可以通过solidity语言创建和执行智能合约。和比特币一样,以太坊网络中所有的节点也会在本地备份所有数据。以太坊的EVM中可以运行图灵完备的代码,而且任意用户都可以发布合约。为了防止恶意用户编写一些无法结束的合约来让以太坊一直做无效的工作,以太坊在设计时就引入了运行费用,也就是合约运行是有耗费的,这个就是燃料(gas)。$Gas$以$wei$为单位,$1wei = 10^{-9}ETH$(ETH为以太币)。
用户使用solidity语言编写智能合约后,会被编译成EVM可执行的字节码。这些字节码由许多指令构成,每个指令的执行都需要消耗一定的燃料。以太坊为各个指令都定义了一定的燃料费,具体大家可以参考以太坊的黄皮书[2]。用户可以对于每次执行的合约操作指定一个消耗燃料的上限,一旦执行合约时消耗的燃料超过了这个限制,那么合约就终止运行。显然,在此限制下,用户无法通过编写一个无限循环来一直占用EVM的计算资源。
各指令燃料消耗分析
此部分测量了一些指令在以太坊的区块链客户端aleth(c++)执行所消耗的燃料的情况。
操作码平均执行时间及燃料消耗
上图展示了简单的加法(ADD)、乘法(MUL)、除法(DIV)以及指数(EXP)运算的平均执行时间。下表给出了各指令消耗的燃料以及单位时间($1\mu s$)的吞吐量。吞吐量定义为$\cfrac{燃料消耗}{平均执行时间}$,比如下面的ADD指令,$吞吐量=\cfrac{3wei}{0.001\mu s} = 36.5$。
从上表结果可以看出:ADD操作和MUL操作指令的执行时间差不多,但是MUL操作的燃料成本比ADD操作高了$65$%以上;MUL操作和DIV操作消耗相同的GAS,但DIV操作平均比MUL操作慢大约5倍;EXP操作平均消耗的燃料比DIV操作多10倍,但是执行时间比DIV操作还要快。实验结果还表明,DIV操作的平均执行时间具有比其它操作高得多的标准偏差。
一般我们会以为燃料的消耗可以反应其执行时间,也就是执行时间越长消耗燃料越多,但实际实验结果表明并不是这样的。
燃料消耗与资源消耗之间的关系
为了探究燃料费与实际硬件资源消耗之间的关系,作者进行了进一步的实验,计算出了指令的硬件资源消耗(CPU/内存/存储)与燃料费之间的相关性。相关性使用Pearson相关系数来衡量。
EIP 150 对于存储资源相关操作指令的燃料价格影响非常大。
文中对比实验了$EIP_{150}$实现前后版本客户端的情况(EIP为以太坊改进建议),具体结果如下表所示。
实验数据:
EIP-150之前:区块高度为1,400,000〜1,500,000间的所有区块上的交易EIP-150之后:区块高度为2,500,000〜2,600,000间的所有区块上的交易
$EIP_{150}$实现前:
$EIP_{150}$实现后:
比较$EIP_{150}$之前和之后的Pearson系数可以看出,存储资源的消耗与燃料消耗的相关性在三种资源中最高,在$EIP_{150}$之后,该得分为0.907。这表明合约执行的燃气成本受存储的影响很大。存储和内存的组合产生了最高的相关分数0.938,可以确认存储的使用与GAS强相关。
EIP-150更新之后,CPU消耗的相关系数进一步降低,看起来CPU和燃料消耗基本无关。从此发现来看,貌似可以通过很低的燃料费来构建一个非常消耗CPU资源的合约。
I/O相关操作的执行时间
实验对于不同的存储相关的操作指令执行时间进行了统计,下表显示了执行时间的标准偏差较高的操作指令。在执行同一命令时,执行时间可能会有所不同,这有两个原因:
- 参数对执行特定命令所花费的时间有影响。例如:EXCODECOPY是复制帐户的字节码并在内存中存储用户指定数量的字节的操作。因此,执行时间可能会根据用户指定的字节数而有所不同
- 指令执行输入/输出操作要花费很多时间。例如:BLOCKHASH是用于搜索块哈希的命令,并且最多可以搜索256个块。当EVM执行指令时,可能需要在存储中进行输入/输出操作,该指令的时间消耗取决于高速缓存的实现和状态,因此,BLOCKHASH操作的执行时间可能会大大不同
缓存对操作执行速度的影响
考虑到某些指令的执行时间差异很大,研究者进行了关于缓存是否影响EVM执行速度的实验,实验中的合约平均每个大约消耗800,000燃料。上图显示了多个合约在使用缓存时速度提高倍数的分布情况。使用操作系统的页面缓存,可以让智能合约的执行速度提高24倍到33倍,并且超过一半的合同运行速度可以提高27到29倍。
使用高速缓存的速度差异是由于Level DB的特性所致。以太坊使用Level DB做数据存储,但是Level DB仅将部分数据保留在内存中,不在内存中的数据访问就需要访问磁盘。如果所需的数据已经在页面缓存中,则使用缓存可以直接访问而无需慢速的磁盘的访问,从而提高了速度。
有一点需要注意的是,如果合约足够小,所有的数据都会存储在内存中,那么页面缓存存在与否对于运行速度就影响不大。作者使用了平均消耗100,000燃料的合同进行相同的实验,发现缓存速度的提高只有大约两倍。
缓存失效及其影响
本实验评估了缓存块执行时间的影响。为了让缓存失效,实验中包装了多个合约区块,并且连续执行$n$个不同合约的区块,持续增大$n$以确保最初的合约的页面缓存已经失效,这可以从合约的运行时间直接看出。每$n$个区块会执行10次,第一次执行时,系统内显然没有缓存,执行时间都较长。之后,如果系统中如果存在缓存,那么合约执行时间将大大减少。部分实验结果如下图所示。
图中展示了显示了块数为$n=14,15, 16$的结果。横坐标为$m$即执行到第几次,纵坐标为该次$n$个合约块的总执行时间。
我们可以看到第一次执行时,执行14个块大约需要70秒钟,而第三次执行仅花费了6秒钟。$n=15$的运行时间比$n=14$时花费的时间更长,执行时长的降低较为平缓,经过6次运行后时间才稳定下来。当$n=16$时,执行时间不会减少,而是保持在85秒。也就是说,执行了16个以上的块足以将最初区块执行的操作系统缓存换出,此时就没有了加速效果。
实验总结
- 即使是简单的指令(例如,相对可预测的算术运算等)燃料成本与执行时间也无正相关关系
- 大多数指令的执行速度在不同情况下时间都差不多,但是如果涉及到输入/输出操作,则指令的执行速度可能会有很大差异
- 当使用页面缓存时,智能合约的执行速度增加很多
- 可以看出,指令执行的燃料定价存在问题,EVM给出的度量指标没有考虑到输入/输出操作的复杂性
现有以太坊DoS攻击简介
EXTCODESIZE攻击
16年,以太坊遭到了DoS攻击,攻击者向以太坊区块链网络发送了大量包含EXTCODESIZE指令的交易。EXTCODESIZE攻击是一种利用具有较低GAS费用的EXTCODESIZE操作码进行攻击的技术。EXTCODESIZE操作码的作用是返回代码的大小,在$EIP{150}$更新之前,仅需消耗$20$个燃料即可执行,调用1,500次也仅需不到0.01美元。EXTCODESIZE操作码执行时,客户端会从磁盘检索合约并生成具有大量IO的事务。而执行事务大约需要20到80秒(在一般事务的情况下,仅需ms单位时间),这导致以太坊区块的生成速率降低了2倍以上。攻击者可以通过大量调用EXTCODESIZE来执行攻击,从而以很小的代价降低了块生成速度。此后,以太坊意识到了这个问题的严重性,并将$EIP_{150}$中的EXTCODESIZE操作的燃料消耗从20增加到700,并对代码进行了更新,极大的增加了攻击的代价,以此避免以太坊再次受到此类攻击。
自杀式攻击
EXTCODESIZE攻击出现后不久,又出现了使用SUICIDE操作码进行的攻击。SUICIDE命令作用是终止合约并将剩余的以太币发送到另一个地址上。此时,如果没有地址,则会创建一个新地址。当年在攻击发生的时候,这时调用SUICIDE的燃料费为0,也就是无需花费任何费用。因此攻击者可以以低廉的费用,向以太坊网络发送一笔交易用于创建和销毁合同。大量此类交易很快就爆了各节点的内存,此攻击对于Go语言实现的客户端影响尤为明显(go内存优化不咋地)。后来,在$EIP_{150}$中,如果SUICIDE将以太币发送到不存在的地址,也将收取常规的燃料费,并且SUICIDE交易的费用也从$0$被增加到了$5,000$。
详细内容
此小节在前文实验的基础上介绍了一种新的攻击技术,称为资源耗尽攻击。此攻击通过创建一个与正常合约相比,燃料消耗少、资源需求极高攻击合约,而让节点在处理该合约时无法与网络维持数据同步。
问题定义
本文提出的资源耗尽攻击旨在寻找一个智能合约满足:
- 执行时间满足目标需求(足够长,达到$t_{target}$)
- 最优化单位时间内的燃料消耗
我们用符号$\mathbb{I}$表示EVM的指令码,$P$表示智能合约的集合。定义一个智能合约为一连串的指令码序列$I_1,\ldots, I_n, (\forall I_i \in \mathbb{I})$,定义函数$t:P\to \mathbb{R}$以一个智能合约为输入并返回合约的执行时间,定义函数$g:P\to \mathbb{N}$以智能合约为输入并返回合约运行消耗的燃料量。定义我们的目标函数:
我们的目标是找到一个合约$p_{slowest} \in P$满足:
也就是,我们希望合约执行时间长,并且消耗燃料少。对于一个包含$n$条指令的合约$p$来说,搜索空间大小为$|\mathbb{I}|^n$,显然这么大的搜索空间无法通过暴力搜索来解决。本文使用了著名的遗传算法(GA)来解决此搜索问题。
攻击合约构造算法
首先需要注意的一点是攻击合约的目的是为了消耗资源,它并不需要有实际意义,但是需要能在EVM中运行。因此,需要创建一个合约满足:
- 程序中的指令不会去访问指令码运行堆栈意外的数据
- 不去执行会消耗大量燃料的指令
- 避免程序中包含死循环(文中不考虑使用JUMP/JUMPI等跳转命令)
由于文章中使用遗传算法来解决最优化问题,其中包含几个重要的步骤:
- 种群生成
- 交叉
- 后代变异
合约生成算法(种群生成)
为了达到一个很好的效果,初始合约就很重要,初始合约生成算法如下图所示:
上图中的算法会返回一个长度大小差不多为$size$的合约,几个函数作用为:
- $biased\_sample$:从一个指令集$\mathbb{I}_s$中根据指令权重采样得到一个指令,指令集$\mathbb{I}_s$表示所有最多消耗运行栈上$s$个参数的指令的集合
- $uses\_memory$:返回0或1,表示输入指令是否会通过某种方式访问运行栈
- $prepare\_stack$:为需要访问运行栈的指令准备数据,数据少了就PUSH,需要弹出数据的就POP
- $r(I)$:返回指令会在栈上返回多少个数据
- $a(I)$:返回指令会消耗栈上多少个数据
$biased\_sample$是一个概率采样算法,每个指令会根据我们前文实验中得到的每个指令的吞吐量来决定选取的概率。显然,指令的吞吐量越大,表示,该指令消耗的gas越多,我们选择它的概率越小。概率计算公式如下:
首先使用$W$更具每个指令的吞吐量来计算每个指令的权重,然后选出一些初始指令集$\mathbb{I}_n$,并根据$P$计算每个指令被选中的概率。最后$biased\_sample$就根据这个概率对指令集中的指令进行采样。
交叉算法
合约的交叉算法非常直观,只需要在两个需要交叉的合约中分别选择一个截断点,然后根据这个截断点重组两个合约即可。由于指令的执行依赖于堆栈,随意选择交叉点可以导致后代合约无法执行,因此需要选择一个合适的交叉点,具体交叉算法如下:
算法中$CreateStacksSizeMapping$的目的就是为了将指令根据其消耗的参数数量进行分类,这样在两个合约中只需要选择一个消耗同样参数的两个指令所在位置为分割交叉点即可。
后代变异
合约的变异方法为:随机选择一个指令替换为一个在栈上消耗同样数量且产生同样数量参数的指令。
样本质量评估(GA中称作适应度评估)
适应度评估也就是计算上文我们定义的$f(p)$的值,实验中作者搭建了实际的以太坊环境来运行生成的合约,从而得到评估值。
实验结果
下图给出了一个攻击合约的部分字节码:
其中加黑的部分为存储相关的IO操作码。我们可以看到有许多与I / O相关的命令,尤其是BLOCKHASH和BALANCE命令。这表明虽然在EIP-150中,BALANCE的费用已从20修改为400,但仍设置的仍然不够高。
上面展示了使用遗传算法运行了$n$次迭代后智能合约的吞吐量。蓝线代表测量值的平均值,浅蓝色宽度代表标准偏差(通过运行3次进行测量),可以看出标准差很小,说明产生的合约运行稳定。一代合约(初始合约)消耗燃料大约为$1.25M$,这相比于以太坊智能合约的平均值$20M$来说已经很小了,说明样本生成算法很好。从图中可以看出,在最初的几代中,吞吐量下降非常快,吞吐量迅速下降至110K后变得平稳(在200代以后)。110K相比以太坊正常交易的平均吞吐量来说,要慢180倍以上。
实验的最优结果在遗传算法迭代了243代后得到,此时一整个攻击区块大概需要耗费$9.9M$燃料,并且需要耗费93秒执行时间(此时吞吐量为107k)。下图给出了在此得到的最优后代合约的情况下,执行时消耗的燃料数量与执行时间的关系:
可以看出执行时间几乎和燃料的消耗量呈线性关系,这表示生成的所有攻击合约交易的燃料消耗量都差不多,此时,攻击者就可以轻易的控制受害者被攻击的时长了。由于以太坊区块大约每13秒创建一次,因此如果接收到包含此类恶意交易的区块,那么在受害者被攻击的时间内(93秒一直在执行攻击合约),攻击者可以重新创建约7个区块,直到受害者完成验证该区块的工作为止。
多种以太坊客户端对比
在本文中,对各种以太坊客户端进行了实验。下表展示了各个客户端的实验结果,可以看出攻击在最常用的geth(1.9.6v)和parity(2.5.9v)中是有效的。虽然本文提出的这种攻击在低性能的硬件上效果非常显著,本文还使用Parity客户端在高性能的机器上跑了一个实验(结果为下表中的Parity(metal)),高性能PC配置为:4core 2.7GHZ CPU,32GB RAM和540MB / s SSD的裸机。每个客户端都进行三次测量,实验步骤如下:
- 同步要测试的客户端
- 在私有网络中开启客户端,以防止客户端在实验中执行所创建攻击合约以外的任何内容
- 使用eth-call RPC让客户端上执行事务(一个transaction/合约调用)。
a)发送大量交易给客户端预热(防止客户端缓存有之前执行的内容)b)发送大概消耗$10M$燃料量的恶意交易供客户端运行4. 统计恶意交易时使用的燃料,时间,IO,CPU和内存
上图展示了实验结果。虽然实验中对于CPU,内存和IO的使用情况都测量了,但大多数的时间消耗与I / O操作有关,而且在攻击过程中CPU或内存的使用并没有显着增加。因此,上表中仅比较了IO的对比测量值。
Geth在修改之前,平均需要75秒钟来执行消耗10M燃料的恶意交易。Parity最不容易受到恶意攻击,平均需要47秒。与其他客户端相比,Aleth客户是运行最慢的。这是因为本文提出的算法针对Aleth进行了优化,而且Aleth本身存在许多漏洞。即是运行在高性能机器上的Parity客户端平均处理速度也很慢,大约为18秒,这表明本文提出的攻击技术即使在具有良好性能的硬件中也有效。
本问的漏洞提交后,Geth对此进行了修复,修复后的结果为上表中的Geth(fixed)。Geth优化了存储访问速度并且此减少了客户端I / O的负载从而提高了事务吞吐量,解决了此漏洞。
解决方案
为了防止这种攻击,本文提出了以下解决方案。
临时方案:
- 增加IO相关指令的燃料成本
- 改善以太坊客户端的性能,比如:使用布隆过滤器提高IO访问速度
长期解决方案:
- 对于一些消耗gas低,但是执行时间极长的交易(也就是表现异常的交易)进行惩罚
- 改变客户端的运行方式,比如让交易发送时自带交易所需数据的证明(类似比特币的默克尔树的证明机制),这样就无需进行IO操作了
结束语
本文通过一些实验表明:"以太坊合约执行的燃料成本是根据执行时间来确定的"这个直觉是错误的,同时实验还揭示了以太坊中存在的诸如缓存依赖之类的设计问题。以这些实验结果为基础,作者介绍了一种针对以太坊的资源耗尽型DoS攻击方法。此攻击使用遗传算法来创建合约,最终得到的攻击合约吞吐量非常低(比正常吞吐量低约100倍),执行时间非常长,这可以导致矿工执行该区块就需要很长时间。
本文的作者与以太坊基金会进行了沟通,告知他们存在此类问题,并表示他们从以太坊基金会获得了5,000美元的漏洞赏金。
参考文献
[1] Perez, Daniel, and Benjamin Livshits. "Broken metre: Attacking resource metering in EVM." arXiv preprint arXiv:1909.07220 (2019).
[2] https://ethereum.github.io/yellowpaper/paper.pdf
More Recommendations