IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 区块链 -> 有趣的智能合约蜜罐(下) -> 正文阅读

[区块链]有趣的智能合约蜜罐(下)

1. 概述

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

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

  • smart-contract-honey
  • Solidlity-Vulnerable

2. 新颖的赌博游戏

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

2.1 加密轮盘赌轮:CryptoRoulette

2.1.1 蜜罐分析

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

蜜罐的完整代码如下:

// 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,它译为「开发地址彩票」。

蜜罐的完整代码如下:

// 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() 函数,这是用户参与竞猜的函数:

function participate() payable { // 参与竞猜
        if(msg.value<0.1 ether) // 要求携带大不小于 0.1 的以太币
            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 // 查看竞猜者地址生产的随机数是否为 1
            winner[msg.sender] = true; // every address can only win once // 如果符合判断则该竞猜者竞猜成功
            
            uint win=(msg.value/10)*19; //win = 1.9 times the ticket price // 奖金为带入以太币的 1.9 倍
            
            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
    }

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

    function luckyNumberOfAddress(address addr) constant returns(uint n){ // 根据地址生成 luckyNumber
        // calculate the number of current address - 50% chance
        n = uint(keccak256(uint(addr), secretSeed)[0]) % 2; //mod 2 returns either 0 or 1 // 对 2 取余只可能为 0 或者 1
    }

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

 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;
    }

通过上面对合约的分析,看起来合约没有什么问题,中奖率也是 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。

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

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

在这里插入图片描述
攻击者 0x 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,它译为「山丘之王」。

蜜罐的完整代码如下:

// https://etherscan.io/address/0x4dc76cfc65b14b3fd83c8bc8b895482f3cbc150a#code

pragma solidity ^0.4.11;

// Simple Game. Each time you send more than the current jackpot, you become
// owner of the contract. As an owner, you can take the jackpot after a delay
// of 5 days after the last payment.

contract Owned {
    address owner;    function Owned() {
        owner = msg.sender;
    }
    modifier onlyOwner{
        if (msg.sender != owner)
            revert();        _;
    }
}

contract KingOfTheHill is Owned {
    address public owner;
    uint public jackpot;
    uint public withdrawDelay;

    function() public payable {
        // transfer contract ownership if player pay more than current jackpot
        if (msg.value > jackpot) {
            owner = msg.sender;
            withdrawDelay = block.timestamp + 5 days;
        }
        jackpot+=msg.value;
    }

    function takeAll() public onlyOwner {
        require(block.timestamp >= withdrawDelay);
        msg.sender.transfer(this.balance);
        jackpot=0;
    }
}

蜜罐合约 KingOfTheHill 只有 38 行代码,逻辑很简单,有回退函数和 takeAll() 函数,其中 jackpot 变量是传入合约的所有代币之和,每次有用户调用回退函数后如果传入的 mag.valuejackpot 大,就将 owner 的值赋值为 msg.sender。当用户获得了合约所有者权限后,就可以调用 takeAll() 函数在延期时间到后将合约中所有余额转走。接下来重点分析下这两个函数。

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

function() public payable { // 回退函数,用户通过该函数参与合约
	// transfer contract ownership if player pay more than current jackpot
	if (msg.value > jackpot) { // 判断用户调用时带入的以太币是否大于 jackpot
		owner = msg.sender; // 将 owner 设为用户的地址
		withdrawDelay = block.timestamp + 5 days; // 取款时间为 5 天后
	}
	jackpot+=msg.value; // jackpot 的值加上刚才传入的以太
}

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

function takeAll() public onlyOwner { // 取走所有余额的前提是要满足修饰器 onlyOwner 的要求
    require(block.timestamp >= withdrawDelay); // 判断当前时间是否大于了延迟时间
    msg.sender.transfer(this.balance); // 将合约中所有余额转到调用者地址中
    jackpot=0; // 将 jackpot 设置为 0
}

