类Fomo3D游戏漏洞与修复机制全解析

摘要:
无论是 Fomo3D 山寨版还是正宗原版都摆脱不了“一轮就凉凉”的宿命,这与其智能合约的设计漏洞不无关系。本文从合约安全开发的角度出发,详细分析了类 Fomo3D 游戏的两个问题,并提出若干个可能的解决方案。希望能有所帮助,欢迎感兴趣的朋友加入技术社区讨论(wx添加小安同学:secbit_xiaoanbi,加入“SECBIT智能合约安全技术讨论”社群)。

黑客攻击之下,类 Fomo3D 游戏一蹶不振

Fomo3D 游戏已正式进入第三轮。截止北京时间 9 月 29 日上午 11 点整,本轮奖池仅累积了 97.8988 Ether,外加上一轮滚入的 680 Ether,奖池总金额不足 800 Ether,相较前两轮的盛况,可谓惨不忍睹。

安比(SECBIT)实验室曾经撰文分析了类 Fomo3D 游戏的衰败现状,先来简单回顾一下 [1]。
在这里插入图片描述
图一:Fomo3D 玩家参与度与入场资金状况

上图展示了「Fomo3D 玩家参与度与入场资金状况」。红色代表调用合约参与游戏的人次,蓝色则代表进入游戏合约的资金量。图左侧出现数据曲线最高峰,对应时间分别是 7 月 20 日和 7 月 21 日。这两天恰好大量媒体疯狂报道 Fomo3D 这一现象级游戏。当时众多玩家跟风入场,游戏合约的参与次数和入场资金均达到了最高峰,入场资金量超过 40,000 Ether,而参与次数最高超过 18,000 次。高峰过后,Fomo3D 游戏热度骤降,于 8 月 22 日前后结束第一轮,并随即进入第二轮,但游戏热度已然无法恢复。

尽管如此,黑客却没有停止攻击。
在这里插入图片描述
图二:Fomo3D 游戏合约被攻击状况

上图是「Fomo3D 游戏合约被攻击状况」,第一轮游戏高峰前后以及第二轮开始后,有黑客疯狂地利用“空投漏洞”进行攻击,攫取高额收益 [2]。而在第一轮临近结束,以及第二轮倒计时快结束之际,则有黑客疯狂尝试“阻塞交易”攻击,企图夺取最终大奖 [3]。

不仅仅是 Fomo3D 原版游戏,其他众多的类 Fomo3D 山寨游戏,也成为黑客的攻击目标。
在这里插入图片描述
Fomo3D 类游戏参与形式是用 Ether 购买游戏道具,最后一位购买者获得“最终大奖”,平时参与者有一定概率获得“空投奖励”,分别从主奖池和副奖池中获取。这两类奖励是游戏设计层面对参与者的重要激励。这一设计,目的在于利用“随机”和“竞争”提升游戏趣味度,吸引更多人投入资金参与,从而延长游戏时间。

然而事与愿违,由于合约代码存在漏洞,掌握攻击技巧的黑客能够以很高的概率持续获得“空投奖励”,而“最终大奖”也会被黑客利用特殊技巧夺走。普通参与者在这类游戏中几乎无法获得这两种重要奖励。因此,他们仅能幻想在每轮游戏开始后第一时间入场,然后靠后续他人的资金回本。但是,游戏最重要的两个激励机制已然失效,无法持续吸引新资金,最终形成恶性循环。

黑客是如何利用这两个漏洞的?项目方难道就无计可施吗?

空投漏洞分析

先看看“空投奖励”。

所有投入游戏的 Ether,会有 1% 数量进到副奖池。空投的概率从 0% 开始,每增加一笔不小于 0.1 ETH 销售订单,空投概率会增加 0.1%。同时空投奖励金额与购买金额也挂钩,如果购买 0.1 ~ 1 ETH,就有概率赢得 25% 副奖池奖金,购买越多则比例越大。游戏界面会鲜明显示当前中奖概率和奖池金额。

Fomo3D 空投奖励实现存在两处问题:

  1. 合约中的“随机数”可被预测

  2. 判断调用者是否是合约地址的方法有漏洞

空投奖励依靠智能合约内生成的“随机数”,在 Fomo3D 源码中由 airdrop() 函数控制。

/**
    * @dev generates a random number between 0-99 and checks to see if thats
    * resulted in an airdrop win
    * @return do we have a winner?
    */
