当前位置:主页 > 列表页 > 正文

有趣的智能合约蜜罐(下)

2022-01-30 18:45 | 出处: odaily

1. 概述

有趣的智能合约蜜罐(上)中我们对古老的欺骗手段和神奇的逻辑漏洞进行了讲解和复现,在下部分中我们将会对新颖的赌博游戏和黑客的漏洞利用进行讲解以及复现,从而进一步增加对智能合约蜜罐的了解。

同样的,所有的智能合约蜜罐代码都可以 GitHub 上找到,这里再次给出他们的网址:

2. 新颖的赌博游戏

赌博行业从古至今一直存在,而区块链的去中心化似乎给赌博行业带了新的机会,它的进入会让人们觉得赌博变得公平,然而我们都知道赌博结果往往都是必输,那么接下来就通过分析四个基于区块链的赌博游戏合约来介绍庄家是如何最后稳赢的。

2.1 加密轮盘赌轮:CryptoRoulette

2.1.1 蜜罐分析

第一个要介绍的是 CryptoRoulette,它译为「加密轮盘赌轮」。

蜜罐的完整代码如下:

该合约设置了一个私有属性的随机数 secretNumber,在 shuffle() 函数中被指定范围在 1 - 20,玩家可以通过 play() 函数去盲猜这个随机数,如果猜对了就可以将合约中的所有钱取走,每次调用 play() 函数后都会重置随机数。

这么看来这个合约好像没有什么问题,随着猜错的玩家越来越多,合约中的代币余额也会积累的越多,如果碰巧猜对了就可以获取所有的奖金,然而事实是这样的嘛?

我们可以看到在这个蜜罐合约中,最重要的就是 shuffle() 和 play() 这两个函数,下面就来分析下这两个函数。

初始的 secretNumber 是在构造函数 CryptoRoulette 中调用 shuffle() 函数,而 shuffle() 函数中只有一行代码,就是设置 secretNumber 的值,从代码中也可以看出 secretNumber 的值既和区块的数目有关,也和时间有关。函数代码如下:

而 play() 函数就是提供给用户进行赌博来猜这个随机数的,玩家携带不小于 0.1 eth 并传入自己猜的数字 number,玩家猜的这个数字 number 去和 secretNumber 进行比较,如果相等就可以获胜,转走合约中的所有以太币,但是在函数的开头中有一个检查 require,其中后面要求玩家猜的数字不能大于 10,而 secretNumber 我们在上面的函数中讲到范围是 1 - 20,这样看来虽然加大了难度,但是也存在猜对可能性,然而事实是 secretNumber 一定会大于 10,玩家永远都不可能猜对数字,合约所有者却可以通过调用 kill() 函数转走合约中的所有以太币。

这里会有人问了,secretNumber 为啥一定会大于 10 呢?原因就是结构体 game 的初始化对存储数据 secretNumber 的覆盖,我们在函数里直接初始化结构体必须加 memory 关键字,因为 memory 是使用内存来进行存储,这样一来就可以避免占用 storage 的存储位,而蜜罐合约中并未使用 memory 关键字,从而导致了变量覆盖。

该问题在 Solidity 0.5.0 版本以前只是进行了提示,并没有做出错误警告,所以在老版本编译器中要注意该问题。在下面的代码复现中可以看到问题所在。

2.1.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,为了方便我们查看 secretNumber 的值,我们将 secretNumber 的类型设置为 public,这样就可以在 Remix IDE 中直接看到它的值了。

甚至有些蜜罐部署者为了诱惑攻击者来攻击合约,也可以设置为 public 属性,因为就算告诉攻击者 secretNumber 的值他也不能猜对这个数字。

使用地址 0 x5B3 点击「Deploy」部署合约,调用 secretNumber 查看初始随机数为 1,由于这里还没有初始化结构体也就不会覆盖随机数所以是正确的。

之后攻击者发现了该蜜罐合约,查看 secretNumber 为 1 并认为该合约可以进行攻击获利,所以在符合 play() 函数中的第一个判断条件情况下传入数字 1 和携带 1 个以太币进行函数调用,函数调用成功后查看账户余额发现账户余额不仅没有得到合约中的所有代币反而将刚才函数调用时携带的 1 个以太币也损失掉了。