通过对上面两个函数的分析,感觉该合约并没有什么问题,但是我们说了这是个蜜罐,那么它的陷阱到底在哪儿呢?回看下「有趣的智能合约蜜罐(上)」中的 TestBank 蜜罐合约就能知道原因了,它们的原理类似,都是「谁是合约主人」的问题。KingOfTheHill 中存在着 Owned 和 KingOfTheHill 两个合约,KingOfTheHill 继承了 Owned,为了方便理解,我们将 KingOfTheHill 改写成一个单合约,代码如下:

pragma solidity ^0.4.11;

contract KingOfTheHill {
    address owner1;
    
    function Owned() {
        owner1 = msg.sender;
    }
    modifier onlyOwner{
        if (msg.sender != owner1)
            revert();        _;
    }
}

    address public owner2;
    uint public jackpot;
    uint public withdrawDelay;

    function() public payable {
        if (msg.value > jackpot) {
            owner2 = msg.sender;
            withdrawDelay = block.timestamp + 5 days;
        }
        jackpot+=msg.value;
    }

    function takeAll() public onlyOwner {
        require(block.timestamp >= withdrawDelay);
        msg.sender.transfer(this.balance);
        jackpot=0;
    }
}

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

2.3.2 代码复现

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

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

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

在这里插入图片描述
攻击者 0xAb8 设置 msg.value 为 20 eth 调用回退函数,查看 owner 为 0xAb8。

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

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

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

2.4 以太币竞争游戏:RACEFORETH

2.4.1 蜜罐分析

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

蜜罐的完整代码如下:

pragma solidity 0.4.21;

// How fast can you get to 100 points and win the prize?
// First person to deposit 0.1 eth (100 finney) wins the entire prize!
// 1 finney = 1 point

contract RACEFORETH {
    // 100 points to win!
    uint256 public SCORE_TO_WIN = 100 finney;
    uint256 public PRIZE;
    
    // 100 points = 0.1 ether
    // Speed limit: 0.05 eth to prevent insta-win
    // Prevents people from going too fast!
    uint256 public speed_limit = 50 finney;
    
    // Keep track of everyone's score
    mapping (address => uint256) racerScore;
    mapping (address => uint256) racerSpeedLimit;
    
    uint256 latestTimestamp;
    address owner;
    
    function RACEFORETH () public payable {
        PRIZE = msg.value;
        owner = msg.sender;
    }
    
    function race() public payable {
        if (racerSpeedLimit[msg.sender] == 0) { racerSpeedLimit[msg.sender] = speed_limit; }
        require(msg.value <= racerSpeedLimit[msg.sender] && msg.value > 1 wei);
        
        racerScore[msg.sender] += msg.value;
        racerSpeedLimit[msg.sender] = (racerSpeedLimit[msg.sender] / 2);
        
        latestTimestamp = now;
    
        // YOU WON
        if (racerScore[msg.sender] >= SCORE_TO_WIN) {
            msg.sender.transfer(PRIZE);
        }
    }
    
    function () public payable {
        race();
    }
    
    // Pull the prize if no one has raced in 3 days :(
    function endRace() public {
        require(msg.sender == owner);
        require(now > latestTimestamp + 3 days);
        
        msg.sender.transfer(this.balance);
    }
}

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

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

function race() public payable { // 竞赛
    if (racerSpeedLimit[msg.sender] == 0) { racerSpeedLimit[msg.sender] = speed_limit; } //  如果当前调用者的补偿限制为 0,则将其步长最大限制设置为 speed_limit
        require(msg.value <= racerSpeedLimit[msg.sender] && msg.value > 1 wei); // 判断 msg.value 是否小于等于当前步长限制并且 msg.value 要大于 1 wei

        racerScore[msg.sender] += msg.value; // 用户总得分加上本次的 msg.value
        racerSpeedLimit[msg.sender] = (racerSpeedLimit[msg.sender] / 2); // 用户步长限制设置为当前步长限制的一半

        latestTimestamp = now;

        // YOU WON
        if (racerScore[msg.sender] >= SCORE_TO_WIN) { // 如果用户总得分大于等于目标分数
        msg.sender.transfer(PRIZE); // 将奖励金额转给用户
    }
}