function airdrop()
    private 
    view 
    returns(bool)
{
    uint256 seed = uint256(keccak256(abi.encodePacked(
        
        (block.timestamp).add
        (block.difficulty).add
        ((uint256(keccak256(abi.encodePacked(block.coinbase)))) / (now)).add
        (block.gaslimit).add
        ((uint256(keccak256(abi.encodePacked(msg.sender)))) / (now)).add
        (block.number)
        
    )));
    if((seed - ((seed / 1000) * 1000)) < airDropTracker_)
        return(true);
    else
        return(false);
}

airdrop() 函数中的“随机数” seed 由各种区块信息和交易发起者地址计算得来。这显然十分容易预测 [4]。

为了防止合约自动化攻击,Fomo3D 开发者还使用 isHuman() 来防止合约账户参与 Fomo3D 游戏,试图以此方法来禁止玩家在合约内预测中奖随机数。

/**
    * @dev prevents contracts from interacting with fomo3d 
    */
modifier isHuman() {
    address _addr = msg.sender;
    uint256 _codeLength;
    
    assembly {_codeLength := extcodesize(_addr)}
    require(_codeLength == 0, "sorry humans only");
    _;
}

这里犯了另一个常见错误。extcodesize 操作符用来获取目标地址上的代码大小。对于已部署成功的合约,由于其地址对应着特定代码,extcodesize 的返回值始终大于 0。因此不少人用此方法来判断目标地址是否是合约,Fomo3D 甚至以此为依据来阻止合约调用特定函数。但该判断方法存在明显漏洞,在构造新合约的过程中(即合约构造方法里)调用游戏参与函数即可绕过该限制。这是因为合约在构造过程中,其地址并未对应任何代码,extcodesize 的返回值为 0 [5]。

上述的两个安全问题综合作用,最终导致黑客可以构造攻击合约,通过合约参与游戏,随意预测随机数,进而极大提高自己的胜率 [2]。

如何修复空投漏洞

那么究竟如何解决 Fomo3D 的“空投漏洞”?

黑客能够成功攻击,是利用了上文列出的两个漏洞,构造攻击合约来预测游戏合约中的“随机数”。因此,我们只需完成以下两件事之一,使攻击所需的必要条件不满足即可:

  1. 防止智能合约中的“随机数”预测

  2. 采取更安全的方式判断调用者是否是合约

方案一:防范智能合约中的“随机数”预测

让我们先解决“随机数”预测的问题。

智能合约环境内“随机数”容易被预测的原因在于,“随机数”产生所依赖的“随机源”可以被任何人轻易获得。攻击者可以构造一个攻击合约,在相同环境内执行“随机数”计算公式,即可得到需要的“随机数”,并以之作为下一步行动的判断依据。

智能合约内几乎一切可用变量都是公开的,并且“随机数”计算公式需要确保所有节点执行结果都一致。因此,很难找到十分简洁的方法来产生无法被预测的“随机数”。

但仍有一些稍复杂但可行的解决方案。如开发者可通过先提交再披露(commit/reveal)、或延迟若干个区块开奖。此外,还有一些引入外部预言机(Oracle)的方案,如 Oraclize 和 BTCRelay [6]。

安比(SECBIT)实验室结合 Fomo3D 游戏机制,介绍一种利用“当前/未来”区块的哈希值来防止“随机数”被预测的方案 [7]。

以太坊智能合约中可以通过 block.blockhash() 来获取特定区块的哈希值。该函数接受参数为区块高度,可取范围为除当前区块外的最近 256 个区块。当传入其他值时,该函数均返回 0。
在这里插入图片描述
常见不安全的“随机数”计算方法,会读取当前块的前一个块的哈希 block.blockhash(block.number-1) 作为随机源。而在合约内执行 block.blockhash(block.number) 返回值为 0。我们无法在合约内获得当前区块的哈希,这是因为矿工打包并执行交易时,当前区块哈希尚未被算出。因此,我们可以认为“当前区块”哈希是“未来”的,无法预测。

我们可以在用户首次购买道具参与游戏时,记录其地址、当前区块高度 N 至一个数组中,最终拿到一个唯一的 id(如下面 _purchase() 函数所示)。

function _purchase(address user) internal {
    Purchase memory p = Purchase({
        user: user,
        commit: uint64(block.number),
        randomness: 0
    });

    uint id = purchases.push(p) - 1;

    emit KeysPurchased(id, user, packCount);
}

在接下来的 255 个区块内,用户可以用该 id 再次参与游戏,此时高度为 N 的区块哈希可正常获得,以此来生成“随机数”,判断用户是否中奖(如下面 _airdrop() 函数所示)。

