以太坊网络上备受瞩目的游戏 Fomo3D(Fomo3D:Long)其第一轮在昨天(北京时间 8 月 22 日下午 3 点左右)结束了。最终,地址为 0xa169... 的玩家获得了 10469.66 Eth 的奖金,其取款交易被记录在了 6191962 区块中,该玩家在游戏中的总投入不到 0.8 Eth。那么,是不是这个玩家真的是靠运气“中了大奖”呢?当然不是,这是个有计划、有预谋的、精心设计的“技术性攻击”所取得的胜利结果。
本文将为大家讲解这次“技术性攻击”的原理、过程和攻击中的关键技术细节。本文的讲解假设读者了解以太坊、智能合约、矿工/矿池、交易打包确认以及 gas 等术语的基本概念。
从游戏设定看达成”攻击“所需的必要条件
Fomo3D 是近一个多月以太坊上最火爆的应用,也是个赌博游戏,本文的目的是做技术分析,所以这里只介绍其结束的设定:
- 游戏启动后从 24 小时开始倒计时;倒计时结束时,最后一个够买 key 的玩家将获得奖池中 48% 的奖金;
- 每有一个玩家购买 key,倒计时会增加 30 秒。
所以,获胜条件实际上很简单:在自己购买 key 之后到游戏倒计时结束,不再有其他人购买 key。在现实世界中,要做到这点不那么容易,除非所有玩家都没钱了;但在区块链的世界中,具体到以太坊上,是可以通过“技术手段”做到不让其他人购买的(也就是不让其他人的“购买交易”得到“网络确认”)。这就是大家耳熟能详的“拒绝服务攻击”(Denial of Service,DoS)。
攻击的原理
在目前成熟的 Web 服务技术里,制造 DoS 攻击一般是通过大量的并发请求和/或大数据量的独立请求,将 Web 服务的带宽/服务资源占满,而使其无法再相应正常的数据请求。在以太坊中,则可以通过制造大量的“垃圾合约调用”来达到同样的效果。
这里需要来讲一个机制了:交易池(transaction pool)。在矿工/矿池节点上,通常都会有一个交易池,网络上广播的所有新的交易都会被首先加入这个“池”,而后再由矿工/矿池选择那些“经济性更好”的交易优先打包确认。这里说的“经济性”,即由交易发送者在交易数据中指定的 gasPrice,gasPrice 越高,执行交易所附带的合约代码的执行费用也就越高,而这些费用通常是会作为手续费支付给矿工的。所以,矿工/矿池会从交易池中选取那些 gasPrice 明显高于其他交易的交易来优先打包执行(确认)。并且,矿工并不能从技术上判断一个交易中附带的程序代码是否是“垃圾合约调用”(它们也没有这个“责任”),它们仅仅选取那些执行费用更高的交易来优先执行。基于这个原理,就允许了攻击者通过调高包含了“垃圾合约调用”的交易的 gasPrice,来在短时间内用这些“无效交易”占用区块的可用 gas,以使其他“正常交易”无法被打包进区块。
这里还有几个基础知识需要科普一下:
- 以太坊中的区块可包含的交易(计算量)是由区块的 gasLimit 来控制的,而并不是像比特币那样用数据大小来限制;以太坊中目前区块的 gasLimit 上限是 800 万 gas,矿工可以做 5% 以内的上下浮动;区块内能包含多少交易,是看这些交易执行所消耗的总 gas 是否达到这个区块的 gasLimit;
- 以太坊中执行交易的费用,是用交易基础执行费用的 21000 gas,加上交易中附加的代码的字节大小的费用(这里有一个折算公式,不详细讲了)以及实际执行代码所消耗的 gas 的总和乘以交易中指定的 gasPrice 来计算的;这个交易费用,会从交易发送者账户中自动扣除;如果交易发送者账户余额不足,交易不会被打包进区块;
- 以太坊中的交易的实际执行所要消耗的 gas 是可以根据交易执行时的“世界状态”明确知道的,也就是这个交易的实际执行费用是明确知道的,矿工就是据此来判断打包交易的“经济性”。
在 Fomo3D 游戏的后期(即奖池金额已经很高),大多数玩家都会选择在倒计时的最后数分钟内才去购买 key,以让游戏能继续下去。这时,如果有一个机会,在攻击者自己购买了 key 之后(这只会给剩余时间增加 30 秒),能在其后数分钟内让网络不再确认其他人的购买交易,攻击者就可以让游戏结束从而赢得大奖。
这里还有一个需要科普的就是所谓“30 秒规则”。以太坊网络目前是基于 PoW 共识的,节点之间是通过“竞争”来决定记账权,这会导致区块链末端的“不稳定”,也就是会分叉。所以实际上某个交易会包含在哪个区块是可能在短时间内变化的,但基于过往的经验数据,如果合约中用区块的时间戳来判断,那么这个时间的精度大概会有 30 秒的误差,这就是所谓的“30 秒规则”。
因为 Fomo3D 合约中的结束时间是使用 now(也就是当前区块的时间戳)来判断的,所以如果要攻击的话,一定要多攻击 30 秒。比如攻击者在倒计时 2 分钟时购买 key,这会使倒计时增加 30 秒,然后基于 30 秒规则,就需要保证在之后的 3 分钟内没有其他玩家的交易被打包确认。实际的攻击也是这样进行的。
攻击的过程
下面我们就来根据区块链浏览器中可以查到的实际数据来看看这个攻击是如何发生的:
- 区块号 6191896:确认了一个由 0xa169… 到 Fomo3D:Long 的交易,调用了 buyXid 函数(即购买了若干 key);这个区块的时间戳是 06:48:22(UTC)。
- 区块号 6191897 到 6191902:攻击者开始使用“垃圾合约调用”来填充区块(大概占用了这几个区块中的一半左右的可用 gas),但没有刻意调高 gasPrice(使用的是平均水平,20 GWei 左右),这是个非常有耐心、也非常大胆的处理,在看到 6 个区块的时间内没有其他人购买 key 之后,攻击者知道机会来了!
- 区块号 6191903 到 6191908:从 6191903 开始,攻击者将“垃圾合约调用”交易的 gasPrice 提高到了 190 GWei,即平均水平的 8 倍以上,后续交易更是设置了 500 GWei 的超高 gasPrice,开始了真正的 DoS 攻击!直到 6191908 区块,这 6 个区块中只包含了不到 10 个简单的转账交易(即不包含合约执行的简单交易,固定消耗 21000 gas),其他可用 gas 完全被这些高 gasPrice 的“垃圾交易”占用。
- 区块号 6191909:网络状况恢复正常。这个区块的时间戳是 06:51:17(UTC)。在这个区块中,我们可以看到数个调用了Fomo3D:Long 的 buyXaddr 和 buyXid 的交易,但因为游戏合约内的时间戳判定条件已经达到游戏结束,所以这些购买当然就没有效果了。
值得一提的是,在区块 6191907 中,我们会看到一个 gasPrice 高达 5559.7 GWei 的调用 Fomo3D:Long 的 buyXaddr 函数的交易,但很可惜,这个交易的 gasLimit 设置过低(仅设置了 379000)导致发生了 out of gas(即交易触发的合约执行实际 gas 消耗超过交易的 gasLimit)的错误,而白白花费了 2.1 Eth 的手续费,却没有抢到最终大奖!这应该是某个大神在读秒阶段发现了攻击者的企图,但由于时间过于紧张,没有将 gasLimit 设置到合理范围(大概是手误少输入了一个 0)。是不是有点儿看黑客大片的即视感啊?
可以看到,攻击者的计划、准备周密,很有耐心,且技术处理上几乎无懈可击,完美地达成了必要的 DoS 攻击(短时间内阻止了其他玩家的交易被确认),从而“技术性获胜”。
攻击中的几个技术细节
首先,我们可以看到在上边提到的这十几个区块中包含了很多“失败”的交易,这些失败的交易有个共同的特点,都是由 Bad Instruction
导致的。这里的 Bad Instruction
也就是以太坊协议里预设的 EVM 操作码 0xfe(无效指令)。
这里再科普一个 Solidity 语言的技术细节:
Solidity 中有三个指令可以撤销本次合约执行中的所有状态修改并导致合约执行“异常停止”:require、revert 和 assert。根据 EVM 的指令设计,require 和 revert 实际上最终都是使用了 EVM 操作码 0xfd(停止执行,但会返还交易执行所剩余的 gas,也就是会返还一部分执行费用),它们实际上都是 revert,只不过 require 指令在执行 revert 之前做了一个条件检查;而 assert,则在条件满足时会使用 EVM 操作码 0xfe(无效指令,会消耗交易附带的所有可用 gas) 。
攻击者用来完成 DoS 攻击的合约源代码并不是公开的,但我们可以从实际的合约字节码中看到一些端倪(因为过于技术化,这里不再展开讨论)。
然后,从这些“垃圾交易”的整体设计上看,也是很有学问的。这些交易的 gasLimit 并不都是一样的,而是从十几万、几十万到几百万这样的离散值。这是因为在启动攻击的时候,网络状况仍然是正常状况,所以各大矿工/矿池可能已经有了打包了一半的区块,这时,当它们收到了新交易之后,除了判断经济性以外,还会判断其 gas 消耗能否在当前区块的剩余可用 gas 中包含。比如有些矿池打包的区块中已经只剩不到 50 万 gas,这时那些超过百万的大交易自然就不能包含进去;这样,如果没有适合的 gas 量的“垃圾交易”来填充,就有可能让其他玩家的正常购买交易填充进去。所以,从攻击的角度讲,这些 gasLimit 比较小的“垃圾交易”同样是非常重要也是非常必要的!我们不得不佩服攻击者思路的缜密。
最后,要完成这样精确的攻击,攻击者需要很多技术准备。
他们需要若干能连接到前五乃至前十矿池(或者能连接到与这些矿池节点在“网络上”非常接近的全节点),这一点非常重要。因为要实施这样的攻击,你必须具备能实时获知各大矿池节点最新区块数据的能力,以便在发起最终的 DoS 攻击之前能确定没有其他人的正常购买交易被打包!也就是刚刚提到的 6191897 到 6191902 区块的等待期,在越多的大矿池节点数据中得到确认,攻击成功的几率越高。
然后,在发起攻击的时候,一定要在短时间内将用来攻击的数十个“垃圾交易”同时发送到前五乃至前十矿池,让他们把这些交易加入“交易池”;以最大限度地避免因为网络延迟导致其他玩家的购买交易被某个大矿池先打包的情况;这同样对攻击的完成至关重要!
以上这两点,需要攻击者同时拥有数个可以联动的定制化的客户端,并且有相应的程序进行监控(检查区块数据)并发起实际攻击(连续发送数十个预设的交易),这大概不是通过单个客户端或者简单地用几个脚本就可以做到的。
小结
从 Fomo3D:Long 第一轮游戏的结束来看,虽然我们可以搞懂整个过程以及其中的技术细节,但能不能先于别人实施、考虑到尽可能多的细节、尽量提高成功的概率就是个纯粹的技术活儿了;也需要大量的时间和精力以及资金支持。不过这个例子也给了我们更大的动力去研究技术、去学习细节,只有掌握了足够多的细节才能做到一击必中!不是么?
攻击者在这次攻击中的总投入成本当然不是开头说的在合约上花的那点儿钱,这些“垃圾交易”的执行费用是非常高的,包括攻击者先前在主网上做的各种试水,总成本粗略估计在 40 Eth 以上。貌似也不是我等屌丝能负担的啊……。