用户:

function race() public payable { // 竞赛
    if (racerSpeedLimit[msg.sender] == 0) { racerSpeedLimit[msg.sender] = speed_limit; } //  如果当前调用者的补偿限制为 0,则将其步长最大限制设置为 speed_limit
        require(msg.value <= racerSpeedLimit[msg.sender] && msg.value > 1 wei); // 判断 msg.value 是否小于等于当前步长限制并且 msg.value 要大于 1 wei

        racerScore[msg.sender] += msg.value; // 用户总得分加上本次的 msg.value
        racerSpeedLimit[msg.sender] = (racerSpeedLimit[msg.sender] / 2); // 用户步长限制设置为当前步长限制的一半

        latestTimestamp = now;

        // YOU WON
        if (racerScore[msg.sender] >= SCORE_TO_WIN) { // 如果用户总得分大于等于目标分数
        msg.sender.transfer(PRIZE); // 将奖励金额转给用户
    }
}

用户其代码如下:

function endRace() public { // 结束竞赛
    require(msg.sender == owner); // 要求调用者为当前合约所有者
    require(now > latestTimestamp + 3 days); // 要求当前时间大于上次竞赛后的 3 天

    msg.sender.transfer(this.balance); // 将合约所有余额转给调用者
}

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

2.4.2 代码复现

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

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

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

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

在这里插入图片描述

每次我们都传入上一次最大值的一半,执行多次后发现仍然未到 100 Finney。因为如下的公式只能无限趋于 100 却用于不能等于 100。
50 + 50 × 1 2 + 50 × ( 1 2 ) 2 + . . . + 50 × ( 1 2 ) n = 50 × ( 1 + 1 2 + 1 4 + . . . + ( 1 2 ) n ) 50 + 50 ×\frac{1}{2}+50×(\frac{1}{2})^2+...+50×(\frac{1}{2})^n=50×(1+\frac{1}{2}+\frac{1}{4}+...+(\frac{1}{2})^n) 50+50×21?+50×(21?)2+...+50×(21?)n=50×(1+21?+41?+...+(21?)n)
其中:
1 + 1 2 + 1 4 + . . . + ( 1 2 ) n 1+\frac{1}{2}+\frac{1}{4}+...+(\frac{1}{2})^n 1+21?+41?+...+(21?)n
永远是小于 2 的,那么 50 乘上这个式子就永远不可能等于 100 了,也就永远无法到达终点,所以对于该蜜罐合约,即使我们多次调用 race() 函数,每次都转入最大限制值,也不可能达到目标分数,那么我们就不能取出合约中的奖励了。

在这里插入图片描述

3. 黑客的漏洞利用

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

3.1.1 蜜罐分析

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

蜜罐的完整代码如下:

// https://etherscan.io/address/0x2eCF8D1F46DD3C2098de9352683444A0B69Eb229#code

pragma solidity ^0.4.19;

contract For_Test
{
    address owner = msg.sender;
    
    function withdraw()
    payable
    public
    {
        require(msg.sender==owner);
        owner.transfer(this.balance);
    }
    
    function() payable {}
    
    function Test()
    payable
    public
    {
        if(msg.value> 0.1 ether)
        {
            uint256 multi =0;
            uint256 amountToTransfer=0;
             
            
            for(var i=0;i<msg.value*2;i++)
            {
                multi=i*2;
                
                if(multi<amountToTransfer)
                {
                  break;  
                }
                else
                {
                    amountToTransfer=multi;
                }
            }    
            msg.sender.transfer(amountToTransfer);
        }
    }
}

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

Test() 函数的代码如下:

