Solidity 游戏/随机数/开宝箱 安全问题
今年靠 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 的,使用以下代码可以检查是否来自普通用户地址,
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 这种防护方案,这也是为什么这个方案使用删除线的原因
contract FakeEOA {
constructor(address _a) public {
OnlyForEOA c = OnlyForEOA(_a);
c.setFlag(1);
}
}
tx.origin
检查 tx.origin == msg.sender
是一个较好的方案,它将确保调用者来自普通用户且未经过其他合约来调用,但是
tx.origin
将来有可能 会从以太坊协议中删除- 这种方式将会把合理的合约用户拒之门外,比如多签合约钱包、DAO 持有人
所以奶爸更推荐下面的防护方案,在业务逻辑中将 「剧透」 的科学家们拒之门外
延迟读取
比如我在 1000 区块开出卡,我可以记录下来出卡区块,读取稀有度那里可以限定只能 N+1 读取,同区块读取就返回 0,这样就完美避免了利用外部合约验证稀有度的问题。
分阶段执行
可以将一个操作拆分为两个阶段,
- 第一阶段就隐式的将第二阶段所用到的所有随机数都准备好,不暴露出来。gas 都是一模一样的,无法推测、无法验证。
- 第二阶段根据第一阶段产生的结果执行对应操作,给对应奖励,这一阶段不论干什么都是无效的,结果已在第一阶段确认。
⚠️ 这里需要限制这两个阶段不能在同一个区块中避免验证,或者直接限制个人地址调用。
估算 Gas
这个问题可以这么解决,战斗成功,奖励 10 U,战斗失败 奖励 0 U 呗,该执行的操作一视同仁,但是你听过一句话吗?「听君一席话,胜听一席话」,给他加 0 U 呗,gas 表现出来不能说完全一样(修改数值和不修改数值 Gas 是不同的),但是差异也是很小的,也减少了特征点。
也可以做些额外操作来补充没进行的操作,类似 花指令。