Solidity 安全:已知攻擊方法和常見防禦模式綜合列表

FLy_鵬程萬里發表於2019-02-01

雖然處於起步階段,但是 Solidity 已被廣泛採用,並被用於編譯我們今天看到的許多以太坊智慧合約中的位元組碼。相應地,開發者和使用者也獲得許多嚴酷的教訓,例如發現語言和EVM的細微差別。這篇文章旨在作為一個相對深入和最新的介紹性文章,詳述 Solidity 開發人員曾經踩過的坑,避免後續開發者重蹈覆轍。

 

重入漏洞

以太坊智慧合約的特點之一是能夠呼叫和利用其他外部合約的程式碼。合約通常也處理Ether,因此通常會將Ether傳送給各種外部使用者地址。呼叫外部合約或將乙太網傳送到地址的操作需要合約提交外部呼叫。這些外部呼叫可能被攻擊者劫持,迫使合約執行進一步的程式碼(即通過回退函式),包括回撥自身。因此程式碼執行“ 重新進入 ”合約。這種攻擊被用於臭名昭著的DAO攻擊。

有關重入攻擊的進一步閱讀,請參閱重入式對智慧合約Consensus - 以太坊智慧合約最佳實踐。 

漏洞

當合約將ether傳送到未知地址時,可能會發生此攻擊。攻擊者可以在fallback函式中的外部地址處構建一個包含惡意程式碼的合約。因此,當合約向此地址傳送ether時,它將呼叫惡意程式碼。通常,惡意程式碼在易受攻擊的合約上執行一項功能,執行開發人員不希望的操作。“重入”這個名稱來源於外部惡意合約回覆了易受攻擊合約的功能,並在易受攻擊的合約的任意位置“ 重新輸入”了程式碼執行。

為了澄清這一點,請考慮簡單易受傷害的合約,該合約充當以太坊保險庫,允許存款人每週只提取1個Ether。

EtherStore.sol: 

contract EtherStore {    
    uint256 public withdrawalLimit = 1 ether;    
    mapping(address => uint256) public lastWithdrawTime;    
    mapping(address => uint256) public balances;    
    
    function depositFunds() public payable {
        balances[msg.sender] += msg.value;
    }    
    
    function withdrawFunds (uint256 _weiToWithdraw) public {        
        require(balances[msg.sender] >= _weiToWithdraw);        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);            // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);        
        require(msg.sender.call.value(_weiToWithdraw)());
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
    }
 }

該合約有兩個公共職能。depositFunds()withdrawFunds()。該depositFunds()功能只是增加發件人餘額。該withdrawFunds()功能允許發件人指定要撤回的wei的數量。如果所要求的退出金額小於1Ether並且在上週沒有發生撤回,它才會成功。還是呢?...

該漏洞出現在[17]行,我們向使用者傳送他們所要求的以太數量。考慮一個惡意攻擊者建立下列合約,

Attack.sol: 

import "EtherStore.sol";
contract Attack {
  EtherStore public etherStore;                         // intialise the etherStore variable with the contract address
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }  
  function pwnEtherStore() public payable {           // attack to the nearest ether
      require(msg.value >= 1 ether);                  // send eth to the depositFunds() function
      etherStore.depositFunds.value(1 ether)();      // start the magic
      etherStore.withdrawFunds(1 ether);
  }  
  function collectEther() public {      
      msg.sender.transfer(this.balance);
  }    
  // fallback function - where the magic happens
  function () payable {     
      if (etherStore.balance > 1 ether) {
          etherStore.withdrawFunds(1 ether);
      }
  }
}

讓我們看看這個惡意合約是如何利用我們的EtherStore合約的。攻擊者可以0x0...123使用EtherStore合約地址作為建構函式引數來建立上述合約(假設在地址中)。這將初始化並將公共變數etherStore指向我們想要攻擊的合約。

然後攻擊者會呼叫這個pwnEtherStore()函式,並且有一些以太(大於或等於1),1 ether這個例子可以說。在這個例子中,我們假設一些其他使用者已經將以太幣存入這份合約中,這樣它的當前餘額就是10 ether。然後會發生以下情況: 

  1. Attack.sol -Line[15] -的depositFunds()所述EtherStore合約的功能將與被叫msg.value1 ether(和大量gas)。sender(msg.sender)將是我們的惡意合約(0x0...123)。因此,balances[0x0..123] = 1 ether

  2. Attack.sol - Line [17] - 惡意合約將使用一個引數來呼叫合約的withdrawFunds()功能。這將通過所有要求(合約的行[12] - [16] ),因為我們以前沒有提款。

  3. EtherStore.sol - 行[17] - 合約將傳送1 ether回惡意合約。

  4. Attack.sol - Line [25] - 傳送給惡意合約的乙太網將執行後備功能。

  5. Attack.sol - Line [26] - EtherStore合約的總餘額是10 ether,現在9 ether是這樣,如果宣告通過。

  6. Attack.sol - Line [27] - 回退函式然後EtherStore withdrawFunds()再次呼叫該函式並“ 重新輸入 ” EtherStore合約。

  7. EtherStore.sol - 行[11] - 在第二次呼叫時withdrawFunds(),我們的餘額仍然1 ether是行[18]尚未執行。因此,我們仍然有balances[0x0..123] = 1 ether。lastWithdrawTime變數也是這種情況。我們再次通過所有要求。

  8. EtherStore.sol - 行[17] - 我們撤回另一個1 ether。

步驟4-8將重複 - 直到EtherStore.balance >= 1[26]行所指定的Attack.sol。
 

  1. Attack.sol - Line [26] - 一旦在EtherStore合約中留下少於1(或更少)的ether,此if語句將失敗。這樣就EtherStore可以執行合約的[18]和[19]行(每次呼叫withdrawFunds()函式)。

  2. EtherStore.sol - 行[18]和[19] - balances和lastWithdrawTime對映將被設定並且執行將結束。

最終的結果是,攻擊者已經從EtherStore合約中立即撤銷了所有(第1條)乙太網,只需一筆交易即可。

預防技術

有許多常用技術可以幫助避免智慧合約中潛在的重入漏洞。首先是(在可能的情況下)在將ether傳送給外部合約時使用內建的transfer()函式。轉賬功能只傳送2300 gas不足以使目的地地址/合約呼叫另一份合約(即重新輸入傳送合約)。

第二種技術是確保所有改變狀態變數的邏輯發生在ether被髮送出合約(或任何外部呼叫)之前。在這個EtherStore例子中,[18]和[19]行EtherStore.sol應放在行[17]之前。將任何執行外部呼叫的程式碼放置在未知地址上作為本地化函式或程式碼執行中的最後一個操作是一種很好的做法。這被稱為檢查效果互動(checks-effects-interactions)模式。

第三種技術是引入互斥鎖。也就是說,要新增一個在程式碼執行過程中鎖定合約的狀態變數,阻止重入呼叫。 應用所有這些技術(所有這三種技術都是不必要的,但是這些技術是為了演示目的而完成的)

EtherStore.sol給出了無再簽約合約: 

contract EtherStore {    // initialise the mutex
    bool reEntrancyMutex = false;    
    uint256 public withdrawalLimit = 1 ether;    
    mapping(address => uint256) public lastWithdrawTime;   
    mapping(address => uint256) public balances;    
    function depositFunds() public payable {
        balances[msg.sender] += msg.value;
    }    
    function withdrawFunds (uint256 _weiToWithdraw) public {        
        require(!reEntrancyMutex);        
        require(balances[msg.sender] >= _weiToWithdraw);        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;        // set the reEntrancy mutex before the external call
        reEntrancyMutex = true;        
        msg.sender.transfer(_weiToWithdraw);        // release the mutex after the external call
        reEntrancyMutex = false; 
    }
 }

真實的例子:DAO

DAO(分散式自治組織)是以太坊早期發展的主要黑客之一。當時,該合約持有1.5億美元以上。重入在這次攻擊中發揮了重要作用,最終導致了Ethereum Classic(ETC)的分叉。有關DAO漏洞的詳細分析,請參閱Phil Daian的文章

演算法上下溢位

以太坊虛擬機器(EVM)為整數指定固定大小的資料型別。這意味著一個整型變數只能有一定範圍的數字表示。A uint8例如,只能儲存在範圍[0,255]的數字。試圖儲存256到一個uint8將導致0。如果不注意,如果不選中使用者輸入並執行計算,導致數字超出儲存它們的資料型別的範圍,則可以利用Solidity中的變數。

要進一步閱讀演算法上下流程,請參閱如何保護您的智慧合約以太坊智慧合約最佳實踐以太坊,可靠性和整數溢位:程式設計區塊鏈程式 1970年

漏洞

當執行操作需要固定大小的變數來儲存超出變數資料型別範圍的數字(或資料)時,會發生溢位/不足流量。

例如,1從一個uint8(無符號的8位整數,即只有正數)變數中減去儲存0該值的變數將導致該數量255。這是一個下溢。我們已經為該範圍下的一個數字分配了一個數字uint8,結果包裹並給出了uint8可以儲存的最大數字。同樣,加入2^8=256 到a uint8會使變數保持不變,因為我們已經包裹了整個長度uint(對於數學家來說,這類似於將三角函式的角度加上$ 2 \ pi $,$ \ sin(x)= \的sin(x + 2 \ PI)$)。新增大於資料型別範圍的數字稱為溢位。為了清楚起見,新增257到一個uint8目前有一個零值將導致數字1。將固定型別變數設為迴圈有時很有啟發意義,如果我們在最大可能儲存數字之上新增數字,我們從零開始,反之亦然為零(我們從最大數字開始倒數,從中減去的數字越多) 0)。

這些型別的漏洞允許攻擊者濫用程式碼並建立意外的邏輯流程。例如,請考慮下面的時間鎖定合約。

TimeLock.sol: 

contract TimeLock {    
    mapping(address => uint) public balances;    
    mapping(address => uint) public lockTime;    
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = now + 1 weeks;
    }    
    
    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }    
    
    function withdraw() public {        
        require(balances[msg.sender] > 0);        
        require(now > lockTime[msg.sender]);
        balances[msg.sender] = 0;        
        msg.sender.transfer(balances[msg.sender]);
    }
}

這份合約的設計就像是一個時間保險庫,使用者可以將Ether存入合約,並在那裡鎖定至少一週。如果使用者選擇的話,使用者可以延長超過1周的時間,但是一旦存放,使用者可以確信他們的Ether被安全鎖定至少一週。或者他們可以嗎?...

如果使用者被迫交出他們的私鑰(認為是人質情況),像這樣的合約可能很方便,以確保在短時間內無法獲得Ether。如果使用者已經鎖定了100 ether合約並將其金鑰交給了攻擊者,那麼攻擊者可以使用溢位來接收乙太網,無論如何lockTime。

