区块链产业目前处于快速发展的阶段,越来越多新技术应用落地的同时,很多新的安全问题也随之而来。其中,智能合约安全问题最为常见、最为灵活,其造成的损失也最不可控。Dice2win是一款基于以太坊公有链,通过智能合约实现的去中心化博彩小游戏,自小游戏发布的4个月时间里,由于智能合约漏洞,就发生了多起不同类型的攻击事件,而且事件环环相扣,损失大且不可逆。众多区块链智能合约安全事件证明,区块链应用的安全防御尤为重要。
区块链技术的市场背景
区块链技术从2008年出现至今已经有十年时间,在这个科技飞速发展的互联网时代,区块链依然是一个国际上关注度最高的技术,很多国内外知名的公司都投身其中:微软、IBM、中国银行、平安银行、百度、阿里巴巴、腾讯等。在2018年5月28日,中国科学院第十九次院士大会上,国家主席习近平发表重要讲话,他强调,中国要强盛、要复兴,就一定要大力发展科学技术,努力成为世界主要科学中心和创新高地。而自2018年以来,通过国家监管机构的引导,区块链技术已经逐步转换到服务实体经济的方向上,区块链技术应用已从最初的金融货币领域延伸到了物联网、智能制造、供应链管理、数字资产交易等多个领域。
区块链技术市场背景
区块链技术的安全
区块链技术在密码学和不可篡改的基础上解决交易的信任和安全问题,其底层采用的方法和架构是可靠的、安全的,但区块链的安全机制并不完善,对各个层级的保护机制并不完善,区块链技术应用对应的区块链底层技术、智能合约、应用平台、用户数据等方面均可能存在安全隐患。
区块链系统迄今为止发生了众多安全事件,综合整理各种攻击手段,针对区块链系统的攻击面如下图所示:
区块链技术攻击面
区块链系统攻击面包含:Web攻击、Dapps攻击、智能合约攻击、底层链攻击。
区块链中Web、Dapps攻击与传统Web、APP攻击类似,而智能合约及底层链的攻击为区块链技术自身特有攻击。
Web、Dapp常见攻击举例:
- 输入与输出:若应用系统未对用户输入的数据进行验证,攻击者可在请求数据中插入恶意代码对应用系统进行攻击,或者通过应用系统调用的区块链核心系统的接口,对区块链核心系统进行攻击,不限于SQL注入、文件上传、路径遍历、XML注入、代码注入等漏洞)以及XSS等漏洞;
- API误用:API是调用者与被调用者之间的约定,若调用者未能按照约定调用API,也会引发安全问题;
- 网络传输:应用系统与用户进行数据交互,以及应用系统与区块链核心系统通信过程中,应对信道中传输的数据进行保护,否则会存在数据泄露的风险等。
智能合约常见攻击举例:
- 条件竞争:当外部合约调用恶意代码执行,他们能操纵控制流程,并且将不希望被更改的数据篡改,导致资源竞争(Race Conditions);
- 智能合约call调用:Call函数可以调用对方函数,此种做法风险极大,非法消息call调用可导致完全控制合约;
- 调用深度栈问题:调用栈的最大值1024,如果调用执行超过1024,任何方式的调用都会失败等。
底层链常见攻击举例:
- 密钥被盗:攻击者通过盗用的私钥发起欺诈性交易、欺诈性提款;
- 共识机制重写:攻击者发起相同的对自己有利的交易并达成共识;
- 匿名攻击:公链上攻击者隐藏自己的身份发起攻击,匿名机制导致很难找到攻击者等。
因建立区块链应用组织的疏漏所致攻击举例:
- 测试不充分:应用代码测试不充分导致攻击者有机可乘;
- 未经授权的访问:以不恰当的访问私钥或用区块链应用软件去窃取资金或信息;
- 身份管理:窃取个人信息或冒充节点来获取区块链的访问权限等。
下面结合具体的安全事件来分析智能合约安全,了解合约层的安全对区块链应用安全的影响有多么巨大。
Dice2win安全事件分析
游戏简介
Dice2win游戏是以太坊公有链上非常火爆的博彩游戏之一,其中包括“抛硬币”“掷骰子”“两个骰子”“Etherroll”四种游戏,其被称为“可证明公平的”Dice2win,截至安全事件爆出之前每日的交易量达到上千以太币(价值一百五十多万元人民币)。游戏中玩家和庄家一对一进行打赌,庄家通过以太坊智能合约的一系列协议生成随机数,玩家来猜随机数,猜中玩家获利,猜不中庄家获利。
Dice2win游戏
游戏的总体流程如下:
- House庄家通过以太坊智能合约平台生成随机数reveal
- 同时对reveal进行加密承诺commit =keccak256(reveal)
- 根据块高度设置该承诺使用的最后块高度CommitlastBlock
- 对CommitlastBlock和commit进行签名sig=sign(CommitlastBlock,commit)
- 将sig发送给player玩家
- 用户选择游戏猜随机数,并下注(将交易placeBet发送到智能合约进行下注)
- 矿工看到下注交易,将交易进行打包放到block(placeBlockNumber)中,并将下注的内容存储到智能合约中
- 庄家获取到了block中下注的信息,向区块链发起交易settleBet
- 智能合约计算随机数random number=keccak256(reveal,blockHash)
- 随机数比对,猜对用户获利,猜错庄家获利。
工作流程时序图
Dice2win整个工作流程基于RSA Mental Poker密码学算法,通过密码学及以太坊智能合约平台共同形成了在非可信第三方情况下的可信棋牌游戏。
Dice2win宣称自己是“可证明公平的”游戏,随机数和随机数生成过程都是玩家和庄家共同参与,其有着完美的算法、极佳的实践、无懈可击的理念,但是再完美的官方宣传也抵不过可冲破谎言的严谨逻辑分析和从根本出发的智能合约安全审计。表面看似简单,其实暗藏玄机,通过程序可让我们获利,也可以让我们受损,损失极大且不可逆,可以说智能合约一行代码即可扭转人生。下面一起看看Dice2win游戏中的两类攻击方式:庄家获利攻击和黑客获利攻击。
庄家获利攻击
庄家获利攻击方式中包含两种方式:选择性中止攻击和分叉导致的选择性开奖攻击。
选择性中止攻击
Dice2win游戏基于RSA Mental Poker密码学算法,该算法针对公平性存在一种常见的攻击方式,就是选择性中止攻击(Selective Abort Attack)。游戏中庄家通过智能合约程序执行顺序提前获取到玩家计算随机数的相关信息判断是否中奖,根据下注额度和中奖金额判断是否中止开奖,声称“可证明公平的”游戏在这里其实并不公平。
下面是选择性中止攻击成因的合约程序,其中通过placeBet函数为玩家建立赌局,庄家生成随机数并对随机数进行一系列运算后生成blockHash,玩家选择游戏进行下注(发送交易),服务器在运行一段时间后,智能合约将对随机数进行计算并调用settleBet函数进行开奖。
// Commits are signed with a block limit to ensure that they are used at most once - otherwise // it would be possible for a miner to place a bet with a known commit/reveal pair and tamper // with the blockhash. Croupier guarantees that commitLastBlock will always be not greater than // placeBet block number plus BET_EXPIRATION_BLOCKS. See whitepaper for details. function placeBet(uint betMask, uint modulo, uint commitLastBlock, uint commit, bytes32 r, bytes32 s) external payable { // Check that the bet is in 'clean' state. Bet storage bet = bets[commit]; require (bet.gambler == address(0), "Bet should be in a 'clean' state."); // Validate input data ranges. uint amount = msg.value; require (modulo > 1 && modulo <= MAX_MODULO, "Modulo should be within range."); require (amount >= MIN_BET && amount <= MAX_AMOUNT, "Amount should be within range."); require (betMask > 0 && betMask < MAX_BET_MASK, "Mask should be within range."); // Check that commit is valid - it has not expired and its signature is valid. require (block.number <= commitLastBlock, "Commit has expired."); bytes32 signatureHash = keccak256(abi.encodePacked(uint40(commitLastBlock), commit)); require (secretSigner == ecrecover(signatureHash, 27, r, s), "ECDSA signature is not valid."); uint rollUnder; uint mask; if (modulo <= MAX_MASK_MODULO) { // Small modulo games specify bet outcomes via bit mask. // rollUnder is a number of 1 bits in this mask (population count). // This magic looking formula is an efficient way to compute population // count on EVM for numbers below 2**40. For detailed proo87090f consult // the dice2.win whitepaper. rollUnder = ((betMask * POPCNT_MULT) & POPCNT_MASK) % POPCNT_MODULO; mask = betMask; } else { // Larger modulos specify the right edge of half-open interval of // winning bet outcomes. require (betMask > 0 && betMask <= modulo, "High modulo range, betMask larger than modulo."); rollUnder = betMask; } // Winning amount and jackpot increase. uint possibleWinAmount; uint jackpotFee; (possibleWinAmount, jackpotFee) = getDiceWinAmount(amount, modulo, rollUnder); // Enforce max profit limit. require (possibleWinAmount <= amount + maxProfit, "maxProfit limit violation."); // Lock funds. lockedInBets += uint128(possibleWinAmount); jackpotSize += uint128(jackpotFee); // Check whether contract has enough funds to process this bet. require (jackpotSize + lockedInBets <= address(this).balance, "Cannot afford to lose this bet."); // Record commit in logs. emit Commit(commit); // Store bet parameters on blockchain. bet.amount = amount; bet.modulo = uint8(modulo); bet.rollUnder = uint8(rollUnder); bet.placeBlockNumber = uint40(block.number); bet.mask = uint40(mask); bet.gambler = msg.sender; } // This is the method used to settle 99% of bets. To process a bet with a specific // "commit", settleBet should supply a "reveal" number that would Keccak256-hash to // "commit". "blockHash" is the block hash of placeBet block as seen by croupier; it // is additionally asserted to prevent changing the bet outcomes on Ethereum reorgs. function settleBet(uint reveal, bytes32 blockHash) external onlyCroupier { uint commit = uint(keccak256(abi.encodePacked(reveal))); Bet storage bet = bets[commit]; uint placeBlockNumber = bet.placeBlockNumber; // Check that bet has not expired yet (see comment to BET_EXPIRATION_BLOCKS). require (block.number > placeBlockNumber, "settleBet in the same block as placeBet, or before."); require (block.number <= placeBlockNumber + BET_EXPIRATION_BLOCKS, "Blockhash can't be queried by EVM."); require (blockhash(placeBlockNumber) == blockHash); // Settle bet using reveal and blockHash as entropy sources. settleBetCommon(bet, reveal, blockHash); }
为了保证提交赌局者使用block limit进行签名,确保区块信息最多使用一次,防止矿工对commit、reveal进行投注并篡改blockHash,下注和开奖函数的执行必须按照合约编写顺序执行,但是正因为避免了上述安全风险,导致了用户的下注信息在placeBet函数执行时,即可获取到,服务器可提前进行随机数的运算,提前判断用户是否中奖,中奖金额是否过大,从而选择性中止当前交易。下图是庄家对于选择性中止交易这一现象的官方解释。交易中止或者重摇,可能是因为技术问题或以太坊网络拥堵导致。
选择性中止攻击
解除选择性中止的庄家优势,增加惩罚机制,要求庄家在限定时间打开承诺即可。
分叉导致的选择性开奖攻击
选择性开奖成因
Dice2win游戏是基于以太坊区块链智能合约应用,其共识机制采用的是POW(工作量证明机制),矿工需要计算出满足条件的Hash才能拥有记账权进行记账并获得奖励,而正因为这种看似公平的机制,可能会吸引更多的矿工进行计算争夺记账权,那么就存在软分叉的可能,而面对分叉,以太坊是通过幽灵协议(GHOST protocol)选择主链,分叉链上的区块将均成为孤块,其上面的交易、计算均会失效,交易退回到主链分叉块的初始位置,而Dice2win游戏中RSA Mental Poker等密码学安全计算均在智能合约中进行调用、计算,就存在不稳定计算的可能。对于博彩游戏来说,保证游戏的单向不可逆是重要原则之一,为了解决上述交易计算可能会舍弃回退的问题,Dice2win更新了合约,添加Merkle proofs对uncle block进行验证运算。如下为Dice2win智能合约更新代码块:
选择性开奖成因代码块1
选择性开奖成因代码块2
为了保证在不影响用户体验的情况下开奖单向不可逆,Dice2win官方通过智能合约实现了:当分叉块产生时,无论交易是在主链上还是分叉链上,只要收到交易区块就执行开奖操作,当最终交易块在主链上,则使用settleBet开奖,当交易块在分叉链上,则引用Mekle proof进行存证,证实交易在分叉区块上,则使用uncle block开奖。
选择性开奖
Dice2win采用了Merkle proof算法对以太坊分叉现象做了看似较好的修复,但是由于以太坊到目前为止,unclerace已在12%以上,当庄家遇到分叉交易情况,就可以根据中奖情况选择开奖位置,如果主链交易玩家胜则在分叉区块上进行开奖,如果分叉区块交易玩家胜则在主链上进行开奖,由此分析,Dice2win依然存在对玩家不公平的情况,庄家可对赌局进行选择性开奖。不过选择性开奖这一安全风险由于受到以太坊公网算力及智能合约执行位置的影响,此类安全风险不易被利用,选择性开奖虽存在对玩家不公的风险,但情况极其少见,对大部分玩家影响较小。
黑客获利攻击
黑客获利攻击方式包含两种方式:任意开奖攻击和整数下溢攻击。
任意开奖攻击(Merkle proof绕过)
庄家优势漏洞修复之后,经过多方对智能合约的审计发现合约中Merkle Proof验证算法有多种绕过方式,并且已有攻击者通过Merkle Proof算法绕过的方式对Dice2win发起了任意开奖攻击。下面我们来分析一下Dice2win合约的漏洞。
secretSigner多版本值相同的问题
Dice2win是一个持续更新的合约,对于不同版本的合约secretSigner值相同,这导致了一个签名可以在多个合约中使用的安全风险。
secretSigner多版本值相同
placeBet函数对commit的过期校验可绕过
该漏洞是由于在placeBet函数中commitLastBlock在与blocknumber进行判断时类型是uint256,而通过keccak256验证时类型变成了uint40,当攻击者在合约执行placeBet函数时,将commitLastBlock的最高位修改,即可绕过commit过期校验,这就导致了某个签名信息一直有效。
过期校验可绕过
Merkle proof校验不严格
该漏洞是由于settleBetUncleMerkleProof函数中对hashSlot校验不严格,导致攻击者无需将commit绑定在Merkle proof中,从而绕过验证。
Merkle proof校验不严格
修复:
修复Merkle proof校验不严格
该修复解决了Merkle proof校验不严格的安全风险,并添加了onlyCroupier修饰符,保证了开奖函数的调用只有庄家可以。避免了开奖函数被越权调用。
整数下溢攻击
该漏洞是由于jackpotSize可控,当Dice2win有幸运用户中了大奖,攻击者进行下注并commit,在幸运用户拿走大奖后,攻击者调用refundBet,导致jackpotSize下溢,变成一个巨大整数。
整数下溢攻击
Dice2win事件总结
Dice2win游戏是基于以太坊区块链平台,通过传统密码学RSA Mental Poker安全算法实现的游戏合约。用户、庄家之间无需与中心化节点进行交互,所有操作均通过智能合约协议进行运算并存储上链。在这一过程中由于密码学自身特性及智能合约函数为了正确计算而必须强制顺序执行,导致了庄家存在可选择性中止赌局的优势,并且以太坊区块链的分叉现象导致了开奖结果可逆,官方为了解决开奖结果可逆的现象对智能合约进行了修复,修复后虽然解决了开奖结果可逆,却由于修复后的合约依然存在不严谨的情况,导致了修复方案可绕过,这给攻击者可乘之机,攻击者向合约实施任意开奖合约攻击,导致用户、庄家受损。
经过分析,我们发现Dice2win安全事件是一环扣一环的,解决一个漏洞后,又出现了新的漏洞,攻击方式精巧、利用方式多。这证实了智能合约应用安全的难点,和智能合约安全的严峻性。
参考
https://dice2.win/
合约代码地址:
https://etherscan.io/address/0xd1ceeeeee83f8bcf3bedad437202b6154e9f5405#code
修复合约代码地址1:
https://github.com/dice2-win/contracts/commit/86217b39e7d069636b04429507c64dc061262d9c
修复合约代码地址2:
https://github.com/dice2-win/contracts/commit/b0a0412f0301623dc3af2743dcace8e86cc6036b
https://medium.com/dapppub/fairdicedesign-315a4e253ad6
https://en.wikipedia.org/wiki/Mental_poker
《智能合约安全编码指南》
《区块链应用安全服务总体方案》