一、前言

在前一篇的蜜罐合约中,我们介绍并测试了部分由于继承等问题而搭建的蜜罐合约。蜜罐合约顾名思义,就是利用了受害者的投机想法,从而另普通用户自行进行转账的合约。在我们文章中演示的相关合约对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,并且用户还未赢得过奖励。之后合约会将LuckyNumberluckyNumberOfAddress(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的过程也十分清晰。

然而我们进行一个合约测试。

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()函数中初始化结构体并赋初值,之后我们看看效果。

部署合约:

查看变量内容:

之后我们调用change函数。并查看,发现我们的变量被修改了,而修改的内容就是结构体中的内容。

这就是我们的漏洞所在。

我们的合约中并没有修改变量的值,但是由于solidity机制的问题而导致了变量修改问题。

而这个漏洞对我们上述介绍的蜜罐合约有什么影响呢?我们进行一下测试。

为了方便我们查看测试效果,我们为LuckyNumber添加查看函数。

倘若此时owner不进行任何操作,任凭用户进行下一步的赌博,那么用户还是有很大的概率获得奖励的。例如:(为了方便演示,我在函数中添加了event事件)

emit back(msg.sender,win,true);

此时我们能够看到,LuckyNumber是初始值1 。

之后,我们更换用户进行参与。我们投入1 eth进行竞猜。

第一次:

没有获得奖励,所以1 eth赔进去了。

继续更换用户参与:

直到最后一个用户:

我们得到了奖励金1900000000000000000 wei,所以竞猜成功。

而我们大致能够发现,其实我们是拥有很大的概率获得奖励的。这个合约真的就是拼概率的传统赌博合约吗?然而事实并非如此。

根据我们前文所测试的漏洞,这个合约中同样存在恶意篡改的行为。我们发现了合约中其实存在着结构体

而这个结构体在合约中存在修改函数:

所以,如果owner调用了此函数,那么会不会发起漏洞从而将竞猜值恶意修改呢?

我们更换地址为owner,并且调用此函数。

我们惊奇的发现,果然此时的竞猜值从1变成了7 。

而我们合约中的判断条件是luckyNumberOfAddress(msg.sender) == LuckyNumber。而我们函数中luckyNumberOfAddress(msg.sender)只能是0或者1两种可能。这里的LuckNumber是7,也就是说无论我们如何竞猜,永远都不会成功。

三、赌博?庄家永远更胜一筹

在看完上述的高级蜜罐后,我们来看一下常规的蜜罐合约。

合约地址为:https://etherscan.io/address/0x94602b0E2512DdAd62a935763BF1277c973B2758#code

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()函数进行自杀操作。将余额转入到自己的账户中。

四、参考链接

本稿为原创稿件,转载请标明出处。谢谢。

源链接

Hacking more

...