2018-8-8 12:12
來源: HiBlock-Net
雖然處于起步階段,但是 Solidity 已被廣泛采用,并被用于編譯我們今天看到的許多以太坊智能合約中的字節碼。相應地,開發者和用戶也獲得許多嚴酷的教訓,例如發現語言和EVM的細微差別。這篇文章旨在作為一個相對深入和最新的介紹性文章,詳述 Solidity 開發人員曾經踩過的坑,避免后續開發者重蹈覆轍。
1
以太坊智能合約的特點之一是能夠調用和利用其他外部合約的代碼。合約通常也處理 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() 函數,并存入一些 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 的文章。
2
以太坊虛擬機(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() 函數。
3
通常,當 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)的一些詭異操作,并描述了客戶端節點如何檢查不正確的不變量,這可能會導致相當災難性的客戶端問題。
4
合約不使用 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 競賽中出現了一些可利用該漏洞的合約的例子。
5
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(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.
}
}
請注意,此攻擊合約可以通過更改存儲位置 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 修改過。
6
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 萬美元。
7
以太坊區塊鏈上的所有交易都是確定性的狀態轉換操作。這意味著每筆交易都會改變以太坊生態系統的全球狀態,并且它以可計算的方式進行,沒有不確定性。這最終意味著在區塊鏈生態系統內不存在熵或隨機性的來源。Solidity 中沒有 rand() 功能。實現區中心化的熵源(隨機性)是一個由來已久的問題,人們提出了很多想法來解決這個問題(例如,RanDAO,或是如 Vitalik 在這篇帖子中說的那樣,使用哈希鏈)。
漏洞
以太坊平臺上建立的首批合約中,有一些是圍繞博彩的。從根本上講,博彩需要不確定性(可以下注),這使得在區塊鏈(一個確定性系統)上構建博彩系統變得相當困難。很明顯,不確定性只能來自于區塊鏈外部的來源。朋友之間怡情還是可以的(例如參見承諾揭示技術),然而,要讓合約成為賭場(比如玩 21 點或是輪盤賭),則困難得多。一個常見的誤區是使用未來的塊變量,如區塊哈希值,時間戳,區塊高低或是 Gas 上限。與這些設計有關的問題是,這些量都是由挖礦的礦工控制的,因此并不是真正隨機的。
例如,考慮一個輪盤賭智能合約,其邏輯是如果下一個塊哈希值以偶數結尾,則返回一個黑色數字。一個礦工(或礦池)可以在黑色上下注 100 萬美元。如果他們挖出下一個塊并發現塊哈希值以奇數結尾,他們會高興地不發布他們的塊、繼續挖礦、直到他們挖出一個塊哈希值為偶數的塊(假設區塊獎勵和費用低于 100 萬美元)。Martin Swende 在其優秀的博客文章中指出,使用過去或現在的區塊變量可能會更具破壞性。此外,僅使用塊變量意味著偽隨機數對于一個塊中的所有交易都是相同的,所以攻擊者可以通過在一個塊內進行多次交易來使收益倍增(如果賭注有上限的話)。
預防技術
熵(隨機性)的來源只能在區塊鏈之外。在熟人之間,這可以通過使用諸如 commit-reveal 之類的系統來解決,或通過將信任模型更改為一組參與者(例如 RanDAO)。這也可以通過一個中心化的實體來完成,這個實體充當一個隨機性的預言機(Oracle)。區塊變量(一般來說,有一些例外)不應該被用來提供熵,因為它們可以被礦工操縱。
真實世界示例:PRNG 合約
Arseny Reutov 分析了 3649 份使用某種偽隨機數發生器(PRNG)的已上線智能合約,在發現 43 份可被利用的合約之后寫了一篇博文。該文詳細討論了使用區塊變量作為熵源的缺陷。
8
以太坊全球計算機的好處之一是能夠重復使用代碼、與已部署在網絡上的合約進行交互。因此,大量合約引用外部合約,并且在一般運營中使用外部消息調用(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。
9
這種攻擊并不是專門針對 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。