function Test()
    payable
    public
    {
        if(msg.value> 0.1 ether) // 要求 msg.value 必须大于 0.1 eth
        {
            uint256 multi =0;
            uint256 amountToTransfer=0;
             
            
            for(var i=0;i<msg.value*2;i++) // i 小于 msg.value*2 则执行下面内容
            {
                multi=i*2; // multi 的值为 2 倍的 i
                
                if(multi<amountToTransfer) // multi 小于 amountToTransfer 则 break 跳出循环
                {
                  break;  
                }
                else
                {
                    amountToTransfer=multi; // 否则将 amountToTransfer 设置为 multi
                }
            }    
            msg.sender.transfer(amountToTransfer); // 将 amountToTransfer 的值转给调用者
        }
    }

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

  • msg.value 的单位是 wei,而 1 eth = 1018 wei。
  • 当一个参数变量被定义为 var 时,其数据类型为 uint8,其取值范围为 [0,255]。

再次看到 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 中,使用地址 0x5B3 点击「Deploy」部署 For_Test 合约,此时 0x5B3 的账户余额为 100 eth。

在这里插入图片描述
选择 0xAb8 作为攻击者,将 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,它译为「股息分配」。

蜜罐的完整代码如下:

// https://etherscan.io/address/0x858c9eaf3ace37d2bedb4a1eb6b8805ffe801bba


pragma solidity ^0.4.0;

contract Ownable {
  address public owner;
  function Ownable() public {
    owner = msg.sender;
  }

  modifier onlyOwner() {
    if (msg.sender != owner)
        throw;
    _;
  }
  
  modifier protected() {
      if(msg.sender != address(this))
        throw;
      _;
  }

  function transferOwnership(address newOwner) public onlyOwner {
    if (newOwner == address(0))
        throw;
    owner = newOwner;
  }
}

contract DividendDistributorv3 is Ownable{
    event Transfer(
        uint amount,
        bytes32 message,
        address target,
        address currentOwner
    );
    
    struct Investor {
        uint investment;
        uint lastDividend;
    }

    mapping(address => Investor) investors;

    uint public minInvestment;
    uint public sumInvested;
    uint public sumDividend;
    
    function DividendDistributorv3() public{ 
        minInvestment = 0.4 ether;
    }
    
    function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner) protected
    {
        if(! target.call.value(amount)() )
            throw;
        Transfer(amount, message, target, currentOwner);
    }
    
    function invest() public payable {
        if (msg.value >= minInvestment)
        {
            sumInvested += msg.value;
            investors[msg.sender].investment += msg.value;
            // manually call payDividend() before reinvesting, because this resets dividend payments!
            investors[msg.sender].lastDividend = sumDividend;
        }
    }

    function divest(uint amount) public {
        if ( investors[msg.sender].investment == 0 || amount == 0)
            throw;
        // no need to test, this will throw if amount > investment
        investors[msg.sender].investment -= amount;
        sumInvested -= amount; 
        this.loggedTransfer(amount, "", msg.sender, owner);
    }

    function calculateDividend() constant public returns(uint dividend) {
        uint lastDividend = investors[msg.sender].lastDividend;
        if (sumDividend > lastDividend)
            throw;
        // no overflows here, because not that much money will be handled
        dividend = (sumDividend - lastDividend) * investors[msg.sender].investment / sumInvested;
    }
    
    function getInvestment() constant public returns(uint investment) {
        investment = investors[msg.sender].investment;
    }
    
    function payDividend() public {
        uint dividend = calculateDividend();
        if (dividend == 0)
            throw;
        investors[msg.sender].lastDividend = sumDividend;
        this.loggedTransfer(dividend, "Dividend payment", msg.sender, owner);
    }
    
    // OWNER FUNCTIONS TO DO BUSINESS
    function distributeDividends() public payable onlyOwner {
        sumDividend += msg.value;
    }
    
    function doTransfer(address target, uint amount) public onlyOwner {
        this.loggedTransfer(amount, "Owner transfer", target, owner);
    }
    
    function setMinInvestment(uint amount) public onlyOwner {
        minInvestment = amount;
    }
    
    function () public payable onlyOwner {
    }

    function destroy() public onlyOwner {
        selfdestruct(msg.sender);
    }
}

