智能合约安全审计入门篇 —— 随机数

本期我们将带大家了解智能合约中一个经常被用到的东西——随机数。

By:小白@慢雾安全团队

背景概述

上次的文章中我们了解了 delegatecall 函数的特点以及其正确的使用方式。本期我们将带大家了解智能合约中一个经常被用到的东西——随机数。

前置知识

智能合约的开发中常常会用到随机数,例如 Lottery 和现在流行的 NFT 数字藏品的属性等都需要用到随机数。目前来说常见的随机数获取有两种:使用区块变量生成随机数,使用预言机来生成随机数。下面我们了解一下这两者的特点:

  • 使用区块变量生成随机数

我们先了解一下常见的区块变量有哪些:

block.basefee(uint):当前区块的基本费用

block.chainid(uint):当前链 id

block.coinbase():当前区块矿工地址 address payable

block.difficulty(uint):当前区块难度

block.gaslimit(uint):当前区块 gaslimit

block.number(uint):当前区块号

block.timestamp(uint):自 Unix 纪元以来的当前区块时间戳(以秒为单位)

blockhash(uint blockNumber) returns (bytes32):给定区块的哈希,仅适用于 256 个最近的区块

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

  • 使用预言机生成随机数

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

当然这种方法也会有一些安全风险,例如依赖第三方给出的随机数种子的话同样会存在第三方作弊或者受贿的情形,即使是自己搭建的随机数服务也可能因为故障等原因无法使用,项目方也有可能操控随机数对 DApp 的运行和用户造成重大的损失。因此使用链下服务获取随机数的方法依赖于是否有一个可信又稳定的第三方服务,如果有,那么这个方法相较于使用区块链变量生成随机数的方法,随机数的不可预测性会更强一些。

说到这里大家可能会有一些疑问,这些随机数生成方式或多或少都存在一定风险,那么就没有一个既稳定又安全的随机数获取方式吗?答案是有,有很多去中心化的预言机服务,以 Chainlink 为例,这类去中心化的预言机服务提供的随机数相对更加稳定和安全。Chainlink VRF 提供了一个链上去中心化的随机数种子获取方案。只要付出 Link 币就可以从 Chainlink 上获取随机数种子了,至于他们的各种优点和使用方式这里就不过多介绍了。

接下来我们还是用合约代码来给大家演示弱随机数可能带来的危害。

漏洞示例

pragma solidity ^0.8.13;
contract GuessTheRandomNumber {
    constructor() payable {}

    function guess(uint _guess) public {
        uint answer = uint(
            keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
        );

        if (_guess == answer) {
            (bool sent, ) = msg.sender.call{value: 1 ether}("");
            require(sent, "Failed to send Ether");
        }
    }
}

漏洞分析

首先我们先来了解一下代码中的两个函数,abi.encodePacked 和 keccak256:

  • abi.encodePacked 对参数进行编码,solidity 提供两种编码方法 encode 和 encodePacked,前者对每一个参数进行 32 字节补齐,后者不进行补齐而是直接将待编码参数连接起来。

  • keccak256 哈希算法,可以将任意长度的输入压缩成 64 位的 16 进制的数,且哈希碰撞的概率近乎为 0。

接下来我们来看合约代码,这个合约是一个猜数字赢以太的游戏,我们可以看到,部署者使用上个区块的区块哈希和区块时间作为随机数种子生成随机数,我们只需要模拟他的随机数生成方法就可以得到奖励。下面我们来看攻击合约:

攻击合约

pragma solidity ^0.8.13;
contract Attack {
    receive() external payable {}

    function attack(GuessTheRandomNumber guessTheRandomNumber) public {
        uint answer = uint(
            keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
        );

        guessTheRandomNumber.guess(answer);
    }

    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

首先我们先来分析攻击流程:

1.Alice 使用一个以太币部署 GuessTheRandomNumber 合约;

2.Eve 部署 Attack 合约调用 attack() 函数并传入 GuessTheRandomNumber 合约的地址;

3.Eve 赢得一个以太币。

这次的攻击可以说是本系列文章创编以来最简单的了,咋回事呢:

首先,Attack.attack() 模拟了 GuessTheRandomNumber 合约中随机数的生成方式生成随机数后调用 guessTheRandomNumber.guess() 并将生成的随机数传入,由于从 Attack.attack() 生成随机数到调用 guessTheRandomNumber.guess() 都是在同一区块中完成的,且在同一区块中 block.number 和 block.timestamp 这两个参数是不变的,所以,Attack.attack() 和 guessTheRandomNumber.guess() 这两个函数生成的随机数的结果是相同的,从而攻击者可以顺利通过 if(_guess == answer) 判断得到奖励。

修复建议

作为开发者

如果随机数属于非核心业务的话可以使用未来区块哈希来生成随机数也就是将猜数和领奖分开做异步处理。我针对这次的漏洞合约写了一个优化版本,大家可以看下(由于漏洞合约中使用了区块时间和区块哈希来生成一个 256 位的随机数,这无疑很大程度上增加了猜测的难度,对于玩家来说非常的不方便,这里为了契合原漏洞合约我就不做修改了):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract GuessTheRandomNumber {
    constructor() payable {}
    uint256 public deadline = block.timestamp + 72 hours;
    mapping ( address => uint256 ) public Answer;

    modifier isTime(){
        require(block.timestamp > deadline , "Not the time!");
    }

    event Guess(address,uint256);
    event Claim(address);

    function guess(uint256 _guess) public {
        require(block.timestamp <= deadline , "Too late!");
        Answer.[msg.sender] = _guess;
        emit Guess(msg.sender,_guess);
    }

    function claim() public isTime{
        uint256 key = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)));
        uint256 answer = Answer.[msg.sender];
        require(key == answer , "Sorry,may be next time.");
        (bool sent, ) = msg.sender.call{value: 1 ether}("");
        require(sent, "Failed to send Ether");
        emit Claim(msg.sender);
    }
}

大家其实可以看到,我添加了 deadline 参数将 guess 和 claim 做了异步处理,在部署合约后的 72 小时内可以调用 guess() 猜随机数,在 72 小时后 guess() 关闭 claim() 开启,玩家可以通过 claim() 来验证自己是否猜中。当然,这个修复合约并不是完美的解决方案,正如前置知识中提到的,如果矿工来玩的话他可以在打包的时候知道自己是否猜中,如果猜中打包上链,如果没有猜中放弃打包(相信没有任何一个矿工愿意为了得到一个以太而付出这么大的代价)。所以最优的解决办法还是接入知名预言机来获取随机数。

作为审计者

作为审计者在审计过程中遇到随机数的话需要重点关注随机数种子来源,几乎所有使用区块变量都逃不脱被矿工作恶的可能。遇到使用第三方提供的随机数种子时应提醒项目方确认其来源是否百分百可靠,避免第三方作恶或硬件问题造成损失。在可能的情况下应当建议项目方接入知名预言机来获取安全的随机数。

注:本文参考于《Solidity by Example》

参考链接: https://solidity-by-example.org/hacks/randomness

本文首发于:https://mp.weixin.qq.com/s/2_2GsyZ54Vs8Trcbg4766g

  • 发表于 2022-07-18 09:37
  • 阅读 ( 491 )
  • 学分 ( 0 )
  • 分类:安全

0 条评论

请先 登录 后评论
慢雾科技
慢雾科技

60 篇文章, 63 学分