为了探究具体原因我们对刚才的函数调用进行 Debug。

调试点击下一步直到第一个条件判断,此时 secretNumber 仍然为 1。

继续点击按钮进行下一步的调试,当进行到 game.player = msg.sender 时由于结构体 game 的初始化对存储数据 secretNumber 进行了覆盖,导致 secretNumber 变成了 msg.sender 的 uint256 内容,这样一来就使得后面的 if 判断条件不能成立,从而使得攻击者不能转走合约中的所有代币余额。

2.2 开放地址彩票:OpenAddressLottery

2.2.1 蜜罐分析

第二个要介绍的是 OpenAddressLottery,它译为「开发地址彩票」。

蜜罐的完整代码如下:

蜜罐合约 OpenAddressLottery 的游戏逻辑很简单,合约中有一个初始值为 1 的状态变量 LuckyNumber,竞猜者每次竞猜时都会根据其地址随即生成 0 或者 1,如果生成的值和 LuckyNumber 一样,那么竞猜者就可以获得 1.9 倍的奖金,且每个地址只能赢得一次游戏胜利,之后将无法继续参加竞猜。该蜜罐合约的重点就在于 participate()、luckyNumberOfAddress() 和 forceReseed() 函数,下面来对这 3 个函数进行依次讲解。

首先是 participate() 函数,这是用户参与竞猜的函数:

接着是 luckyNumberOfAddress() 函数,将竞猜者的地址作为参数传入,通过 n = uint(keccak256(uint(addr), secretSeed)[0]) % 2; 来计算竞彩时竞猜者对应的数字,由于是对 2 取余,所以得到的结果只能为 0 或者 1。在计算这个数字时使用了变量 secretSeed,而该变量总是通过 reseed() 函数得到的。

最后我们来讲下上面说到的 reseed() 函数,通过 keccak256 算法将传入的 4 个参数来生成 secretSeed。

通过上面对合约的分析,看起来合约没有什么问题,中奖率也是 50%,但其实是有陷阱的,这就要说到 Solidity 0.4.x 结构体局部变量引起的变量覆盖漏洞,也就是给未初始化的结构体局部变量赋值时会直接覆盖掉智能合约中定义的前几个变量,这样就使得合约中 forceReseed() 函数被调用后,第四个定义的参数 LuckyNumber 会被 s.component4 = tx.gasprice * 7 给覆盖并将其设置为 7,该蜜罐合约原理和上一个蜜罐合约类似。

查看该合约的交易内容,可以发现 OpenAddressLottery 的交易数量很多,这也说明了蜜罐合约 OpenAddressLottery 的欺骗性。

2.2.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,为了方便我们查看 LuckyNumber 的值,我们将 LuckyNumber 的类型设置为 public,这样就可以在 Remix IDE 中就有获取其值的 getter() 函数了。同样的,蜜罐部署者也可以将该变量设置为 public 属性让攻击者误以为有利可图,因为 LuckyNumber 的值会被覆盖永远为 7。

使用地址 0 x5B3 点击「Deploy」部署合约,调用 LuckyNumber 查看其值为 1,由于这里还没有初始化 SeedComponent 结构体也就不会覆盖掉 LuckyNumber 的值,所以它还是 1。

使用合约所有者 0 x5B3 调用 forceReseed() 函数来初始化 SeedComponent 中的四个变量,可以看到 LuckyNumber 的值由于初始化已经变成了 7。

攻击者 0 x 4B2 看到该合约后认为其存在漏洞,携带 10 eth 调用 participate() 函数,调用后查看余额发现并没有增加。查看自己的地址对应的 luckyNumberOfAddress 的值为 1,但是却没有得到奖励,再查看 LuckyNumber 的值发现一直为 7。

其原因就是在部署者调用 forceReseed() 函数初始化后 LuckyNumber 的值就被覆盖为了 7,而攻击者地址生成的随机数只能是 0 或 1,这就意味着永远不会有人获得胜利。这就是利用了编译器的漏洞,该问题已经在 Solidity 0.5.0 中修复,所以这种蜜罐合约只有在 Solidity 0.4.x 中才会生效。