蜜罐合约 DividendDistributor 的逻辑不算太难,主要有投资、取钱、计算股息等功能,合约中有一个结构体类型的 investor,其作用为存储投资人的投资信息包括投资额度和股息,并且该结构体通过 mapping 实现账户地址到 investor 的映射。通篇看来下合约并没有任何的问题,并且如果编译器版本设置正确的话合约也不会出现任何问题。看一下合约关键的函数,invest()divest()loggedTransfer()payDividend(),接下来我们就对这 4 个函数进行详细分析。

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

function invest() public payable { // 投资
    if (msg.value >= minInvestment) // 要求 msg.value 不小于最低投资数
    {
        sumInvested += msg.value; // 总投资数加上该次投资的
        investors[msg.sender].investment += msg.value; // 调用者投资数也加上该次投资的
        // manually call payDividend() before reinvesting, because this resets dividend payments!
        investors[msg.sender].lastDividend = sumDividend; // 调用者最后一次股息分红为总的股息
    }
}

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

function divest(uint amount) public { // 取钱
    if ( investors[msg.sender].investment == 0 || amount == 0) // 要求调用者投资的数量和该次取出的数量都不为 0
    	throw;
    // no need to test, this will throw if amount > investment
    investors[msg.sender].investment -= amount; // 调用者投资的数量减去本次取出的数量
    sumInvested -= amount;  // 总投资数也减去本次取出的数量
    this.loggedTransfer(amount, "", msg.sender, owner); // 从合约所有者账户中转走 amount 金额给调调用者
}

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

 function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner) protected
 { // 记录转账操作
     if(! target.call.value(amount)() ) // 转账给 target 地址
     	throw; // 失败则抛出异常
     Transfer(amount, message, target, currentOwner);
 }

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

function payDividend() public { // 获取股息
    uint dividend = calculateDividend(); // 计算股息
    if (dividend == 0) // 股息为 0 则抛出异常
    	throw;
    investors[msg.sender].lastDividend = sumDividend; // 调用者最后一次股息为总股息
    this.loggedTransfer(dividend, "Dividend payment", msg.sender, owner); // 从合约所有者账户中转走 dividend 股息给调调用者
}

通过分析上面的 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。

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

在这里插入图片描述
将 0xAb8 作为攻击者,设置 VALUE 为 10 eth 并调用 invest() 函数进行投资。

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

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

在这里插入图片描述

4. 总结

通过对以太坊蜜罐智能合约的分析,我们可以发现在智能合约中这些有趣的蜜罐合约更像是钓鱼,通过各种欺骗手法诱使他人将代币转入合约中从而进一步获取这些代币。当然蜜罐合约也不是完全没有学习价值的,我们从蜜罐合约中可以看到合约的攻击思路以及 Solidity 的很多新旧特性,在平时的合约审计中也需要考虑这些问题,否则这些合约就可能被黑客击导致合约代币被盗取。即使是现在,同样有人编写蜜罐合约进行诱骗,只是他们的思路不再仅限于那些想要靠天上掉馅饼获取利益的人,各种机器人也成为了他们的诱骗目标。所以我们一定要重视合约的功能逻辑,防止合约因为功能逻辑被攻击的同时还要防止合约所有者跑路等各种因素。

5. 文献参考

  区块链 最新文章
盘点具备盈利潜力的几大加密板块,以及潜在
阅读笔记|让区块空间成为商品,打造Web3云
区块链1.0-比特币的数据结构
Team Finance被黑分析|黑客自建Token“瞒天
区块链≠绿色?波卡或成 Web3“生态环保”标
期货从入门到高深之手动交易系列D1课
以太坊基础---区块验证
进入以太坊合并的五个数字
经典同态加密算法Paillier解读 - 原理、实现
IPFS/Filecoin学习知识科普(四)
上一篇文章      下一篇文章      查看所有文章
加:2022-01-24 10:55:15  更:2022-01-24 10:57:24 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 22:28:31-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码