攻擊者可以確定lockTime他們現在擁有金鑰的地址(它是一個公共變數)。我們稱之為userLockTime。然後他們可以呼叫該increaseLockTime函式並將該數字作為引數傳遞2^256 - userLockTime。該號碼將被新增到當前userLockTime並導致溢位,重置lockTime[msg.sender]為0。攻擊者然後可以簡單地呼叫withdraw函式來獲得他們的獎勵。

我們來看另一個例子,來自Ethernaut Challanges的這個例子。

SPOILER ALERT: 如果你還沒有完成Ethernaut的挑戰,這可以解決其中一個難題。 

pragma solidity ^0.4.18;contract Token {  
    mapping(address => uint) balances;  
    uint public totalSupply;  

  function Token(uint _initialSupply) {
     balances[msg.sender] = totalSupply = _initialSupply;
  }  
  
  function transfer(address _to, uint _value) public returns (bool) {    
     require(balances[msg.sender] - _value >= 0);
     balances[msg.sender] -= _value;
     balances[_to] += _value;    
     return true;
  }  

  function balanceOf(address _owner) public constant returns (uint balance) {    
     return balances[_owner];
  }
}

這是一個簡單的令牌合約,它使用一個transfer()功能,允許參與者移動他們的令牌。你能看到這份合約中的錯誤嗎?

缺陷出現在transfer()功能中。行[13]上的require語句可以使用下溢來繞過。考慮一個沒有平衡的使用者。他們可以transfer()用任何非零值呼叫函式,_value並在行[13]上傳遞require語句。這是因為balances[msg.sender] 零(和a uint256)因此減去任何正數(不包括2^256)將導致正數,這是由於我們上面描述的下溢。對於[14]行也是如此,我們的餘額將記入正數。因此,在這個例子中,我們由於下溢漏洞而實現了自由標記。

預防技術

防止溢位漏洞的(當前)常規技術是使用或建立取代標準數學運算子的數學庫; 加法,減法和乘法(劃分被排除,因為它不會導致過量/不足流量,並且EVM將被0除法)。

OppenZepplin在構建和審計Ethereum社群可以利用的安全庫方面做得非常出色。特別是,他們的SafeMath是一個參考或庫,用來避免漏洞/溢位漏洞。

為了演示如何在Solidity中使用這些庫,讓我們TimeLock使用Open Zepplin的SafeMath庫更正合約。超自由合約將變為: 

 

library SafeMath {  

function mul(uint256 a, uint256 b) internal pure returns (uint256) {    
    if (a == 0) {      
        return 0;
    }    
    uint256 c = a * b;    
    assert(c / a == b); 
    return c;
  }  

function div(uint256 a, uint256 b) internal pure returns (uint256) {    
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b; 
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }  

function sub(uint256 a, uint256 b) internal pure returns (uint256) {    
    assert(b <= a);    
    return a - b;
  }  

function add(uint256 a, uint256 b) internal pure returns (uint256) {    
    uint256 c = a + b;    
    assert(c >= a);    
    return c;
  }
}

contract TimeLock {    

    using SafeMath for uint; // use the library for uint type
    mapping(address => uint256) public balances;    
    mapping(address => uint256) public lockTime;    
    
    function deposit() public payable {
        balances[msg.sender] = balances[msg.sender].add(msg.value);
        lockTime[msg.sender] = now.add(1 weeks);
    }    
    
    function increaseLockTime(uint256 _secondsToIncrease) public {
        lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
    }    
    
    function withdraw() public {        
        require(balances[msg.sender] > 0);        
        require(now > lockTime[msg.sender]);
        balances[msg.sender] = 0;        
        msg.sender.transfer(balances[msg.sender]);
    }
}

請注意,所有標準的數學運算已被SafeMath庫中定義的數學運算所取代。該TimeLock合約不再執行任何能夠進行一個 向下/越界的操作。

實際示例:PoWHC和批量傳輸溢位(CVE-2018-10299

一個4chan小組決定用Solidity編寫一個在Ethereum上構建龐氏騙局的好主意。他們稱它為弱手硬幣證明(PoWHC)。不幸的是,似乎合約的作者之前沒有看到過/不足的流量,因此,866Ether從合約中解放出來。在Eric Banisadar的文章中,我們很好地概述了下溢是如何發生的(這與上面的Ethernaut挑戰不太相似)。

一些開發人員還batchTransfer()為一些ERC20令牌合約實施了一項功能。該實現包含溢位。這篇文章對此進行了解釋,但是我認為標題有誤導性,因為它與ERC20標準無關,而是一些ERC20令牌合約batchTransfer()實施了易受攻擊的功能。 

意外的Ether

通常,當Ether傳送到合約時,它必須執行回退功能或合約中描述的其他功能。這有兩個例外,其中ether可以存在於合約中而不執行任何程式碼。依賴程式碼執行的合約傳送給合約的每個以太可能容易受到強制傳送給合約的攻擊。

關於這方面的進一步閱讀,請參閱如何保護您的智慧合約:6Solidity security patterns - forcing ether to a contract

漏洞

一種常用的防禦性程式設計技術對於執行正確的狀態轉換或驗證操作很有用,它是不變檢查。該技術涉及定義一組不變數(不應改變的度量或引數),並且在單個(或多個)操作之後檢查這些不變數保持不變。這通常是很好的設計,只要檢查的不變數實際上是不變數。不變數的一個例子是totalSupply固定發行ERC20令牌。由於沒有函式應該修改此不變數,因此可以在該transfer()函式中新增一個檢查以確保totalSupply保持未修改狀態,以確保函式按預期工作。 

不管智慧合約中規定的規則如何,特別是有一個明顯的“不變”,可能會誘使開發人員使用,但事實上可以由外部使用者操縱。這是合約中儲存的當前以太。通常,當開發人員首先學習Solidity時,他們有一種誤解,認為合約只能通過付費功能接受或獲得以太。這種誤解可能會導致合約對其內部的以太平衡有錯誤的假設,這會導致一系列的漏洞。此漏洞的吸菸槍是(不正確)使用this.balance。正如我們將看到的,錯誤的使用this.balance會導致這種型別的嚴重漏洞。

有兩種方式可以將ether(強制)傳送給合約,而無需使用payable函式或執行合約中的任何程式碼。這些在下面列出。 

 

自毀/自殺

任何合約都能夠實現該selfdestruct(address)功能,該功能從合約地址中刪除所有位元組碼,並將所有儲存在那裡的ether傳送到引數指定的地址。如果此指定的地址也是合約,則不會呼叫任何功能(包括故障預置)。因此,selfdestruct()無論合約中可能存在的任何程式碼,該功能都可以用來強制將Ether 傳送給任何合約。這包括沒有任何應付功能的合約。這意味著,任何攻擊者都可以與某個selfdestruct()功能建立合約,向其傳送以太,致電selfdestruct(target)並強制將乙太網傳送至target合約。Martin Swende有一篇出色的部落格文章描述了自毀操作碼(Quirk#2)的一些怪癖,並描述了客戶端節點如何檢查不正確的不變數,這可能會導致相當災難性的客戶端問題。

預先傳送Ether

合約可以不使用selfdestruct()函式或呼叫任何應付函式就可以獲得以太的第二種方式是使用ether 預裝合約地址。合約地址是確定性的,實際上地址是根據建立合約的地址的雜湊值和建立合約的事務現時值計算得出的。即形式:(address = sha3(rlp.encode([account_address,transaction_nonce]))請參閱Keyless Ether的一些有趣的使用情況)。這意味著,任何人都可以在建立合約地址之前計算出合約地址,並將Ether傳送到該地址。當合約確實建立時,它將具有非零的Ether餘額。 根據上述知識,我們來探討一些可能出現的缺陷。 考慮過於簡單的合約,

EtherGame.sol: 

contract EtherGame {    
    uint public payoutMileStone1 = 3 ether;    
    uint public mileStone1Reward = 2 ether;    
    uint public payoutMileStone2 = 5 ether;    
    uint public mileStone2Reward = 3 ether; 
    uint public finalMileStone = 10 ether; 
    uint public finalReward = 5 ether; 
    
    mapping(address => uint) redeemableEther;    // users pay 0.5 ether. At specific milestones, credit their accounts
    
    function play() public payable {        
        require(msg.value == 0.5 ether); // each play is 0.5 ether
        uint currentBalance = this.balance + msg.value;        // ensure no players after the game as finished
        require(currentBalance <= finalMileStone);        // if at a milestone credit the players account
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }        return;
    }    
    function claimReward() public {        // ensure the game is complete
        require(this.balance == finalMileStone);        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0); 
        redeemableEther[msg.sender] = 0;        
        msg.sender.transfer(redeemableEther[msg.sender]);
    }
 }

這個合約代表一個簡單的遊戲(自然會引起條件競爭),玩家0.5 ether可以將合約傳送給合約,希望成為第一個達到三個里程碑之一的玩家。里程碑以ether計價。當遊戲結束時,第一個達到里程碑的人可能會要求其中的一部分。當達到最後的里程碑(10 ether)時,遊戲結束,使用者可以申請獎勵。

EtherGame合約的問題來自this.balance兩條線[14](以及協會[16])和[32] 的不良使用。一個調皮的攻擊者可以0.1 ether通過selfdestruct()函式(上面討論過的)強行傳送少量的以太,以防止未來的玩家達到一個里程碑。由於所有合法玩家只能傳送0.5 ether增量,this.balance不再是半個整數,因為它也會0.1 ether有貢獻。這可以防止[18],[21]和[24]行的所有條件成立。

更糟糕的是,一個錯過了里程碑的Ethereum的攻擊者可能會強行傳送10 ether(或者等同數量的以太會將合約的餘額推到上面finalMileStone),這將永久鎖定合約中的所有獎勵。這是因為該claimReward()函式總是會回覆,因為[32]上的要求(即this.balance大於finalMileStone)。

預防技術

這個漏洞通常是由於濫用this.balance。如果可能,合約邏輯應該避免依賴於合約餘額的確切值,因為它可以被人為地操縱。如果基於邏輯應用this.balance,確保考慮到意外的餘額。

如果需要確定的沉積ether值,則應使用自定義變數,以增加應付功能,以安全地追蹤沉積的ether。這個變數不會受到通過selfdestruct()呼叫傳送的強制乙太網的影響。

考慮到這一點,修正後的EtherGame合約版本可能如下所示: 

contract EtherGame {    
    uint public payoutMileStone1 = 3 ether;    
    uint public mileStone1Reward = 2 ether;    
    uint public payoutMileStone2 = 5 ether;    
    uint public mileStone2Reward = 3 ether; 
    uint public finalMileStone = 10 ether; 
    uint public finalReward = 5 ether; 
    uint public depositedWei;    
    mapping (address => uint) redeemableEther;    
    function play() public payable {        
    require(msg.value == 0.5 ether);        
    uint currentBalance = depositedWei + msg.value;        // ensure no players after the game as finished
        require(currentBalance <= finalMileStone);        
    if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        depositedWei += msg.value;        return;
    }    

    function claimReward() public {        // ensure the game is complete
        require(depositedWei == finalMileStone);        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0); 
        redeemableEther[msg.sender] = 0;        
        msg.sender.transfer(redeemableEther[msg.sender]);
    }
 }

