1. 概述
在有趣的智能合约蜜罐(上)中我们对古老的欺骗手段和神奇的逻辑漏洞进行了讲解和复现,在下部分中我们将会对新颖的赌博游戏和黑客的漏洞利用进行讲解以及复现,从而进一步增加对智能合约蜜罐的了解。
同样的,所有的智能合约蜜罐代码都可以 GitHub 上找到,这里再次给出他们的网址:
- smart-contract-honey
- Solidlity-Vulnerable
2. 新颖的赌博游戏
赌博行业从古至今一直存在,而区块链的去中心化似乎给赌博行业带了新的机会,它的进入会让人们觉得赌博变得公平,然而我们都知道赌博结果往往都是必输,那么接下来就通过分析四个基于区块链的赌博游戏合约来介绍庄家是如何最后稳赢的。
2.1 加密轮盘赌轮:CryptoRoulette
2.1.1 蜜罐分析
第一个要介绍的是 CryptoRoulette,它译为「加密轮盘赌轮」。
- GutHub 地址:smart-contract-honeypots/CryptoRoulette.sol
- Etherscan 地址:CryptoRoulette | 0x94602b0E2512DdAd62a935763BF1277c973B2758
蜜罐的完整代码如下:
// https://github.com/misterch0c/Solidlity-Vulnerable/blob/master/traps/CryptoRoulette.sol
// https://etherscan.io/address/0x94602b0E2512DdAd62a935763BF1277c973B2758
pragma solidity ^0.4.19;
// CryptoRoulette
//
// Guess the number secretly stored in the blockchain and win the whole contract balance!
// A new number is randomly chosen after each try.
//
// To play, call the play() method with the guessed number (1-20). Bet price: 0.1 ether
contract CryptoRoulette {
uint256 private secretNumber;
uint256 public lastPlayed;
uint256 public betPrice = 0.1 ether;
address public ownerAddr;
struct Game {
address player;
uint256 number;
}
Game[] public gamesPlayed;
function CryptoRoulette() public {
ownerAddr = msg.sender;
shuffle();
}
function shuffle() internal {
// randomly set secretNumber with a value between 1 and 20
secretNumber = uint8(sha3(now, block.blockhash(block.number-1))) % 20 + 1;
}
function play(uint256 number) payable public {
require(msg.value >= betPrice && number <= 10);
Game game;
game.player = msg.sender;
game.number = number;
gamesPlayed.push(game);
if (number == secretNumber) {
// win!
msg.sender.transfer(this.balance);
}
shuffle();
lastPlayed = now;
}
function kill() public {
if (msg.sender == ownerAddr && now > lastPlayed + 1 days) {
suicide(msg.sender);
}
}
function() public payable { }
}
该合约设置了一个私有属性的随机数 secretNumber
,在 shuffle()
函数中被指定范围在 1 - 20,玩家可以通过 play()
函数去盲猜这个随机数,如果猜对了就可以将合约中的所有钱取走,每次调用 play()
函数后都会重置随机数。这么看来这个合约好像没有什么问题,随着猜错的玩家越来越多,合约中的代币余额也会积累的越多,如果碰巧猜对了就可以获取所有的奖金,然而事实是这样的嘛?我们可以看到在这个蜜罐合约中,最重要的就是 shuffle()
和 play()
这两个函数,下面就来分析下这两个函数。
初始的 secretNumber
是在构造函数 CryptoRoulette
中调用 shuffle()
函数,而 shuffle()
函数中只有一行代码,就是设置 secretNumber
的值,从代码中也可以看出 secretNumber
的值既和区块的数目有关,也和时间有关。函数代码如下:
function shuffle() internal { // 设置随机数 secretNumber
// randomly set secretNumber with a value between 1 and 20
secretNumber = uint8(sha3(now, block.blockhash(block.number-1))) % 20 + 1; // 对 20 取余 再加 1,所以范围在 1 - 20
}
而 play()
函数就是提供给用户进行赌博来猜这个随机数的,玩家携带不小于 0.1 eth 并传入自己猜的数字 number
,玩家猜的这个数字 number
去和 secretNumber
进行比较,如果相等就可以获胜,转走合约中的所有以太币,但是在函数的开头中有一个检查 require,其中后面要求玩家猜的数字不能大于 10,而 secretNumber
我们在上面的函数中讲到范围是 1 - 20,这样看来虽然加大了难度,但是也存在猜对可能性,然而事实是 secretNumber
一定会大于 10,玩家永远都不可能猜对数字,合约所有者却可以通过调用 kill()
函数转走合约中的所有以太币。函数代码如下:
function play(uint256 number) payable public { // 玩游戏竞猜数字
require(msg.value >= betPrice && number <= 10); // 要求 msg.value 不小于 0.1 eth 且 number 要不大于 10
Game game;
game.player = msg.sender; // 游戏玩家为调用者
game.number = number; // 游戏的数字为 number
gamesPlayed.push(game); // 加入游戏列表
if (number == secretNumber) { // 如果 number 为 secretNumber 则游戏胜利
// win!
msg.sender.transfer(this.balance); // 将合约中所有的以太币转给调用者
}
shuffle(); // 执行 shuffle 重置随机数
lastPlayed = now; // 设置最后一个玩的时间为现在
}
这里会有人问了,secretNumber
为啥一定会大于 10 呢?原因就是结构体 game 的初始化对存储数据 secretNumber
的覆盖,我们在函数里直接初始化结构体必须加 memory 关键字,因为 memory 是使用内存来进行存储,这样一来就可以避免占用 storage 的存储位,而蜜罐合约中并未使用 memory 关键字,从而导致了变量覆盖。该问题在 Solidity 0.5.0 版本以前只是进行了提示,并没有做出错误警告,所以在老版本编译器中要注意该问题。在下面的代码复现中可以看到问题所在。
2.1.2 代码复现
将蜜罐合约的代码复制到 Remix IDE 中,为了方便我们查看 secretNumber
的值,我们将 secretNumber
的类型设置为 public,这样就可以在 Remix IDE 中直接看到它的值了。甚至有些蜜罐部署者为了诱惑攻击者来攻击合约,也可以设置为 public 属性,因为就算告诉攻击者 secretNumber
的值他也不能猜对这个数字。
使用地址 0x5B3 点击「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,它译为「开发地址彩票」。
- GutHub 地址:Solidlity-Vulnerable/OpenAddressLottery.sol
- Etherscan 地址:OpenAddressLottery | 0xd1915A2bCC4B77794d64c4e483E43444193373Fa
蜜罐的完整代码如下:
// https://etherscan.io/address/0xd1915A2bCC4B77794d64c4e483E43444193373Fa
pragma solidity ^0.4.19;
/*
* This is a distributed lottery that chooses random addresses as lucky addresses. If these
* participate, they get the jackpot: 1.9 times the price of their bet.
* Of course one address can only win once. The owner regularly reseeds the secret
* seed of the contract (based on which the lucky addresses are chosen), so if you did not win,
* just wait for a reseed and try again!
*
* Jackpot chance: 50%
* Ticket price: Anything larger than (or equal to) 0.1 ETH
* Jackpot size: 1.9 times the ticket price
*
* HOW TO PARTICIPATE: Just send any amount greater than (or equal to) 0.1 ETH to the contract's address
* Keep in mind that your address can only win once
*
* If the contract doesn't have enough ETH to pay the jackpot, it sends the whole balance.
*
* Example: For each address, a random number is generated, either 0 or 1. This number is then compared
* with the LuckyNumber - a constant 1. If they are equal, the contract will instantly send you the jackpot:
* your bet multiplied by 1.9 (House edge of 0.1)
*/
contract OpenAddressLottery{
struct SeedComponents{
uint component1;
uint component2;
uint component3;
uint component4;
}
address owner; //address of the owner
uint private secretSeed; //seed used to calculate number of an address
uint private lastReseed; //last reseed - used to automatically reseed the contract every 1000 blocks
uint LuckyNumber = 1; //if the number of an address equals 1, it wins
mapping (address => bool) winner; //keeping track of addresses that have already won
function OpenAddressLottery() {
owner = msg.sender;
reseed(SeedComponents((uint)(block.coinbase), block.difficulty, block.gaslimit, block.timestamp)); //generate a quality random seed
}
function participate() payable {
if(msg.value<0.1 ether)
return; //verify ticket price
// make sure he hasn't won already
require(winner[msg.sender] == false);
if(luckyNumberOfAddress(msg.sender) == LuckyNumber){ //check if it equals 1
winner[msg.sender] = true; // every address can only win once
uint win=(msg.value/10)*19; //win = 1.9 times the ticket price
if(win>this.balance) //if the balance isnt sufficient...
win=this.balance; //...send everything we've got
msg.sender.transfer(win);
}
if(block.number-lastReseed>1000) //reseed if needed
reseed(SeedComponents((uint)(block.coinbase), block.difficulty, block.gaslimit, block.timestamp)); //generate a quality random seed
}
function luckyNumberOfAddress(address addr) constant returns(uint n){
// calculate the number of current address - 50% chance
n = uint(keccak256(uint(addr), secretSeed)[0]) % 2; //mod 2 returns either 0 or 1
}
function reseed(SeedComponents components) internal {
secretSeed = uint256(keccak256(
components.component1,
components.component2,
components.component3,
components.component4
)); //hash the incoming parameters and use the hash to (re)initialize the seed
lastReseed = block.number;
}
function kill() {
require(msg.sender==owner);
selfdestruct(msg.sender);
}
function forceReseed() { //reseed initiated by the owner - for testing purposes
require(msg.sender==owner);
SeedComponents s;
s.component1 = uint(msg.sender);
s.component2 = uint256(block.blockhash(block.number - 1));
s.component3 = block.difficulty*(uint)(block.coinbase);
s.component4 = tx.gasprice * 7;
reseed(s); //reseed
}
function () payable { //if someone sends money without any function call, just assume he wanted to participate
if(msg.value>=0.1 ether && msg.sender!=owner) //owner can't participate, he can only fund the jackpot
participate();
}
}
蜜罐合约 OpenAddressLottery 的游戏逻辑很简单,合约中有一个初始值为 1 的状态变量 LuckyNumber
,竞猜者每次竞猜时都会根据其地址随即生成 0 或者 1,如果生成的值和 LuckyNumber
一样,那么竞猜者就可以获得 1.9 倍的奖金,且每个地址只能赢得一次游戏胜利,之后将无法继续参加竞猜。该蜜罐合约的重点就在于 participate()
、luckyNumberOfAddress()
和 forceReseed()
函数,下面来对这 3 个函数进行依次讲解。
首先是 participate()
函数,这是用户参与竞猜的函数: