# Solidity 游戏/随机数/开宝箱 安全问题

Original: <https://lifelonglearn.ing/solidity-randomness-blindbox-security>

Author: [奶爸](https://lifelonglearn.ing/users/3b04650c-8231-499b-8ef0-381fc4d7efb0)

Published: 2021-10-25T15:45:32Z

Updated: 2026-09-27T06:57:43Z

Version: 1

今年靠 Solidity 随机数问题赚了 6 位数，说一下 Solidity 游戏类合约的一个随机数相关的安全问题吧，

## 随机数

基本上 开箱子（抽卡）、战斗、彩票 等等都跟随机数有关，有些用 block.difficulty、block.number、block.timestamp 等等组个 hash 取模来取随机数的，有使用 ChainLink 等 Oracle 的 真随机合约的。

不管是真随机数还是伪随机数都逃不开数据持久化。

拿抽卡游戏来说，有 R、S、SR、SSR 四种等级吧，然后用户可以用10U来抽卡，抽到的概率是随机的，一般都是用户调用接口，扣过来10U，然后按随机数生成卡片然后将卡片转给用户。那么这个流程中我们可以有什么地方可以操作？

### 同 transaction 内部

做合约的都知道，我们可以通过合约来抽卡，抽前给自己卡片做个快照，抽后给自己卡片做个快照。一是如果连抽到抽不到都是有概率的我们可以知道有没有抽到，二是如果抽到了，我们可以去读取卡片信息来验证开出来的卡片的稀有度。

如果稀有度没有达到我们的期望值，或者根本没抽到，可以在我们合约这里直接 revert，除了付点 gas 费，我们的 10U 还是老老实实回到我们钱包了。

**这个方案需要权衡尝试成本和抽到高级成本，有利润就可以操作**

### estimateGas

我们广播交易前可以对 call 进行 gas 估算，这个方法比较适用于战斗，比如一个有战斗场景的游戏，战斗成功之后直接发U给你当奖励。如果判定你失败，就不会给你发U，当然成功失败的操作差异不会只有这一个，这样大概率 *战斗成功* 的 gas 会比 *战斗失败* 要多一些。

这样在我们调用 fight 之前，就可以进行 gas 估算，如果 gas 比我们算出的中值要高，那么大概率是赢的。

**这个方案只能适用于逻辑比较明显，差异明显的合约。对于逻辑复杂的合约，gas是不好估算的，成功率不高**

## 防护方案

以吾之盾，防吾之矛

### 数据被验证

像上面提到的同交易中利用外部合约验证，验证不通过就 revert 的场景，有几种解决方案。

#### ~~检查caller的extcodesize~~

~~普通用户地址的 extcodesize 一定是 0 的，使用以下代码可以检查是否来自普通用户地址，~~

```solidity
contract OnlyForEOA {  
    uint public flag;

    // bad
    modifier isNotContract(address _a){
        uint len;
        assembly { len := extcodesize(_a) }
        require(len == 0);
        _;
    }

    function setFlag(uint i) public isNotContract(msg.sender){
        flag = i;
    }
}
```

~~但是，​​**部署合约时在 constructor 中的操作 caller 的 extcodesize 也是 0**​，所以我们可以使用以下代码 bypass 这种防护方案，这也是为什么这个方案使用删除线的原因~~

```solidity
contract FakeEOA {
    constructor(address _a) public {
        OnlyForEOA c = OnlyForEOA(_a);
        c.setFlag(1);
    }
}
```

#### tx.origin

检查 `tx.origin == msg.sender` 是一个较好的方案，它将确保调用者来自普通用户且未经过其他合约来调用，但是

1. `tx.origin` 将来有可能 会从以太坊协议中删除
2. 这种方式将会把合理的合约用户拒之门外，比如多签合约钱包、DAO持有人

所以奶爸更推荐下面的防护方案，在业务逻辑中将 **「剧透」** 的科学家们拒之门外

#### 延迟读取

比如我在 1000 区块开出卡，我可以记录下来出卡区块，读取稀有度那里可以限定只能 N+1 读取，同区块读取就返回0，这样就完美避免了利用外部合约验证稀有度的问题。

#### 分阶段执行

可以将一个操作拆分为两个阶段，

1. 第一阶段就隐式的将第二阶段所用到的所有随机数都准备好，不暴露出来。gas都是一模一样的，无法推测、无法验证。
2. 第二阶段根据第一阶段产生的结果执行对应操作，给对应奖励，这一阶段不论干什么都是无效的，结果已在第一阶段确认。

⚠️ **这里需要限制这两个阶段不能在同一个区块中避免验证，或者直接限制个人地址调用。**

### 估算 Gas

这个问题可以这么解决，战斗成功，奖励 10 U，战斗失败 奖励 0 U 呗，该执行的操作一视同仁，但是你听过一句话吗？「听君一席话，胜听一席话」，给他加 0 U呗，gas 表现出来不能说完全一样（修改数值和不修改数值Gas是不同的），但是差异也是很小的，也减少了特征点。

也可以做些额外操作来补充没进行的操作，类似 ​*花指令*​。

### 扩展阅读

* [OpenZeppelin | Contracts](https://lifelonglearn.ing/r/go?url=aHR0cHM6Ly9vcGVuemVwcGVsaW4uY29tL2NvbnRyYWN0cy8=)
* [Security Considerations — Solidity documentation](https://lifelonglearn.ing/r/go?url=aHR0cHM6Ly9kb2NzLnNvbGlkaXR5bGFuZy5vcmcvZW4vbGF0ZXN0L3NlY3VyaXR5LWNvbnNpZGVyYXRpb25zLmh0bWw=)
* [Solidity Top 10 Common Issues - Security Boulevard](https://lifelonglearn.ing/r/go?url=aHR0cHM6Ly9zZWN1cml0eWJvdWxldmFyZC5jb20vMjAyMC8wNS9zb2xpZGl0eS10b3AtMTAtY29tbW9uLWlzc3Vlcy8=)
* [Solidity Security: Comprehensive list of known attack vectors](https://lifelonglearn.ing/r/go?url=aHR0cHM6Ly9ibG9nLnNpZ21hcHJpbWUuaW8vc29saWRpdHktc2VjdXJpdHkuaHRtbA==)