在這裡,我們剛剛建立了一個新變數,depositedEther它跟蹤已知的以太儲存,並且這是我們執行需求和測試的變數。請注意,我們不再有任何參考this.balance。

真實世界的例子:未知

我還沒有找到這個在野被利用的例子。然而,在弱勢群體競賽中給出了一些可利用的合約的例子

Delegatecall

在CALL與DELEGATECALL操作碼是允許Ethereum開發者modularise他們的程式碼非常有用。對契約的標準外部訊息呼叫由CALL操作碼處理,由此程式碼在外部契約/功能的上下文中執行。該DELEGATECALL碼是相同的標準訊息的呼叫,但在目標地址執行的程式碼在呼叫合約的情況下與事實一起執行msg.sender,並msg.value保持不變。該功能支援實現庫,開發人員可以為未來的合約建立可重用的程式碼。

雖然這兩個操作碼之間的區別很簡單直觀,但是使用DELEGATECALL會導致意外的程式碼執行。

有關進一步閱讀,請參閱Stake Exchange上關於以太坊的這篇提問官方文件以及如何保護您的智慧合約:6。 

 

漏洞

 

保護環境的性質DELEGATECALL已經證明,構建無脆弱性的定製庫並不像人們想象的那麼容易。庫中的程式碼本身可以是安全的,無漏洞的,但是當在另一個應用程式的上下文中執行時,可能會出現新的漏洞。讓我們看一個相當複雜的例子,使用斐波那契數字。

考慮下面的庫可以生成斐波那契數列和相似形式的序列。 FibonacciLib.sol[^ 1] 

// library contract - calculates fibonacci-like numbers;

contract FibonacciLib {    // initializing the standard fibonacci sequence;
    uint public start;    
    uint public calculatedFibNumber;    // modify the zeroth number in the sequence
    
    function setStart(uint _start) public {
        start = _start;
    }    
    
    function setFibonacci(uint n) public {
        calculatedFibNumber = fibonacci(n);
    }    

    function fibonacci(uint n) internal returns (uint) {        
        if (n == 0) 
            return start;        
        else if (n == 1) 
            return start + 1;        
        else 
            return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

該庫提供了一個函式,可以在序列中生成第n個斐波那契數。它允許使用者更改第0個start數字並計算這個新序列中的第n個斐波那契數字。

現在我們來考慮一個利用這個庫的合約。

FibonacciBalance.sol: 

contract FibonacciBalance {    
    address public fibonacciLibrary;    // the current fibonacci number to withdraw
    uint public calculatedFibNumber;    // the starting fibonacci sequence number
    uint public start = 3;    
    uint public withdrawalCounter;    // the fibonancci function selector
    bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));    
    // constructor - loads the contract with ether
    
constructor(address _fibonacciLibrary) public payable {
        fibonacciLibrary = _fibonacciLibrary;
    }    
    
    function withdraw() {
        withdrawalCounter += 1;        // calculate the fibonacci number for the current withdrawal user
        // this sets calculatedFibNumber
        require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));        
        msg.sender.transfer(calculatedFibNumber * 1 ether);
    }    
    // allow users to call fibonacci library functions
    
    function() public {        
        require(fibonacciLibrary.delegatecall(msg.data));
    }
}

該合約允許參與者從合約中提取ether,ether的金額等於與參與者提款訂單相對應的斐波納契數字; 即第一個參與者獲得1個ether,第二個參與者獲得1,第三個獲得2,第四個獲得3,第五個5等等(直到合約的餘額小於被撤回的斐波納契數)。

本合約中有許多要素可能需要一些解釋。首先,有一個有趣的變數,fibSig。這包含字串“fibonacci(uint256)”的Keccak(SHA-3)雜湊的前4個位元組。這被稱為函式選擇器,calldata用於指定智慧合約的哪個函式將被呼叫。它在delegatecall[21]行的函式中用來指定我們希望執行該fibonacci(uint256)函式。第二個引數delegatecall是我們傳遞給函式的引數。其次,我們假設FibonacciLib庫的地址在建構函式中正確引用(部署攻擊向量部分 如果合約參考初始化,討論一些與此類相關的潛在漏洞)。

你能在這份合約中發現任何錯誤嗎?如果你把它改成混音,用ether填充並呼叫withdraw(),它可能會恢復。

您可能已經注意到,在start庫和主呼叫合約中都使用了狀態變數。在圖書館合約中,start用於指定斐波納契數列的開始並設定為0,而3在FibonacciBalance合約中設定。您可能還注意到,FibonacciBalance合約中的回退功能允許將所有呼叫傳遞給庫合約,這也允許呼叫庫合約的setStart()功能。回想一下,我們保留了合約的狀態,看起來這個功能可以讓你改變start本地FibonnacciBalance合約中變數的狀態。如果是這樣,這將允許一個撤回更多的醚,因為結果calculatedFibNumber是依賴於start變數(如圖書館合約中所見)。實際上,該setStart()函式不會(也不能)修改合約中的start變數FibonacciBalance。這個合約中的潛在弱點比僅僅修改start變數要糟糕得多。

在討論實際問題之前,我們先快速繞道瞭解狀態變數(storage變數)實際上是如何儲存在合約中的。狀態或storage變數(持續在單個事務中的變數)slots在合約中引入時按順序放置。(這裡有一些複雜性,我鼓勵讀者閱讀儲存中狀態變數的佈局以便更透徹的理解)。

作為一個例子,讓我們看看library 合約。它有兩個狀態變數,start和calculatedFibNumber。第一個變數是start,因此它被儲存在合約的儲存位置slot[0](即第一個槽)。第二個變數calculatedFibNumber放在下一個可用的儲存槽中slot[1]。如果我們看看這個函式setStart(),它會接受一個輸入並設定start輸入的內容。因此,該功能設定slot[0]為我們在該setStart()功能中提供的任何輸入。同樣,該setFibonacci()函式設定calculatedFibNumber為的結果fibonacci(n)。再次,這只是將儲存設定slot[1]為值fibonacci(n)。

現在讓我們看看FibonacciBalance合約。儲存slot[0]現在對應於fibonacciLibrary地址並slot[1]對應於calculatedFibNumber。它就在這裡出現漏洞。delegatecall 保留合約上下文。這意味著通過執行的程式碼delegatecall將作用於呼叫合約的狀態(即儲存)。

現在請注意,我們在withdraw()[21]線上執行,fibonacciLibrary.delegatecall(fibSig,withdrawalCounter)。這就呼叫了setFibonacci()我們討論的函式,修改了儲存 slot[1],在我們當前的情況下calculatedFibNumber。這是預期的(即執行後,calculatedFibNumber得到調整)。但是,請記住,合約中的start變數FibonacciLib位於儲存中slot[0],即fibonacciLibrary當前合約中的地址。這意味著該功能fibonacci()會帶來意想不到的結果。這是因為它引用start(slot[0])當前呼叫上下文中的fibonacciLibrary哪個地址是地址(當解釋為a時,該地址通常很大uint)。因此,該withdraw()函式很可能會恢復,因為它不包含uint(fibonacciLibrary)ether的量,這是什麼calcultedFibNumber會返回。

