区块链安全—守株待兔的蜜罐合约(二)
一、前言
在前一篇的蜜罐合约中,我们介绍并测试了部分由于继承等问题而搭建的蜜罐合约。蜜罐合约顾名思义,就是利用了受害者的投机想法,从而另普通用户自行进行转账的行为。在我们文章中演示的相关合约对owner友好,即普通用户很难从合约中获得利益,所以读者如果看到类似的合约请不要轻易的使用以太币进行尝试。
而本文中,我们在蜜罐合约之上分析由Solidity的结构体产生的漏洞,而此漏洞危害性极大,倘若合约开发不到位会导致owner的篡改,即普通用户的提权操作。
二、由合约漏洞而导致的蜜罐
1 蜜罐合约介绍
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();
}
}
下面我们简单的分析一下这个类彩票合约。
为何称这个合约为蜜罐合约么?我们根据合约内容可以知道,合约在起始时赋值LuckyNumber
为1,而在参与函数中根据参与者的地址生成随机数0 or 1
,之后如果为1,那么就返还value * 1.9的赌金。看似0.5的高概率,但是合约利用了一种以太坊的bug,从而导致用户永远不可能取到钱。下面请看我们的分析。
首先,合约定义了一个结构体。(我认为本来不需要结构体这样的类型来进行随机数的生成,所以我觉得这里的结构体是为了触发合约的漏洞)
struct SeedComponents{
uint component1;
uint component2;
uint component3;
uint component4;
}
之后定义了五个变量,分别代表合约的owner、随机数种子、上一次的记录值、幸运数、竞猜获胜者集合
。
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
}
构造函数将owner
赋初值为合约创建者,之后调用reseed
函数。而我们下面就看一看这个函数的作用。
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;
}
在这个函数中,我们会传入components
结构体,并使用keccak256 ()哈希函数
更新secretSeed
的值,并初始化lastReseed
。
也就是说,我们在构造函数中调用此函数来更新secretSeed
的值。
之后,我们来看participate()
,此函数是用户调用参与接口,用于竞猜的环节。
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
}
在函数中,我们看到用户必须传入value >= 0.1 eth
,并且用户还未赢得过奖励。之后合约会将LuckyNumber
与luckyNumberOfAddress(msg.sender)
进行比较。倘若两者的值相等,那么记录下该用户的中奖记录并进行【value * 1.9】的转账奖励(余额不足的将所有余额转入)。
而我们在看luckyNumberOfAddress
函数。
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
}
传入一个地址,之后根据传入的地址产生随机数,并%2,得到1或0 。
然后是一个测试函数forceReseed
。
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
}
合约创建者在这个函数后面添加了注释//reseed initiated by the owner - for testing purposes
。表达用于测试的目的。
然而问题就是出在这个地方。
整体来看,这个合约并没有什么问题。gamble的过程也十分清晰。
image.png然而我们进行一个合约测试。
2 攻击手段分析
我们先看一个测试合约:
pragma solidity ^0.4.24;
contract test
{
address public addr = 0xa;
uint public b = 555;
uint256 public c = 666;
bytes public d = "abcd";
struct Seed{
uint256 component1;
uint256 component2;
uint256 component3;
uint256 component4;
}
function change() public{
Seed s;
s.component1 = 1;
s.component2 = 2;
s.component3 = 3;
s.component4 = 4;
}
}
在这个合约中,我们设置了4个变量,而这四个变量均有初始值。之后我们又设置了结构体Seed
。在这个结构体中拥有四个变量,而我们在test()
函数中初始化结构体并赋初值,之后我们看看效果。
部署合约:
image.png
查看变量内容:
image.png之后我们调用change
函数。并查看,发现我们的变量被修改了,而修改的内容就是结构体中的内容。
这就是我们的漏洞所在。
我们的合约中并没有修改变量的值,但是由于solidity机制的问题而导致了变量修改问题。
而这个漏洞对我们上述介绍的蜜罐合约有什么影响呢?我们进行一下测试。
为了方便我们查看测试效果,我们为LuckyNumber
添加查看函数。
倘若此时owner
不进行任何操作,任凭用户进行下一步的赌博,那么用户还是有很大的概率获得奖励的。例如:(为了方便演示,我在函数中添加了event事件)
emit back(msg.sender,win,true);
。
此时我们能够看到,LuckyNumber
是初始值1 。
之后,我们更换用户进行参与。我们投入1 eth进行竞猜。
第一次:
image.png没有获得奖励,所以1 eth赔进去了。
继续更换用户参与:
image.png直到最后一个用户:
image.png我们得到了奖励金1900000000000000000 wei
,所以竞猜成功。
而我们大致能够发现,其实我们是拥有很大的概率获得奖励的。这个合约真的就是拼概率的传统赌博合约吗?然而事实并非如此。
根据我们前文所测试的漏洞,这个合约中同样存在恶意篡改的行为。我们发现了合约中其实存在着结构体
。
而这个结构体在合约中存在修改函数:
image.png所以,如果owner
调用了此函数,那么会不会发起漏洞从而将竞猜值恶意修改呢?
我们更换地址为owner,并且调用此函数。
image.png我们惊奇的发现,果然此时的竞猜值从1变成了7 。
而我们合约中的判断条件是luckyNumberOfAddress(msg.sender) == LuckyNumber
。而我们函数中luckyNumberOfAddress(msg.sender)
只能是0或者1两种可能。这里的LuckNumber是7,也就是说无论我们如何竞猜,永远都不会成功。
三、赌博?庄家永远更胜一筹
在看完上述的高级蜜罐后,我们来看一下常规的蜜罐合约。
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 { }
}
为什么说蜜罐的owner更胜一筹呢?我们在阅读了合约的所有函数内容后就知道,在合约中的shuffle()
函数%20,也就意味着它最后的范围是0~19
,而用户能够传入的数是多少呢?在play()
函数中,用户需要传入一个number
,而其规定值<=10。
其概率值相对来说还是极低的。并且在一天之后,倘若用户还未猜对那么owner便可以调用kill()
函数进行自杀操作。将余额转入到自己的账户中。
四、参考链接
本稿为原创稿件,转载请标明出处。谢谢。