2.3 山丘之王:KingOfTheHill

2.3.1 蜜罐分析

第三个要介绍的是 KingOfTheHill,它译为「山丘之王」。

蜜罐的完整代码如下:

蜜罐合约 KingOfTheHill 只有 38 行代码,逻辑很简单,有回退函数和 takeAll() 函数,其中 jackpot 变量是传入合约的所有代币之和,每次有用户调用回退函数后如果传入的 mag.value比 jackpot 大,就将 owner 的值赋值为 msg.sender。

当用户获得了合约所有者权限后,就可以调用 takeAll() 函数在延期时间到后将合约中所有余额转走。接下来重点分析下这两个函数。

首先是回退函数,这是用户参与合约「漏洞」的函数,其代码如下:

接着是 takeAll() 函数,这是能转走合约中所有余额的函数,其代码如下:

通过对上面两个函数的分析,感觉该合约并没有什么问题,但是我们说了这是个蜜罐,那么它的陷阱到底在哪儿呢?回看下「有趣的智能合约蜜罐(上)」中的 TestBank 蜜罐合约就能知道原因了,它们的原理类似,都是「谁是合约主人」的问题。

KingOfTheHill 中存在着 Owned 和 KingOfTheHill 两个合约,KingOfTheHill 继承了 Owned,为了方便理解,我们将 KingOfTheHill 改写成一个单合约,代码如下:

在改写了合约代码后很容易就可以看出问题所在,用于权限判断的修饰器函数 onlyOwner 中判断的变量是 owner1,而回退函数中修改的是原来子类新定义的 owner,也就是 owner2,这就说明了合约所有者是不会被更改的,调用 takeAll() 函数的人只能是合约创建者。接下来我们通过代码来复现一下。

2.3.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,为了方便我们复现,将回退函数中 withdrawDelay = block.timestamp 5 days; 修改为 withdrawDelay = block.timestamp 0 days;,这样我们在测试的时候就不用等待 5 天后再去尝试取款操作了。

使用地址 0 x5B3 点击「Deploy」部署 KingOfTheHill 合约,点击 owner 查看当前值为 0。

再使用 0 x5B3 携带 10 eth 调用回退函数,向合约中存入 10 个以太币,此时 jackpot 为 10 eth,查看 owned 为 0 x5B3。

攻击者 0 xAb8 设置 msg.value 为 20 eth 调用回退函数,查看 owner 为 0 xAb8。

攻击者发现此时 owner 为自己的地址,符合了 takeAll() 函数的要求,所以去调用 takeAll() 函数,结果发现交易失败,并且自己的余额仍然为 80 eth(初始为 100 eth)。

蜜罐部署者 0 x5B3 发现有人上钩了,合约中已经有了 30 eth,此时虽然 owner 为攻击者地址 0 xAb8(这里的 owner 其实为 owner2,不受修饰器 onlyOwner 的约束), 但是 0 x5B3 调用 takeAll() 函数仍然将合约中的所有余额(10 eth 20 eth)全部转走,查看账户余额,的确增加了 30 eth。

与之类似的智能合约还有 RichestTakeAll:

2.4 以太币竞争游戏:RACEFORETH

2.4.1 蜜罐分析

第四个要介绍的是 RACEFORETH,它译为「以太坊竞争游戏」。

蜜罐的完整代码如下:

蜜罐合约 RACEFORETH 中有一个 SCORE_TO_WIN 参数,其值为 100 finney,字面意思我们也可以知道该参数的作用是胜利的分数,然后合约还有两个映射,其中 racerScore 是竞争者当前得分数,racerSpeedLimit 是每步的限制。竞争者通过每次的转账金额来积累自己的分数 racerScore,当自己的得分 racerScore 大于等于 SCORE_TO_WIN 时就能获得胜利,取走合约创建者一开始存入的奖励 PRIZE。蜜罐合约的核心内容就是 race() 函数和 endRace() 函数,接下来我们分析下这两个函数。

首先是 race() 函数,其代码如下:

用户每次调用 race() 函数都会带入 msg.value,且 msg.value 需要大于 1 wei 和小于步长限制,通过判断后加到自己的总得分数 racerScore 上,接着将新的步长限制设置为当前步长限制的一半,只要总得分数大于等于了获胜目标值就可以取走奖励,初看合约会觉得每次增加的步数在减少,但总有一天会追上,但事实是这样吗?

接着是 endRace() 函数,其代码如下:

合约所有者在上一次竞赛的 3 天后就可以转走合约中所有的余额了。

2.4.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,为了方便我们复现,增加了一个 public nowScore,这样我们在测试的时候就可以看到每次竞赛后的分数了。

使用地址 0 x5B3 点击「Deploy」部署 RACEFORETH 合约。

使用 0 xAb8 作为攻击者,根据代码的要求,第一次最大只能为 50 Finney,所以将 msg.value 也设置为 50 Finney,之后查看当前分数为 50 Finney。

攻击者 0 xAb8 第二次尝试将 msg.value 设置为大于上一次竞赛的 50 Finney 一半的 26 Finney,调用 race() 函数后发现调用失败,原因则是因为我们的 26 Finney 不满足 require 中小于等于上一次竞赛一半的条件。

每次我们都传入上一次最大值的一半,执行多次后发现仍然未到 100 Finney。因为如下的公式只能无限趋于 100 却用于不能等于 100。

其中:

永远是小于 2 的,那么 50 乘上这个式子就永远不可能等于 100 了,也就永远无法到达终点,所以对于该蜜罐合约,即使我们多次调用 race() 函数,每次都转入最大限制值,也不可能达到目标分数,那么我们就不能取出合约中的奖励了。

3. 黑客的漏洞利用

3.1 仅仅是测试?(整数溢出):For_Test

3.1.1 蜜罐分析

第五个要介绍的是 For_Test,它译为「仅仅是测试?」。

蜜罐的完整代码如下:

蜜罐合约 For_Test 的逻辑很简单,核心函数只有 Test() 一个,在该函数中当传入的 msg.value 大于 0.1 eth 时,根据 for 循环的内容,最终会得到 amountToTransfer 的值,也就是说函数调用者会获得 4 倍转入金额的奖励。接下来我们分析函数的主要内容。

仔细分析代码逻辑可以发现 for 循环中 if 判断中有个条件,当条件为真时会跳出循环,但是这个判断条件很诡异,因为 amountToTransfer 初始为 0,在跳出之前 amountToTransfer=multi,而在下一次循环时 multi 变为 2 倍的 i,这就意味着 multi是永远大于 amountToTransfer 的值,相应的这个判断条件不是会永远也不成立了吗?在最终揭秘这个蜜罐合约前我们还需要了解下几个知识。

再次看到 Test() 函数中的循环,msg.value 的最小值为 0.1 eth,而 msg.value*2 的值就会超过 uint8 的取值范围,也就是说此处会存在整形溢出,在 i = 255 时再执行 i 就会导致 i 上溢变为 0,此时的 multi 为 0 从而小于 amountToTransfer 的值,这样就满足了 if 的判断条件,循环也会提前结束。根据代码内容,最终转给调用者的金额为 amountToTransfer=255*2=510 wei ,无论调用者传入了大于 0.1 eth 的任何金额,最后都只会得到 510 wei。

3.1.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,使用地址 0 x5B3 点击「Deploy」部署 For_Test 合约,此时 0 x5B3 的账户余额为 100 eth。

选择 0 xAb8 作为攻击者,将 msg.value 设置为 10 eth,调用 Test() 函数,调用成功后发现账户余额不但没有增加反而减少了刚才传入的 10 eth(但最终会得到 510 wei 的转账)。

当攻击者将代币转入合约后,合约所有者调用 withdraw() 函数进行取款,将刚才攻击者调用 Test() 函数传入的 10 eth 转走,账户余额增加到 110 eth。

与之类似的智能合约还有 Test1:

Github地址:smart-contract-honeypots/Test1.sol

3.2 股息分配(老版本编译器漏洞):DividendDistributor

3.2.1 蜜罐分析