更糟糕的是,FibonacciBalance合約允許使用者fibonacciLibrary通過行[26]上的後備功能呼叫所有功能。正如我們前面所討論的那樣,這包括該setStart()功能。我們討論過這個功能允許任何人修改或設定儲存slot[0]。在這種情況下,儲存slot[0]是fibonacciLibrary地址。因此,攻擊者可以建立一個惡意合約(下面是一個例子),將地址轉換為uint(這可以在python中輕鬆使用int('

',16))然後呼叫setStart(<attack_contract_address_as_uint>)。這將改變fibonacciLibrary為攻擊合約的地址。然後,無論何時使用者呼叫withdraw()或回退函式,惡意契約都會執行(這可以竊取合約的全部餘額),因為我們修改了實際地址fibonacciLibrary。這種攻擊合約的一個例子是,

contract Attack {    
    uint storageSlot0; // corresponds to fibonacciLibrary
    uint storageSlot1; // corresponds to calculatedFibNumber
   
    // fallback - this will run if a specified function is not found
    function() public {
        storageSlot1 = 0; // we set calculatedFibNumber to 0, so that if withdraw
        // is called we don't send out any ether. 
        <attacker_address>.transfer(this.balance); // we take all the ether
    }
 }

 

請注意,此攻擊合約calculatedFibNumber通過更改儲存來修改slot[1]。原則上,攻擊者可以修改他們選擇的任何其他儲存槽來對本合約執行各種攻擊。我鼓勵所有讀者將這些合約放入Remix,並通過這些delegatecall功能嘗試不同的攻擊合約和狀態更改。

 

同樣重要的是要注意,當我們說這delegatecall是保留狀態時,我們並不是在討論合約的變數名稱,而是這些名稱指向的實際儲存槽位。從這個例子中可以看出,一個簡單的錯誤,可能導致攻擊者劫持整個合約及其乙太網。

預防技術

Solidity library為實施library合約提供了關鍵字(參見Solidity Docs瞭解更多詳情)。這確保了library合約是無國籍,不可自毀的。強制library成為無國籍人員可以緩解本節所述的儲存上下文的複雜性。無狀態庫也可以防止攻擊,攻擊者可以直接修改庫的狀態,以實現依賴庫程式碼的合約。作為一般的經驗法則,在使用時DELEGATECALL要特別注意庫合約和呼叫合約的可能呼叫上下文,並且儘可能構建無狀態庫。

真實世界示例:Parity Multisig Wallet(Second Hack)

第二種Parity Multisig Wallet hack是一個例子,說明如果在非預期的上下文中執行良好的庫程式碼的上下文可以被利用。這個黑客有很多很好的解釋,比如這個概述:Parity MultiSig Hacked。再次通過Anthony Akentiev,這個堆疊交換問題和深入瞭解Parity Multisig Bug。

要新增到這些參考資料中,我們來探索被利用的合約。library和錢包合約可以在這裡的奇偶校驗github上找到。 

我們來看看這個合約的相關方面。這裡包含兩份利益合約,library合約和錢包合約。 library合約,

contract WalletLibrary is WalletEvents {
  
  ...  
  // throw unless the contract is not yet initialized.
  modifier only_uninitialized { 
    if (m_numOwners > 0) throw; _; 
}  
// constructor - just pass on the owner array to the multiowned and
 // the limit to daylimit
  
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {    
     initDaylimit(_daylimit);    
     initMultiowned(_owners, _required);
  }  // kills the contract sending everything to `_to`.
  
function kill(address _to) onlymanyowners(sha3(msg.data)) external {    
      
     suicide(_to);
  }
  
  ...
  
}

和錢包合約

contract Wallet is WalletEvents {

  ...  // METHODS

  // gets called when no other function matches
  function() payable {    // just being sent some cash?
    if (msg.value > 0)      Deposit(msg.sender, msg.value);    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }
  
  ...  

  // FIELDS
  address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe;
}

請注意,Wallet合約基本上通過WalletLibrary委託呼叫將所有呼叫傳遞給合約。_walletLibrary此程式碼段中的常量地址充當實際部署的WalletLibrary合約(位於0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4)的佔位符。

這些合約的預期運作是制定一個簡單的低成本可部署Wallet合約,其程式碼基礎和主要功能在WalletLibrary合約中。不幸的是,WalletLibrary合約本身就是一個合約,並保持它自己的狀態。你能看出為什麼這可能是一個問題?

有可能向WalletLibrary合約本身傳送呼叫。具體來說,WalletLibrary合約可以初始化,併成為擁有。使用者通過呼叫契約initWallet()函式來做到這一點,WalletLibrary成為Library合約的所有者。同一個使用者,隨後稱為kill()功能。因為使用者是Library合約的所有者,所以修改者通過並且Library合約被自動化。由於所有Wallet現存的合約都提及該Library合約,並且不包含更改該參考文獻的方法,因此其所有功能(包括撤回ether的功能)都會隨WalletLibrary合約一起丟失。更直接地說,這種型別的所有奇偶校驗多數錢包中的所有以太會立即丟失或永久不可恢復。

預設可見性

Solidity中的函式具有可見性說明符,它們決定如何呼叫函式。可見性決定一個函式是否可以由使用者或其他派生契約在外部呼叫,僅在內部或僅在外部呼叫。有四個可見性說明符,詳情請參閱Solidity文件。函式預設public允許使用者從外部呼叫它們。正如本節將要討論的,可見性說明符的不正確使用可能會導致智慧合約中的一些資金流失。

漏洞

函式的預設可見性是public。因此,不指定任何可見性的函式將由外部使用者呼叫。當開發人員錯誤地忽略應該是私有的功能(或只能在合約本身內呼叫)的可見性說明符時,問題就出現了。 讓我們快速瀏覽一個簡單的例子。 

contract HashForEther {    
    function withdrawWinnings() {        // Winner if the last 8 hex characters of the address are 0. 
        require(uint32(msg.sender) == 0);        
        _sendWinnings();
     }     
     
    function _sendWinnings() {         
        msg.sender.transfer(this.balance);
     }
}

這個簡單的合約被設計為充當地址猜測賞金遊戲。為了贏得合約的平衡,使用者必須生成一個以太坊地址,其最後8個十六進位制字元為0.一旦獲得,他們可以呼叫該WithdrawWinnings()函式來獲得他們的賞金。 不幸的是,這些功能的可見性尚未明確。特別是,該_sendWinnings()函式是public,因此任何地址都可以呼叫該函式來竊取賞金。

預防技術

總是指定合約中所有功能的可見性,即使這些功能是有意識的,這是一種很好的做法public。最近版本的Solidity現在將在編譯過程中為未設定明確可見性的函式顯示警告,以幫助鼓勵這種做法。

真實世界示例:奇偶MultiSig錢包(First Hack)

在第一次Parity multi-sig黑客攻擊中,約三千一百萬美元的Ether被盜,主要是三個錢包。Haseeb Qureshi在這篇文章中給出了一個很好的回顧。 實質上,多sig錢包(可以在這裡找到)是從一個基礎Wallet合約構建的,該基礎合約呼叫包含核心功能的庫合約(如真實世界中的例子:Parity Multisig(Second Hack)中所述)。庫合約包含初始化錢包的程式碼,如以下程式碼片段所示 

contract WalletLibrary is WalletEvents {
  
  ... 
  
  // METHODS

  ...  
  // constructor is given number of sigs required to do protected "onlymanyowners" transactions
  // as well as the selection of addresses capable of confirming them.
  function initMultiowned(address[] _owners, uint _required) {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;    

   for (uint i = 0; i < _owners.length; ++i)
    {
      m_owners[2 + i] = uint(_owners[i]);
      m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
  }

  ...  // constructor - just pass on the owner array to the multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) {    

        initDaylimit(_daylimit);    
        initMultiowned(_owners, _required);
  }
}

請注意,這兩個函式都沒有明確指定可見性。這兩個函式預設為public。該initWallet()函式在錢包建構函式中呼叫,並設定多sig錢包的所有者,如initMultiowned()函式中所示。由於這些功能被意外留下public,攻擊者可以在部署的合約上呼叫這些功能,並將所有權重置為攻擊者地址。作為主人,襲擊者隨後將所有乙太網的錢包損失至3100萬美元。

函式錯誤

以太坊區塊鏈上的所有交易都是確定性的狀態轉換操作。這意味著每筆交易都會改變以太坊生態系統的全球狀態,並且它以可計算的方式進行,沒有不確定性。這最終意味著在區塊鏈生態系統內不存在函式或隨機性的來源。rand()在Solidity中沒有功能。實現分散函式(隨機性)是一個完善的問題,許多想法被提出來解決這個問題(見例如,RandDAO或使用雜湊的鏈在這個由Vitalik的描述後)。

漏洞

在以太坊平臺上建立的一些首批合約基於賭博。從根本上講,賭博需要不確定性(可以下注),這使得在區塊鏈(一個確定性系統)上構建賭博系統變得相當困難。很明顯,不確定性必須來自區塊鏈外部的來源。這可能會導致同行之間的投注(例如參見承諾揭示技術),但是,如果要執行合約作為房屋,則顯然更困難(如在二十一點我們的輪盤賭)。常見的陷阱是使用未來的塊變數,如雜湊,時間戳,塊數或gas限制。與這些問題有關的是,他們是由開採礦塊的礦工控制的,因此並不是真正隨機的。例如,考慮一個帶有邏輯的輪盤智慧合約,如果下一個塊雜湊以偶數結尾,則返回一個黑色數字。一個礦工(或礦工池)可以在黑色上下注$ 1M。如果他們解決下一個塊並發現奇數的雜湊結束,他們會高興地不釋出他們的塊和我的另一個塊,直到他們發現塊雜湊是偶數的解決方案(假設塊獎勵和費用低於1美元M)。Martin Swende在其優秀的部落格文章中表明,使用過去或現在的變數可能會更具破壞性。此外,單獨使用塊變數意味著偽隨機數對於一個塊中的所有交易都是相同的,所以攻擊者可以通過在一個塊內進行多次交易來增加他們的勝利(應該有最大的賭注)。

預防技術

函式(隨機性)的來源必須在區塊鏈外部。這可以通過諸如commit-reveal之類的系統或通過將信任模型更改為一組參與者(例如RandDAO)來完成。這也可以通過一個集中的實體來完成,這個實體充當一個隨機性的預言者。塊變數(一般來說,有一些例外)不應該被用來提供函式,因為它們可以被礦工操縱。

真實世界示例:PRNG合約

Arseny Reutov 在分析了3649份使用某種偽隨機數發生器(PRNG)的實時智慧合約並發現43份可被利用的合約之後寫了一篇博文。這篇文章詳細討論了使用塊變數作為函式的缺陷。

外部合約引用

以太坊全球計算機的好處之一是能夠重複使用程式碼並與已部署在網路上的合約進行互動。因此,大量合約引用外部合約,並且在一般運營中使用外部訊息呼叫來與這些合約互動。這些外部訊息呼叫可以以一些非顯而易見的方式來掩蓋惡意行為者的意圖,我們將討論這些意圖。

漏洞

在Solidity中,無論地址上的程式碼是否表示正在施工的合約型別,都可以將任何地址轉換為合約。這可能是騙人的,特別是當合約的作者試圖隱藏惡意程式碼時。讓我們以一個例子來說明這一點: 考慮一個程式碼,它基本上實現了Rot13密碼。 

Rot13Encryption.sol:

//encryption contractcontract Rot13Encryption {     
   event Result(string convertedString);   
    //rot13 encrypt a string
    
    function rot13Encrypt (string text) public {        
        uint256 length = bytes(text).length;        
        for (var i = 0; i < length; i++) {           
            byte char = bytes(text)[i];            //inline assembly to modify the string
            assembly {                
            char := byte(0,char) // get the first byte
            if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping. 
             { char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z. 
            if iszero(eq(char, 0x20)) // ignore spaces
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,13))} // add 13 to char. 
            }
        }    emit Result(text);
    }    
    // rot13 decrypt a string
    function rot13Decrypt (string text) public {        
            uint256 length = bytes(text).length;        
            for (var i = 0; i < length; i++) {            
                byte char = bytes(text)[i];
            assembly {                
                char := byte(0,char)                
                if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }                
                if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
            }
        }        emit Result(text);
    }
}

這個程式碼只需要一個字串(字母az,沒有驗證),並通過將每個字元向右移動13個位置(圍繞'z')來加密它; 即'a'轉換為'n','x'轉換為'k'。這裡的集合並不重要,所以如果在這個階段沒有任何意義,不要擔心。

考慮以下使用此程式碼進行加密的合約, 

import "Rot13Encryption.sol";// encrypt your top secret info

contract EncryptionContract {    // library for encryption
    Rot13Encryption encryptionLibrary;        
    // constructor - initialise the library
    constructor(Rot13Encryption _encryptionLibrary) {
        encryptionLibrary = _encryptionLibrary;
    }    
    function encryptPrivateData(string privateInfo) {        // potentially do some operations here
        encryptionLibrary.rot13Encrypt(privateInfo);
     }
 }

這個合約的問題是encryptionLibrary地址不公開或不變。因此,合約的配置人員可以在指向該合約的建構函式中給出一個地址:
 

//encryption contractcontract Rot26Encryption {     
   event Result(string convertedString);   
    //rot13 encrypt a string
    function rot13Encrypt (string text) public {        
            uint256 length = bytes(text).length;        
            for (var i = 0; i < length; i++) {            
                byte char = bytes(text)[i];            //inline assembly to modify the string
                assembly {                
                char := byte(0,char) // get the first byte
                if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping. 
                { char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z. 
                if iszero(eq(char, 0x20)) // ignore spaces
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,26))} // add 13 to char. 
            }
        }        emit Result(text);
    }    
    // rot13 decrypt a string
    function rot13Decrypt (string text) public {        
            uint256 length = bytes(text).length;        
            for (var i = 0; i < length; i++) {            
            byte char = bytes(text)[i];
            assembly {                
            char := byte(0,char)                
            if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }  
                  
            if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
            }
        }        emit Result(text);
    }

它實現了rot26密碼(每個角色移動26個地方,得到它?:p)。再次強調,不需要了解本合約中的程式集。部署人員也可以連結下列合約:

contract Print{    
    event Print(string text);    
    function rot13Encrypt(string text) public {        
        emit Print(text);
    }
 }