function _airdrop(uint id) internal returns(bool) {
    Purchase storage p = purchases[id];

    require(p.randomness == 0);
    require(block.number - 256 < p.commit);
    require(uint64(block.number) != p.commit);
    require(p.user == msg.sender);

    bytes32 bhash = blockhash(p.commit);
    uint seed = uint(keccak256(abi.encodePacked(bhash, p.user, id)));
    p.randomness = seed;

    if((seed - ((seed / 1000) * 1000)) < airDropTracker_)
        return(true);
    else
        return(false);
}

255 个区块之后,用户参与游戏时的区块哈希在合约内无法正常获得。因此,务必要限制用户在一定时间范围内查询是否中奖,并及时参与游戏领取奖励。当然,为了游戏体验,如果用户错失领奖,也可以参照上面的原理再给他一次机会重新抽奖。结合游戏规则,这里仍有一些技术细节需注意,欢迎添加小安同学微信(secbit_xiaoanbi),加入到「SECBIT 智能合约安全技术群」参与讨论。

这种方法也用在知名的区块链卡牌游戏 Gods Unchained 中,用来控制用户所购卡牌稀有程度。当然我们也可以用当前高度后指定数量(如五个)的区块哈希来作为随机源,原理是一样的 [8]。

方案二:防止合约自动化攻击

另一个问题,我们如何判断调用者是否是合约地址?

有一个简便但是有效的方法。

modifier isHuman() {
    require(tx.origin == msg.sender, "sorry humans only");
    _;
}

以太坊安全开发最佳实践中推荐尽量不要使用 tx.origin,因为很多人将 tx.orignmsg.sender 混淆。tx.orign 代表的是一笔交易的发起者,而 msg.sender 代表每一次合约调用(call)的发起者。

A -> B -> C

如普通账户 A 调用合约 B,合约 B 再调用合约 C。在合约 C 内,msg.sender 是合约 B,而 tx.origin 是账户 A。msg.sender 可以是合约地址,但 tx.origin 永远不会是合约。因此,上面的方法可以有效防止合约调用合约。

“阻塞交易”攻击分析

再看看“最终大奖”。

Fomo3D 类游戏存在倒计时,在每轮游戏结束前最后一个购买道具的参与者获胜,可以拿走主奖池中近半的资金。因此众多参与者会在临近结束时,发起购买交易参与游戏,如果能幸运地在最后一刻被矿工打包入块,即可获胜。

普通人在游戏快结束时都是类似的策略:紧盯着时间,调高 Gas 费用,发起参与游戏的交易,然后闭上眼睛祈祷,希望自己能是最后一个参与者。然而,采用这种方法几乎不可能中奖。

据安比(SECBIT)实验室分析,Fomo3D 前两轮获奖者使用手法如出一辙,均在游戏快结束时,发起攻击交易。

获奖者(黑客)通过提前部署好的攻击合约,在合约内调用 getCurrentRoundInfo() 接口查询游戏信息,重点关注剩余时间和最后一位购买者地址。当游戏剩余时间达到一个阈值,并且最后一个购买者是自己时,则通过 assert() 让整个交易失败,并耗光所有 Gas;当剩余时间很长或最后一个购买者不是自己时,则不做任何操作,仅消耗很少的 Gas。
在这里插入图片描述
获奖者(黑客)就是利用这种方法,发起大量类似的可变神秘交易:在自己极有可能成为中奖者时,利用这些高额手续费的神秘交易,吸引矿池优先打包,占满后续区块,从而使得其他玩家购买 key 的交易无法被正常打包,最终加速游戏结束,并极大地提高自己的中奖概率。

普通玩家只能在游戏快结束时手动调高 Gas 费用参与游戏,也有人试图使用自动脚本在临近游戏结束时调高 Gas Price 发起参与游戏交易。与这些盲目的方法相比,黑客的攻击手法显然高明许多。

如何防范“阻塞交易”攻击

其实,这一问题不止会威胁类 Fomo3D 游戏。所有采用类似机制,即需要玩家抢在某个时间范围内完成某种竞争操作的智能合约,都会受此威胁。只要游戏奖励足够丰厚,攻击回报远大于投入,就会有人利用前文提到的方法来破坏游戏公平性。

方案一:提高攻击所需成本