最后一个要介绍的是 DividendDistributor,它译为「股息分配」。

蜜罐的完整代码如下:

蜜罐合约 DividendDistributor 的逻辑不算太难,主要有投资、取钱、计算股息等功能,合约中有一个结构体类型的 investor,其作用为存储投资人的投资信息包括投资额度和股息,并且该结构体通过 mapping 实现账户地址到 investor 的映射。

通篇看来下合约并没有任何的问题,并且如果编译器版本设置正确的话合约也不会出现任何问题。看一下合约关键的函数,invest()、divest()、loggedTransfer() 和 payDividend(),接下来我们就对这 4 个函数进行详细分析。

先是 invest() 函数,其函数功能为用户调用该函数进行投资,每次的投资数量不能小于要求的最低数量 0.4 eth,投资后更新相关的变量。

完整代码如下:

divest() 函数作为和上面的函数刚好相反,是取出自己投资的金额,函数中一开始就要检查调用者投资的数量或者调用函数传入的参数不为 0,接着减去该次取钱操作的金额数量,最后从合约所有者账户中转走 amount 金额给调用者。完整代码如下:

loggedTransfer() 函数的功能非常简单,就是转账和记录转账操作。完整代码如下:

payDividend() 函数为获得由合约所有者设置的股息。完整代码如下:

通过分析上面的 4 个函数,我们发现该蜜罐合约的诱惑点在于投资者不仅能够随时存取投资,还可以通过 payDividend() 函数获取股息,这样的合约好像是有利可图的,然而事实是这是一个陷阱,它利用的就是旧版本编译器中的漏洞,在 Solidity 0.4.12 之前存在一个漏洞,如果将空字符串作为函数调用时的参数那么编译器就会跳过该参数。

而在上面的几个核心函数中,divest() 函数就是存在这样的问题,根据漏洞说明,调用 this.loggedTransfer(amount, "", msg.sender, owner); 后会变成 loggedTransfer(uint amount, bytes32 msg.sender, address owner, address 空) 最终给 owner 用户转账 owner.call.value(amount)()。下面我们就通过代码来复现这个蜜罐合约,揭开它的真面目。

3.2.2 代码复现

将蜜罐合约的代码复制到 Remix IDE 中,将编译器 Solidity 的版本设置为 0.4.11。

选择 0 x5B3 作为合约部署者和所有者,点击「Deploy」进行部署,随后将 VALUE 设置为 10 eth 并调用 distributeDividends 函数设置股息。

将 0 xAb8 作为攻击者,设置 VALUE 为 10 eth 并调用 invest() 函数进行投资。

使用 0 xAb8 调用下图中的函数获取该蜜罐合约的相关信息,包括计算股息,自己的投资数额,最小投资数额,合约所有者 owner,总的股息和总的投资数额。

继续使用 0 xAb8 调用 divest() 函数并设置其传入参数为 5000000000000000000 想要取出刚才投资的 10 eth 的一半,发现该交易被确认,查看该交易的 logs 可以发现和上面我们分析的一样,target 参数变成了 owner 的地址,第二个参数也被 msg.sender 所取代,返回查看账户当前余额,发现刚才调用 divest() 函数取出的 5 eth 被转到了 owner 账户 0 x5B3 中。

4. 总结

通过对以太坊蜜罐智能合约的分析,我们可以发现在智能合约中这些有趣的蜜罐合约更像是钓鱼,通过各种欺骗手法诱使他人将代币转入合约中从而进一步获取这些代币。当然蜜罐合约也不是完全没有学习价值的,我们从蜜罐合约中可以看到合约的攻击思路以及 Solidity 的很多新旧特性。

在平时的合约审计中也需要考虑这些问题,否则这些合约就可能被黑客攻击导致合约代币被盗取。即使是现在,同样有人编写蜜罐合约进行诱骗,只是他们的思路不再仅限于那些想要靠天上掉馅饼获取利益的人,各种机器人也成为了他们的诱骗目标。

所以我们一定要重视合约的功能逻辑,防止合约因为功能逻辑被攻击的同时还要防止合约所有者跑路等各种因素。

5. 文献参考

您可能感兴趣的文章:

相关文章