如果這些合約中的任何一個的地址都在構造encryptPrivateData()函式中給出,那麼該函式只會產生一個列印未加密的私有資料的事件。儘管在這個例子中,在建構函式中設定了類似庫的協定,但是特權使用者(例如owner)可以更改庫合約地址。如果連結合約不包含被呼叫的函式,則將執行回退函式。例如,對於該行encryptionLibrary.rot13Encrypt(),如果指定的合約encryptionLibrary是:

 contract Blank {     
        event Print(string text);     
        function () {         
        emit Print("Here");         //put malicious code here and it will run
     }
 }

 

那麼會發出一個帶有“Here”文字的事件。因此,如果使用者可以更改合約庫,原則上可以讓使用者在不知不覺中執行任意程式碼。

 

注意:不要使用這些加密合約,因為智慧合約的輸入引數在區塊鏈上可見。另外,Rot密碼並不是推薦的加密技術:p

預防技術

如上所示,無漏洞合約可以(在某些情況下)以惡意行為的方式進行部署。審計人員可以公開驗證合約並讓其所有者以惡意方式進行部署,從而產生具有漏洞或惡意的公開審計合約。 有許多技術可以防止這些情況發生。 一種技術是使用new關鍵字來建立合約。在上面的例子中,建構函式可以寫成:

    constructor(){
        encryptionLibrary =  new  Rot13Encryption();
    }

這樣,引用合約的一個例項就會在部署時建立,並且部署者不能在Rot13Encryption不修改智慧合約的情況下用其他任何東西替換合約。

另一個解決方案是如果已知的話,對任何外部合約地址進行硬編碼。

一般來說,應該仔細檢視呼叫外部契約的程式碼。作為開發人員,在定義外部合約時,最好將合約地址公開(這種情況並非如此),以便使用者輕鬆檢視合約引用哪些程式碼。相反,如果合約具有私人變數合約地址,則它可能是某人惡意行為的標誌(如現實示例中所示)。如果特權(或任何)使用者能夠更改用於呼叫外部函式的合約地址,則可能很重要(在分散的系統上下文中)來實現時間鎖定或投票機制,以允許使用者檢視哪些程式碼正在改變或讓參與者有機會選擇加入/退出新的合約地址。

真實世界的例子:重入蜜罐

主網上釋出了一些最近的蜜罐。這些合約試圖勝過試圖利用合約的以太坊黑客,但是誰又會因為他們期望利用的合約而失敗。一個例子是通過在建構函式中用惡意代替期望的合約來應用上述攻擊。程式碼可以在這裡找到: 

 

pragma solidity ^0.4.19;contract Private_Bank{    
    mapping (address => uint) public balances;    
    uint public MinDeposit = 1 ether;
    Log TransferLog;    
    function Private_Bank(address _log)
    {
        TransferLog = Log(_log);
    }    
    function Deposit()    public
    payable
    {        if(msg.value >= MinDeposit)
        {
            balances[msg.sender]+=msg.value;
            TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
        }
    }    
    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");
            }
        }
    }    
    function() public payable{}    
    
}

contract Log {    struct Message
    {        
          address Sender;        
          string  Data;        
          uint Val;        
          uint  Time;
    }
    
    Message[] public History;
    Message LastMsg;    
    function AddMessage(address _adr,uint _val,string _data)    public
    {
        LastMsg.Sender = _adr;
        LastMsg.Time = now;
        LastMsg.Val = _val;
        LastMsg.Data = _data;
        History.push(LastMsg);
    }
}

一位reddit使用者釋出的這篇文章解釋了他們如何在合約中失去1位以試圖利用他們預計會出現在合約中的重入錯誤。

短地址/引數攻擊

這種攻擊並不是專門針對Solidity合約執行的,而是針對可能與之互動的第三方應用程式執行的。為了完整性,我新增了這個攻擊,並瞭解引數如何在合約中被操縱。 有關進一步閱讀,請參閱ERC20短地址攻擊說明,ICO智慧合約漏洞:短地址攻擊或此書籤。

漏洞

將引數傳遞給智慧合約時,引數將根據ABI規範進行編碼。可以傳送比預期引數長度短的編碼引數(例如,傳送只有38個十六進位制字元(19個位元組)的地址而不是標準的40個十六進位制字元(20個位元組))。在這種情況下,EVM會將0填到編碼引數的末尾以彌補預期的長度。

當第三方應用程式不驗證輸入時,這會成為問題。最明顯的例子是當使用者請求提款時,交易所不驗證ERC20令牌的地址。Peter Venesses的文章“ 上述ERC20短地址攻擊解釋 ”中詳細介紹了這個例子。 考慮一下標準的ERC20傳輸函式介面,注意引數的順序,

function transfer(address to, uint tokens) public returns (bool success);

現在考慮一下,一個交易所持有大量的令牌(比方說REP),並且使用者希望撤回他們分享的100個代幣。使用者將提交他們的地址,0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead以及令牌的數量100。交換將在由所述指定的順序編碼這些引數transfer()功能,即address然後tokens。編碼結果將是a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000056bc75e2d63100000。前四個位元組(a9059cbb)是transfer() 函式簽名/選擇器,第二個32位元組是地址,後面是表示uint256令牌數的最後32個位元組。請注意,最後的十六進位制56bc75e2d63100000對應於100個令牌(由REP令牌合約指定的小數點後18位)。 好的,現在讓我們看看如果我們傳送一個丟失1個位元組(2個十六進位制數字)的地址會發生什麼。具體而言,假設攻擊者傳送0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde一個地址(缺少最後兩位數字)和相同的 100令牌撤回。如果交易所沒有驗證這個輸入,它將被編碼為a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde0000000000000000000000000000000000000000000000056bc75e2d6310000000。差別是微妙的。請注意,00已將其填充到編碼的末尾,以彌補傳送的短地址。當它被髮送到智慧合約時,address引數將被讀為,0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00並且該值將被讀為56bc75e2d6310000000(注意兩個額外0的)。此值現在是25600令牌(值已被乘以256)。在這個例子中,如果交易所持有這麼多的代幣,使用者會退出25600令牌(而交換機認為使用者只是撤回100)到修改後的地址。很顯然,在這個例子中攻擊者不會擁有修改後的地址,但是如果攻擊者在哪裡產生以0's 結尾的地址(這可能很容易被強行強制)並且使用了這個生成的地址,他們很容易從毫無防備的交換中竊取令牌。

預防技術

我想很明顯,在將所有輸入傳送到區塊鏈之前對其進行驗證可以防止這些型別的攻擊。還應該指出的是引數排序在這裡起著重要的作用。由於填充只發生在最後,智慧合約中引數的仔細排序可能會緩解某些形式的此攻擊。

真實世界的例子:未知

我不知道在野有這種公開的攻擊。

未檢查的CALL返回值

有很多方法可以穩固地執行外部呼叫。向外部賬戶傳送ether通常通過該transfer()方法完成。但是,該send()功能也可以使用,並且對於更多功能的外部呼叫,CALL可以直接使用操作碼。在call()和send()函式返回一個布林值,指示如果呼叫成功還是失敗。因此,這些功能有一個簡單的警告,在執行這些功能將不會恢復交易,如果外部呼叫(由intialised call()或send())失敗,而不在call()或send()將簡單地返回false。當沒有檢查返回值時,會出現一個常見的錯誤,而開發人員希望恢復發生。 有關進一步閱讀,請參閱DASP Top 10和掃描Live Ethereum合約中的“Unchecked-Send”錯誤。

漏洞

考慮下面的例子: 

contract Lotto {    
    bool public payedOut = false;    
    address public winner;    
    uint public winAmount;    
    // ... extra functionality here 

    function sendToWinner() public {        
        require(!payedOut);
        winner.send(winAmount);
        payedOut = true;
    }    
    function withdrawLeftOver() public {        
        require(payedOut);        
        msg.sender.send(this.balance);
    }
}

這份合約代表了一個類似於大樂透的合約,在這種合約中,winner收到winAmount了ether,通常只剩下一點讓任何人退出。

該錯誤存在於第[11]行,其中使用a send()而不檢查響應。在這個微不足道的例子中,可以將winner其事務失敗(無論是通過耗盡天然氣,是故意丟擲回退函式還是通過呼叫堆疊深度攻擊的合約)payedOut設定為true(無論是否傳送了以太) 。在這種情況下,公眾可以winner通過該withdrawLeftOver()功能撤回獎金。

預防技術

只要有可能,使用transfer()功能,而不是send()作為transfer()意志revert,如果外部事務恢復。如果send()需要,請務必檢查返回值。

更強大的建議是採取撤回模式。在這個解決方案中,每個使用者都承擔著呼叫隔離功能(即撤銷功能)的作用,該功能處理髮送合約以外的事件,並因此獨立地處理失敗的傳送事務的後果。這個想法是將外部傳送功能與程式碼庫的其餘部分進行邏輯隔離,並將可能失敗的事務負擔交給正在呼叫撤消功能的終端使用者。

 

真實的例子:Etherpot和以太之王

Etherpot是一個聰明的合約彩票,與上面提到的示例合約不太相似。etherpot的固體程式碼可以在這裡找到:lotto.sol。這個合約的主要缺點是由於塊雜湊的使用不正確(只有最後的256塊雜湊值是可用的,請參閱Aakil Fernandes 關於Etherpot如何正確實現的帖子)。然而,這份合約也受到未經檢查的通話價值的影響。注意cash()lotto.sol的行[80]上的函式: 

function cash(uint roundIndex, uint subpotIndex){        

        var subpotsCount = getSubpotsCount(roundIndex);        
        if(subpotIndex>=subpotsCount)            
                return;        
        var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);        
        
        if(decisionBlockNumber>block.number)            
                return;        
        if(rounds[roundIndex].isCashed[subpotIndex])            
                return;        //Subpots can only be cashed once. This is to prevent double payouts

        var winner = calculateWinner(roundIndex,subpotIndex);    
        var subpot = getSubpot(roundIndex);

        winner.send(subpot);

        rounds[roundIndex].isCashed[subpotIndex] = true;        //Mark the round as cashed}

請注意,在第[21]行,傳送函式的返回值沒有被選中,然後下一行設定了一個布林值,表示贏家已經傳送了他們的資金。這個錯誤可以允許一個狀態,即贏家沒有收到他們的異議,但是合約狀態可以表明贏家已經支付。

這個錯誤的更嚴重的版本發生在以太之王。一個優秀的驗屍本合約已被寫入詳細介紹瞭如何一個未經檢查的失敗send()可能會被用來攻擊的合約。

條件競爭/非法預先交易

將外部呼叫與其他合約以及底層區塊鏈的多使用者特性結合在一起會產生各種潛在的缺陷,使用者可以通過爭用程式碼來獲取意外狀態。重入是這種條件競爭的一個例子。在本節中,我們將更一般地討論以太坊區塊鏈上可能發生的各種競態條件。在這個領域有很多不錯的帖子,其中一些是:以太坊Wiki - 安全,DASP - 前臺執行和共識 - 智慧合約最佳實踐。

漏洞 