要杜绝这一问题,安比(SECBIT)实验室建议游戏开发者,从游戏机制入手,切断游戏最终胜利(获得某个巨额大奖)和倒计时结束(最后一个交易被打包)之间的必然联系,从而使黑客的攻击获利概率和攻击意愿都降到最低。(wx添加小安同学:secbit_xiaoan-bi,加入“SECBIT智能合约安全技术讨论”社群)。

例如,我们可以修改游戏规则为:每轮游戏结束前最后一个购买道具的参与者有概率获得最终大奖,并将此概率调整为一个较低的值,如 5 %。在倒计时结束但大奖因概率原因没有正常开出的情况下,合约自动给游戏续一定时间。这样一来,前面提到的堵塞区块、阻止别人参与游戏的技巧,无法确保攻击者一定能获得最终大奖。而黑客持续进行“阻塞交易”攻击需耗费大量 Gas 费用,成本会很高,最终会选择放弃攻击。

function buyCore(...)
    private
{
    ...
    // check to see if end round needs to be ran
    if (_now > round_[_rID].end && round_[_rID].ended == false) 
    {
        // check to see whether or not this round should end
        if shouldRndEnd(lastCommitId) (
            // end the round (distributes pot) & start new round
            round_[_rID].ended = true;
            _eventData_ = endRound(_eventData_);
            ...
        ) else {
            ...
            updateTimer(_keys, _rID);
            ...
        }
    }
    ...
}

上面为示例代码,其中 shouldRndEnd() 函数用来在倒计时结束后控制中奖概率,决定这一轮游戏是否真的结束。这里的概率同样依赖“随机数”不能被预测,具体实现原理与前文提到的空投概率控制代码类似。

方案二:禁止合约调用游戏信息查询接口

Fomo3D 最终获胜者可以轻易攻击成功的另一个原因是,游戏合约开放了一个完整的游戏进度信息查询接口,并且普通账户和合约账户都可以任意调用查询。这方便了黑客在攻击合约内实时查询游戏状态,进而执行不同策略来降低攻击成本和提高命中率。

modifier isHuman() {
    require(tx.origin == msg.sender, "sorry humans only");
    _;
}

function getCurrentRoundInfo()
    isHuman()
    public
    view
    returns(...)
{
    ...
}

因此,针对 Fomo3D 游戏,还有另一个简易的防范方法。对 getCurrentRoundInfo() 函数使用前文提到的安全版的 isHuman() 校验来保护,就可以有效避免合约自动化攻击。

总结

有安全和公平性问题的 Fomo3D 原版以及山寨版,仅是“黑客”掘金的对象,注定无法吸引更多普通玩家参加。随着一轮一轮的进行,玩家会逐渐流失,这些游戏会进一步没落。

安比(SECBIT)实验室呼吁后来者吸取教训,不要再原封不动地复制代码,不要试图仅靠“运营”来吸引新人入场。作出一些小小的改变,智能合约的安全性会得到很大的提升,去中心化游戏才能走得更远。

参考文献

• [1] Fomo3D二轮大奖开出,黑客获奖,机制漏洞成游戏没落主因, https://zhuanlan.zhihu.com/p/45330743, 2018/09/25

• [2] 智能合约史上最大规模攻击手法曝光,盘点黑客团伙作案细节, https://zhuanlan.zhihu.com/p/42318584, 2018/08/17

• [3] Fomo3D 千万大奖获得者“特殊攻击技巧”最全揭露, https://zhuanlan.zhihu.com/p/42742004, 2018/08/23

• [4] How to PWN FoMo3D, a beginners guide, https://www.reddit.com/r/ethereum/comments/916xni/howtopwnfomo3dabeginnersguide, 2018/07/23

• [5] Using EVM assembly to get the address’ code size, https://ethereum.stackexchange.com/questions/14015/using-evm-assembly-to-get-the-address-code-size, 2017/04/07

• [6] Predicting Random Numbers in Ethereum Smart Contracts, https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620, 2018/02/01

• [7] Random Number Generation on http://Winsome.io — Future Blockhashes, https://blog.winsome.io/random-number-generation-on-winsome-io-future-blockhashes-fe44b1c61d35, 2017/05/07

• [8] Gods Unchained, https://etherscan.io/address/0x482cf6a9d6b23452c81d4d0f0f139c1414963f89#code, 2018/07/16

本文由安比(SECBIT)实验室提供,安比(SECBIT)实验室致力于参与共建共识、可信、有序的区块链经济体。(wx添加小安同学:secbit_xiaoanbi,加入“SECBIT智能合约安全技术讨论”社群)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值