與大多數區塊鏈一樣,以太坊節點彙集交易並將其形成塊。一旦礦工解決了共識機制(目前Ethereum的 ETHASH PoW),這些交易就被認為是有效的。解決該區塊的礦工也會選擇來自該礦池的哪些交易將包含在該區塊中,這通常是由gasPrice交易訂購的。在這裡有一個潛在的攻擊媒介。攻擊者可以觀察事務池中是否存在可能包含問題解決方案的事務,修改或撤銷攻擊者的許可權或更改合約中的攻擊者不希望的狀態。然後攻擊者可以從這個事務中獲取資料,並建立一個更高階別的事務gasPrice 並在原始之前將其交易包含在一個區塊中。

讓我們看看這可以如何用一個簡單的例子。考慮合約FindThisHash.sol: 

contract FindThisHash {
    bytes32 constant public hash = 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;    
    constructor() public payable {} // load with ether
    
    function solve(string solution) public {        // If you can find the pre image of the hash, receive 1000 ether
        require(hash == sha3(solution)); 
        msg.sender.transfer(1000 ether);
    }
}

想象一下,這個合約包含1000個ether。可以找到sha3雜湊的預映像的使用者
0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a可以提交解決方案並檢索1000 ether。讓我們說一個使用者找出解決方案Ethereum!。他們稱solve()與Ethereum!作為引數。不幸的是,攻擊者非常聰明地為提交解決方案的任何人觀看交易池。他們看到這個解決方案,檢查它的有效性,然後提交一個遠高於gasPrice原始交易的等價交易。解決該問題的礦工可能會因攻擊者的偏好而給予攻擊者偏好,gasPrice並在原求解器之前接受他們的交易。攻擊者將獲得1000ether,解決問題的使用者將不會得到任何東西(合約中沒有剩餘ether)。

未來卡斯珀實施的設計中會出現更現實的問題。卡斯帕證明合約涉及激勵條件,在這種條件下,通知驗證者雙重投票或行為不當的使用者被激勵提交他們已經這樣做的證據。驗證者將受到懲罰並獎勵使用者。在這種情況下,預計礦工和使用者將在所有這些提交的證據前面執行,並且這個問題必須在最終釋出之前得到解決。

預防技術 

有兩類使用者可以執行這些型別的前端攻擊。使用者(他們修改gasPrice他們的交易)和礦工自己(誰可以在一個塊中重新訂購他們認為合適的交易)。對於第一類(使用者)而言易受攻擊的合約比第二類(礦工)易受影響的合約明顯更差,因為礦工只能在解決某個塊時執行攻擊,而對於任何單個礦工來說,塊。在這裡,我將列出一些與他們可能阻止的攻擊者類別有關的緩解措施。

可以採用的一種方法是在合約中建立邏輯,以在其上設定上限gasPrice。這可以防止使用者增加gasPrice並獲得超出上限的優惠交易排序。這種預防措施只能緩解第一類攻擊者(任意使用者)。在這種情況下,礦工仍然可以攻擊合約,因為無論天然氣價格如何,他們都可以在他們的塊中訂購交易。

一個更強大的方法是儘可能使用commit-reveal方案。這種方案規定使用者使用隱藏資訊傳送交易(通常是雜湊)。在事務已包含在塊中後,使用者將傳送一個事務來顯示已傳送的資料(顯示階段)。這種方法可以防止礦工和使用者從事先交易,因為他們無法確定交易的內容。然而,這種方法不能隱藏交易價值(在某些情況下,這是需要隱藏的有價值的資訊)。該ENS 智慧合約允許使用者傳送交易,其承諾資料包括他們願意花費的金額。使用者可以傳送任意值的交易。在披露階段,使用者退還了交易中傳送的金額與他們願意花費的金額之間的差額。 洛倫茨,菲爾,阿里和弗洛裡安的進一步建議是使用潛艇發射。這個想法的有效實現需要CREATE2操作碼,目前還沒有被採用,但似乎在即將出現的硬叉上。

真實世界的例子:ERC20和Bancor 

該ERC20標準是相當知名的關於Ethereum建設令牌。這個標準有一個潛在的超前漏洞,這個漏洞是由於這個approve()功能而產生的。這個漏洞的一個很好的解釋可以在這裡找到。

該標準規定的approve()功能如下:

function approve(address _spender, uint256 _value) returns (bool success)

該功能允許使用者 允許其他使用者 代表他們傳送令牌。當使用者Alice 批准她的朋友Bob花錢時,這種先發制人的漏洞就出現了100 tokens。愛麗絲後來決定,她想撤銷Bob批准花費100 tokens,所以她建立了一個交易,設定Bob的分配50 tokens。Bob,他一直在仔細觀察這個連鎖店,看到這筆交易並且建立了一筆他自己花費的交易100 tokens。他gasPrice的交易比自己的交易要高,他Alice的交易優先於她的交易。一些實現approve()將允許Bob轉移他的100 tokens,然後當Alice事務被提交時,重置Bob的批准50 tokens,實際上允許Bob訪問150 tokens。這種攻擊的緩解策略給出這裡上面連結在文件中。 

另一個突出的現實世界的例子是Bancor。Ivan Bogatty和他的團隊記錄了對Bancor最初實施的有利可圖的攻擊。他的部落格文章和德文3講話詳細討論了這是如何完成的。基本上,令牌的價格是根據交易價值確定的,使用者可以觀察Bancor交易的交易池,並從前端執行它們以從價格差異中獲利。Bancor團隊解決了這一攻擊。

拒絕服務(DOS)

這個類別非常廣泛,但基本上使用者可以在一段時間內(或在某些情況下,永久)使合約無法執行的攻擊組成。這可以永遠陷入這些契約中的以太,就像第二次奇偶MultiSig攻擊一樣

漏洞 

合約可能有多種不可操作的方式。這裡我只強調一些潛在的不太明顯的區塊鏈細微的Solidity編碼模式,可能導致攻擊者執行DOS攻擊。

1.通過外部操縱對映或陣列迴圈 - 在我的冒險中,我看到了這種模式的各種形式。通常情況下,它出現在owner希望在其投資者之間分配代幣的情況下,並且distribute()可以在示例合約中看到類似功能的情況: 

contract DistributeTokens {    

    address public owner; // gets set somewhere
    address[] investors; // array of investors
    uint[] investorTokens; // the amount of tokens each investor gets
    
    // ... extra functionality, including transfertoken()
    
    function invest() public payable {
        investors.push(msg.sender);
        investorTokens.push(msg.value * 5); // 5 times the wei sent
        }    
    function distribute() public {        
        require(msg.sender == owner); // only owner
        for(uint i = 0; i < investors.length; i++) { 
            // here transferToken(to,amount) transfers "amount" of tokens to the address "to"
            transferToken(investors[i],investorTokens[i]); 
        }
    }
}

請注意,此合約中的迴圈遍歷可能被人為誇大的陣列。攻擊者可以建立許多使用者帳戶,使investor陣列變大。原則上,可以這樣做,即執行for迴圈所需的gas超過塊gas極限,基本上使distribute()功能無法操作。

2.所有者操作 - 另一種常見模式是所有者在合約中具有特定許可權,並且必須執行一些任務才能使合約進入下一個狀態。例如,ICO合約要求所有者finalize()簽訂合約,然後允許令牌可以轉讓,即 

bool public isFinalized = false;
address public owner; // gets set somewhere

function finalize() public {    
    require(msg.sender == owner);
    isFinalized == true;
}// ... extra ICO functionality// overloaded transfer function

function transfer(address _to, uint _value) returns (bool) {    
    require(isFinalized);
    super.transfer(_to,_value)
}

在這種情況下,如果特權使用者丟失其私鑰 或變為非活動狀態,則整個令牌合約變得無法操作。在這種情況下,如果owner無法呼叫finalize()不可以轉讓代幣,即令牌生態系統的整個操作取決於一個地址。

3.基於外部呼叫的進展狀態 - 合約有時被編寫成為了進入新的狀態需要將乙太網傳送到某個地址,或者等待來自外部來源的某些輸入。這些模式可能導致DOS攻擊,當外部呼叫失敗時,或由於外部原因而被阻止。在傳送ether的例子中,使用者可以建立一個不接受ether的契約。如果合約需要將ether送到這個地址才能進入新的狀態,那麼合約將永遠不會達到新的狀態,因為乙ether永遠不會被送到合約。

預防技術

在第一個例子中,合約不應該迴圈通過可以被外部使用者人為操縱的資料結構。建議撤銷模式,每個投資者都會呼叫撤銷函式來獨立宣告令牌。

在第二個例子中,要求特權使用者改變合約的狀態。在這樣的例子中(只要有可能),如果無法使用故障安全裝置,則可以使用故障安全裝置owner。一種解決方案可能是建立owner一個多合約。另一種解決方案是使用一個時間段,其中線路[13]上的需求可以包括基於時間的機制,例如require(msg.sender == owner || now > unlockTime)允許任何使用者在一段時間後完成,由指定unlockTime。這種緩解技術也可以在第三個例子中使用。如果需要進行外部呼叫才能進入新狀態,請考慮其可能的失敗情況,並且可能會新增基於時間的狀態進度,以防止所需的呼叫不會到來。

注意:當然,這些建議可以集中替代,maintenanceUser如果需要的話,可以新增一個誰可以來解決基於DOS攻擊向量的問題。通常,這類合約包含對這種實體的權力的信任問題,但這不是本節的對話。

真實的例子:GovernMental

GovernMental是一個古老的龐氏騙局,積累了相當多的以太。實際上,它曾經積累過一百一十萬個以太。不幸的是,它很容易受到本節提到的DOS漏洞的影響。這個Reddit Post描述了合約如何刪除一個大的對映以撤銷以太。這個對映的刪除有一個gas成本超過了當時的gas阻塞限制,因此不可能撤回1100ether。合約地址為0xF45717552f12Ef7cb65e95476F217Ea008167Ae3,您可以從交易0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b中看到1100ether最終通過使用2.5Mgas的交易獲得。

阻止時間戳操作

資料塊時間戳歷來被用於各種應用,例如隨機數的函式(請參閱函式部分以獲取更多詳細資訊),鎖定一段時間的資金以及時間相關的各種狀態變化的條件語句。礦工有能力稍微調整時間戳,如果在智慧合約中使用錯誤的塊時間戳,這可能會證明是相當危險的。

一些有用的參考資料是:Solidity Docs,這個堆疊交換問題,

漏洞

block.timestamp或者別名now可以由礦工操縱,如果他們有這樣做的動機。讓我們構建一個簡單的遊戲,這將容易受到礦工的剝削,

roulette.sol:contract Roulette {    uint public pastBlockTime; // Forces one bet per block
    
    constructor() public payable {} // initially fund contract
    
    // fallback function used to make a bet
    function () public payable {        require(msg.value == 10 ether); // must send 10 ether to play
        require(now != pastBlockTime); // only 1 transaction per block
        pastBlockTime = now;        if(now % 15 == 0) { // winner
            msg.sender.transfer(this.balance);
        }
    }
}

這份合約表現得像一個簡單的彩票。每塊一筆交易可以打賭10 ether贏得合約餘額的機會。這裡的假設是,block.timestamp關於最後兩位數字是均勻分佈的。如果是這樣,那麼將有1/15的機會贏得這個彩票。 但是,正如我們所知,礦工可以根據需要調整時間戳。在這種特殊情況下,如果合約中有足夠的ether,解決某個區塊的礦工將被激勵選擇一個15 block.timestamp或now15 的時間戳0。在這樣做的時候,他們可能會贏得這個合約以及塊獎勵。由於每個區塊只允許一個人下注,所以這也容易受到前線攻擊。

在實踐中,塊時間戳是單調遞增的,所以礦工不能選擇任意塊時間戳(它們必須大於其前輩)。它們也限制在將來設定不太遠的塊時間,因為這些塊可能會被網路拒絕(節點不會驗證其時間戳未來的塊)。

預防技術

塊時間戳不應該用於函式或產生隨機數 - 也就是說,它們不應該是決定性因素(直接或通過某些推導)獲得遊戲或改變重要狀態(如果假定為隨機)。

時間敏感的邏輯有時是必需的; 即解鎖合約(時間鎖定),幾周後完成ICO或強制執行到期日期。有時建議使用block.number(參見Solidity文件)和平均塊時間來估計時間; .ie 1 week與10 second塊時間相等,約等於,60480 blocks。因此,指定更改合約狀態的塊編號可能更安全,因為礦工無法輕鬆操作塊編號。該BAT ICO合約採用這種策略。

如果合約不是特別關心礦工對塊時間戳的操作,這可能是不必要的,但是在開發約同時應該注意這一點。

真實的例子:GovernMental

GovernMental是一個古老的龐氏騙局,積累了相當多的以太。它也容易受到基於時間戳的攻擊。該合約在最後一輪加入球員(至少一分鐘)內完成。因此,作為玩家的礦工可以調整時間戳(未來的時間,使其看起來像是一分鐘過去了),以顯示玩家是最後一分鐘加入的時間(儘管這是現實中並非如此)。關於這方面的更多細節可以在Tanya Bahrynovska 的“以太坊安全漏洞史”中找到。

謹慎建構函式

建構函式是特殊函式,在初始化合約時經常執行關鍵的特權任務。在solidity v0.4.22建構函式被定義為與包含它們的合約名稱相同的函式之前。因此,如果合約名稱在開發過程中發生變化,如果建構函式名稱沒有更改,它將變成正常的可呼叫函式。正如你可以想象的,這可以(並且)導致一些有趣的合約黑客。 為了進一步閱讀,我建議讀者嘗試Ethernaught挑戰(特別是輻射水平)。

漏洞

如果合約名稱被修改,或者在建構函式名稱中存在拼寫錯誤以致它不再與合約名稱匹配,則建構函式的行為將與普通函式類似。這可能會導致可怕的後果,特別是如果建構函式正在執行特權操作。考慮以下合約:

contract OwnerWallet {    address public owner;    //constructor
    function ownerWallet(address _owner) public {
        owner = _owner;
    }    
    // fallback. Collect ether.
    function () payable {} 
    
    function withdraw() public {        require(msg.sender == owner); 
        msg.sender.transfer(this.balance);
    }
}

該合約收集以太,並只允許所有者通過呼叫該withdraw()函式來撤銷所有以太。這個問題是由於建構函式沒有完全以合約名稱命名的。具體來說,ownerWallet是不一樣的OwnerWallet。因此,任何使用者都可以呼叫該ownerWallet()函式,將自己設定為所有者,然後通過呼叫將合約中的所有內容都取出來withdraw()。

預防技術

這個問題已經在Solidity編譯器的版本中得到了主要解決0.4.22。該版本引入了一個constructor指定建構函式的關鍵字,而不是要求函式的名稱與契約名稱匹配。建議使用此關鍵字來指定建構函式,以防止上面突出顯示的命名問題。

真實世界的例子:Rubixi

Rubixi(合約程式碼)是另一個展現這種脆弱性的傳銷方案。它最初被呼叫,DynamicPyramid但合約名稱在部署之前已更改Rubixi。建構函式的名字沒有改變,允許任何使用者成為creator。關於這個bug的一些有趣的討論可以在這個比特幣執行緒中找到。最終,它允許使用者爭取creator地位,從金字塔計劃中支付費用。關於這個特定bug的更多細節可以在這裡找到。

虛擬化儲存指標

EVM將資料儲存為storage或作為memory。開發合約時強烈建議如何完成這項工作,並強烈建議函式區域性變數的預設型別。這是因為可能通過不恰當地初始化變數來產生易受攻擊的合約。 要了解更多關於storage和memory的EVM,看到Solidity Docs: Data Location,Solidity Docs: Layout of State Variables in Storage,Solidity Docs: Layout in Memory。 本節以Stefan Beyer出色的文章為基礎。關於這個話題的進一步閱讀可以從Sefan的靈感中找到,這是這個reddit思路。

漏洞

函式內的區域性變數預設為storage或memory取決於它們的型別。未初始化的本地storage變數可能會指向合約中的其他意外儲存變數,從而導致故意(即,開發人員故意將它們放在那裡進行攻擊)或無意的漏洞。 我們來考慮以下相對簡單的名稱註冊商合約:

// A Locked Name Registrarcontract NameRegistrar {    
    bool public unlocked = false;  // registrar locked, no name updates
    
    struct NameRecord { // map hashes to addresses
        bytes32 name;  
        address mappedAddress;
    }    
    mapping(address => NameRecord) public registeredNameRecord; // records who registered names 
    mapping(bytes32 => address) public resolve; // resolves hashes to addresses
    
    function register(bytes32 _name, address _mappedAddress) public {        // set up the new NameRecord
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 

        resolve[_name] = _mappedAddress;
        registeredNameRecord[msg.sender] = newRecord; 

        require(unlocked); // only allow registrations if contract is unlocked
    }
}

這個簡單的名稱註冊商只有一個功能。當合約是unlocked,它允許任何人註冊一個名稱(作為bytes32雜湊)並將該名稱對映到地址。不幸的是,此註冊商最初被鎖定,並且require線上[23]禁止register()新增姓名記錄。然而,在這個合約中存在一個漏洞,它允許名稱註冊而不管unlocked變數。

為了討論這個漏洞,首先我們需要了解儲存在Solidity中的工作方式。作為一個高層次的概述(沒有任何適當的技術細節 - 我建議閱讀Solidity文件以進行適當的審查),狀態變數按順序儲存在合約中出現的插槽中(它們可以組合在一起,但在本例中不可以,所以我們不用擔心)。因此,unlocked存在於slot 0,registeredNameRecord在存在slot 1和resolve在slot 2等。這些槽是位元組大小32(有與我們忽略現在對映新增的複雜性)。布林unlocked將看起來像0x000...0(64 0,不包括0x)for false或0x000...1(63 0's)true。正如你所看到的,在這個特殊的例子中,儲存會有很大的浪費。

下一個資料,我們需要的,是Solidity違約複雜資料型別,例如structs,以storage初始化它們作為區域性變數時。因此,newRecord在行[16]上預設為storage。該漏洞是由newRecord未初始化的事實引起的。由於它預設為儲存,因此它成為儲存指標,並且由於它未初始化,它指向插槽0(即unlocked儲存位置)。請注意,上線[17]和[18]我們然後設定nameRecord.name到_name和nameRecord.mappedAddress到_mappedAddress,這實際上改變了時隙0和時隙1的儲存位置用於修改都unlocked和與之相關聯的儲存槽registeredNameRecord。

這意味著unlocked可以直接通過函式的bytes32 _name引數進行修改register()。因此,如果最後一個位元組為_name非零,它將修改儲存的最後一個位元組slot 0並直接轉換unlocked為true。這樣_name的值將通過require()線[23],因為我們正在設定unlocked到true。在Remix中試試這個。注意如果你使用下面_name的形式,函式會通過:0x0000000000000000000000000000000000000000000000000000000000000001

預防技術

Solidity編譯器會提出未經初始化的儲存變數作為警告,因此開發人員在構建智慧合約時應小心注意這些警告。當前版本的mist(0.10)不允許編譯這些合約。在處理複雜型別時明確使用memory或storage確定它們的行為如預期一般是很好的做法。

真實世界的例子:蜜罐:OpenAddressLottery和CryptoRoulette

一個名為OpenAddressLottery(合約程式碼)的蜜罐被部署,它使用這個未初始化的儲存變數querk從一些可能的黑客收集ether。合約是相當深入的,所以我會把討論留在這個reddit思路中,這個攻擊很清楚地解釋了。

另一個蜜罐,CryptoRoulette(合約程式碼)也利用這個技巧嘗試並收集一些以太。如果您無法弄清楚攻擊是如何進行的,請參閱對以太坊蜜罐合約的分析以獲得對此合約和其他內容的概述。

浮點和精度

在撰寫本文時(Solidity v0.4.24),不支援定點或浮點數。這意味著浮點表示必須用Solidity中的整數型別進行表示。如果沒有正確實施,這可能會導致錯誤/漏洞。

如需進一步閱讀,請參閱以太坊合約安全技術和提示 - 使用整數部分舍入,

漏洞

由於Solidity中沒有固定點型別,因此開發人員需要使用標準整數資料型別來實現它們自己的型別。在這個過程中,開發人員可能遇到一些陷阱。我將嘗試在本節中重點介紹其中的一些內容。

讓我們從一個程式碼示例開始(為簡單起見,忽略任何over / under流問題)。

contract FunWithNumbers {
    uint constant public tokensPerEth = 10; 
    uint constant public weiPerEth = 1e18;    
    mapping(address => uint) public balances;    
    function buyTokens() public payable {        
    uint tokens = msg.value/weiPerEth*tokensPerEth; // convert wei to eth, then multiply by token rate
        balances[msg.sender] += tokens; 
    }    
    function sellTokens(uint tokens) public {        
        require(balances[msg.sender] >= tokens);        
        uint eth = tokens/tokensPerEth; 
        balances[msg.sender] -= tokens;        
        msg.sender.transfer(eth*weiPerEth); //
    }
}

 

這個簡單的令牌買/賣合約在代幣的買賣中存在一些明顯的問題。雖然買賣令牌的數學計算是正確的,但浮點數的缺乏會給出錯誤的結果。例如,當線上[7]上購買令牌時,如果該值小於1 ether最初的除法將導致0最後的乘法0(即200 wei除以1e18weiPerEth等於0)。同樣,當銷售代幣時,任何代幣10都不會產生0 ether。事實上,這裡四捨五入總是下降,所以銷售29 tokens,將導致2 ether。

 

這個合約的問題是精度只能到最近的ether(即1e18 wei)。當您需要更高的精度時,decimals在處理ERC20令牌時,這有時會變得棘手。

預防技術

保持智慧合約的正確精確度非常重要,尤其是在處理反映經濟決策的比率和比率時。 您應該確保您使用的任何比率或比率都允許分數中的大分子。例如,我們tokensPerEth在示例中使用了費率。使用weiPerTokens這將是一個很大的數字會更好。解決我們可以做的令牌數量問題msg.sender/weiPerTokens。這會給出更精確的結果。

要記住的另一個策略是注意操作的順序。在上面的例子中,購買令牌的計算是msg.value/weiPerEth tokenPerEth。請注意,除法發生在乘法之前。如果計算首先進行乘法,然後再進行除法,那麼這個例子會達到更高的精度msg.value tokenPerEth/weiPerEth。

最後,當為數字定義任意精度時,將變數轉換為更高精度,執行所有數學運算,然後最後在需要時將其轉換回輸出精度可能是一個好主意。通常uint256使用它們(因為它們對於gas使用來說是最佳的),它們的範圍約為60個數量級,其中一些可用於數學運算的精確度。可能會出現這樣的情況:最好將所有變數高精度地保持穩定並在外部應用程式中轉換回較低的精度(這實際上是ERC20令牌合約中decimals變數的工作原理)。要檢視如何完成此操作的示例以及要執行此操作的庫,我建議檢視Maker DAO DSMath。他們使用一些時髦的命名WAD的和RAY的,但這個概念是非常有用的。

真實世界的例子:Ethstick

我無法找到一個很好的例子,說明四捨五入導致合約中出現嚴重問題,但我相信這裡有很多。如果你有一個好的想法,請隨時更新。

由於缺乏一個很好的例子,我想引起您對Ethstick的關注,主要是因為我喜歡合約中的酷命名。但是,這個合約並沒有使用任何擴充套件的精確度wei。所以這個合約會有四捨五入的問題,但只是在wei精確度方面。它有一些更嚴重的缺陷,但這些都與區塊鏈上的函式有關(見Entropty Illusion)。關於Ethstick合約的進一步討論,我會把你推薦給Peter Venesses的另一篇文章,以太坊合約對於黑客來說就是糖果。

Tx.Origin身份驗證

Solidity具有一個全域性變數,tx.origin它遍歷整個呼叫棧並返回最初傳送呼叫(或事務)的帳戶的地址。在智慧合約中使用此變數進行身份驗證會使合約容易受到類似網路釣魚的攻擊。 有關進一步閱讀,請參閱Stack Exchange Question,Peter Venesses部落格和Solidity - Tx.Origin攻擊。

漏洞

授權使用者使用該tx.origin變數的合約通常容易受到網路釣魚攻擊的攻擊,這可能會誘使使用者對易受攻擊的合約執行身份驗證操作。 考慮簡單的合約,
 

contract Phishable {    
    address public owner;    
    constructor (address _owner) {
        owner = _owner; 
    }    
    
    function () public payable {} // collect ether

    function withdrawAll(address _recipient) public {        
        require(tx.origin == owner);
        _recipient.transfer(this.balance); 
    }
}

 

請注意,在[11]行中,此合約授權withdrawAll()使用該功能tx.origin。該合約允許攻擊者建立表單的攻擊合約,
 

 

import "Phishable.sol";contract AttackContract { 
    
    Phishable phishableContract; 
    address attacker; // The attackers address to receive funds.

    constructor (Phishable _phishableContract, address _attackerAddress) { 
        phishableContract = _phishableContract; 
        attacker = _attackerAddress;
    }    
    function () { 
        phishableContract.withdrawAll(attacker); 
    }
}

 

為了利用這個合約,攻擊者會部署它,然後說服Phishable合約的所有者傳送一定數量的合約。攻擊者可能把這個合約偽裝成他們自己的私人地址,社工受害人傳送某種形式的交易到地址。受害者除非注意,否則可能不會注意到攻擊者地址上有程式碼,或者攻擊者可能將其作為多重簽名錢包或某些高階儲存錢包傳遞。

 

在任何情況下,如果受害者向AttackContract地址傳送了一個事務(有足夠的天然氣),它將呼叫fallback功能,後者又呼叫該引數withdrawAll()的Phishable合約功能attacker。這將導致所有資金從Phishable合約中撤回到attacker地址。這是因為,首先初始化呼叫的地址是受害者(即owner中的Phishable合約)。因此,tx.origin將等於owner和require所述的上線[11] Phishable合約會通過。

預防技術

tx.origin不應該用於智慧合約授權。這並不是說該tx.origin變數不應該被使用。它確實在智慧合約中有一些合法用例。例如,如果有人想要拒絕外部合約呼叫當前合約,他們可以實現一個requirefrom require(tx.origin == msg.sender)。這可以防止用於呼叫當前合約的中間合約,將合約限制為常規無程式碼地址。

真實世界的例子:未知

我不知道這種形式在野的任何公開的利用。

以太坊怪異模式

我打算用社群發現的各種有趣怪癖填充本節。這些都儲存在這個部落格中,因為如果在實踐中使用這些怪癖,它們可能有助於智慧合約開發。

無鍵ether

合約地址是確定性的,這意味著它們可以在實際建立地址之前進行計算。建立合約的地址和產生其他合約的合約都是這種情況。實際上,建立的合約地址取決於:

keccak256(rlp.encode([<account_address>, <transaction_nonce>])

從本質上講,合約的地址就是keccak256建立它與賬戶事務隨機數[^ 2]連線的賬戶的雜湊值。合約也是如此,除了合約nonce的開始1地址的交易nonce的開始0。

這意味著給定一個以太坊地址,我們可以計算出該地址可以產生的所有可能的合約地址。例如,如果地址0x123000...000是在其第100次交易中建立合約keccak256(rlp.encode[0x123...000, 100]),則會建立合約地址,該地址將提供合約地址0xed4cafc88a13f5d58a163e61591b9385b6fe6d1a。

這是什麼意思呢?這意味著您可以將ether傳送到預先確定的地址(您不擁有私鑰的地址,但知道您的某個帳戶可以建立合約)。您可以將ether傳送到該地址,然後通過稍後建立在同一地址上生成的合約來檢索乙太網。建構函式可用於返回所有預先傳送的以太。因此,如果有人在哪裡獲得你的以太坊私鑰,攻擊者很難發現你的以太坊地址也可以訪問這個隱藏的乙太網。事實上,如果攻擊者花費太多事務處理,以致需要訪問您的乙太網的隨機數,則不可能恢復您的隱藏乙太網。 讓我用合約澄清一下。
 

contract KeylessHiddenEthCreator { 
    uint public currentContractNonce = 1; // keep track of this contracts nonce publicly (it's also found in the contracts state)

    // determine future addresses which can hide ether. 
    function futureAddresses(uint8 nonce) public view returns (address) {        
        if(nonce == 0) {            
                return address(keccak256(0xd6, 0x94, this, 0x80));
        }        
        return address(keccak256(0xd6, 0x94, this, nonce));    // need to implement rlp encoding properly for a full range of nonces
    }    
    // increment the contract nonce or retrieve ether from a hidden/key-less account
    // provided the nonce is correct
    
    function retrieveHiddenEther(address beneficiary) public returns (address) {
    
        currentContractNonce +=1;       
        return new RecoverContract(beneficiary);
    }    
    function () payable {} // Allow ether transfers (helps for playing in remix)}

contract RecoverContract { 
    constructor(address beneficiary) {        
    selfdestruct(beneficiary); // don't deploy code. Return the ether stored here to the beneficiary. 
    }
 }

 

這個合約允許你儲存無金鑰的以太(相對安全,從某種意義上說你不能錯誤地忽略隨機數)[^ 3]。該futureAddresses()功能可用於計算此合約可產生的前127個合約地址,方法是指定nonce。如果您將ether傳送到其中一個地址,則可以稍後通過呼叫retrieveHiddenEther()足夠的時間來恢復。例如,如果您選擇nonce=4(並將ether傳送到關聯的地址),則需要呼叫retrieveHiddenEther()四次,然後將乙太網恢復到該beneficiary地址。

 

這可以在沒有合約的情況下完成。您可以將ether傳送到可以從您的一個標準以太坊帳戶建立的地址,並在以後以正確的隨機數恢復。但是要小心,如果你不小心超過了恢復你的以太幣所需的交易隨機數,你的資金將永遠丟失。

有關一些更高階的技巧,你可以用這個怪癖做更多的資訊,我推薦閱讀Martin Swende的文章。

一次性地址

以太坊交易簽名使用橢圓曲線數字簽名演算法(ECDSA)。通常,為了在以太坊上傳送經過驗證的交易,您需要使用您的以太坊私鑰簽署一條訊息,該私鑰授權從您的賬戶中支出。在稍微更詳細,您註冊的訊息是復仇交易的組成部分,具體而言,to,value,gas,gasPrice,nonce和data領域。以太坊簽名的結果是三個數字v,r和s。我不會詳細說明這些代表的內容,而是將感興趣的讀者引至ECDSA wiki頁面(描述r和s)以及Ethereum Yellow Paper(附錄F--描述v),最後EIP155為當前使用v。

所以我們知道以太坊交易簽名包含一條訊息和數字v,r並且s。我們可以通過使用訊息(即交易細節)來檢查簽名是否有效,r並s派生出以太坊地址。如果派生的以太坊地址匹配from事務的欄位,那麼我們知道r並且s由擁有(或有權訪問)該from欄位的私鑰的人建立,因此簽名是有效的。

現在考慮一下,我們並不擁有一個私鑰,而是為任意事務構建值r和值s。考慮我們有一個交易,引數為: {to : “ 0xa9e ”,value : 10e18,nonce : 0 }

我忽略了其他引數。該交易將傳送10位乙太網到該0xa9e地址。現在讓我們說我們做了一些數字r和s(這些有特定的範圍)和v。如果我們推匯出與這些編號相關的以太坊地址,我們將得到一個隨機的以太坊地址,讓我們來呼叫它0x54321。知道這個地址,我們可以向地址傳送10個ether 0x54321(不需要擁有該地址的私鑰)。在將來的任何時候,我們都可以傳送交易, {to : “ 0xa9e ”,value : 10e18,nonce : 0,from : “ 0x54321 ” }

以及簽名,即v,r和s我們組成。這將是一個有效的交易,因為派生地址將匹配我們的from欄位。這使我們可以將我們的錢從這個隨機地址(0x54321)中分配到我們選擇的地址0xa9e。因此,我們設法將ether儲存在我們沒有私鑰的地址中,並使用一次性事務來恢復以太。

這個怪癖還可以用來以無可信賴的方式向許多人傳送ether,正如尼克約翰遜在“ 如何將ether傳送給11,440人”中所描述的那樣。

有趣的加密相關的hacks/bugs列表

[1]:此程式碼已從web3j修改過

[2]:事務隨機數就像一個事務計數器。從您的賬戶傳送交易時,它會增加您的交易時間。

[3]:不要部署此合約來儲存任何真實的乙太網。僅用於演示目的。它沒有固有的特權,任何人都可以在部署和使用它時恢復乙太網。 

相關文章