智能合約的安全考量
本篇參考自Solidity文件庫 — -智能合約的安全考量https://docs.soliditylang.org/en/latest/security-considerations.html
簡介
如我們所知道Solidity 是一種物件導向的高階語言,用於實現智能合約的功能。 而智能合約是管理以太坊狀態內的帳號行為程序。
Solidity 是一種大括號語言( curly-bracket language),主要在運行在以太坊虛擬機 (EVM)。 它受到 C++、Python 和 JavaScript 的影響。 我們可以在Solidity文件庫的language influences部分找到有關 Solidity 受到哪些語言啟發的更多詳細資訊。
Solidity 是靜態類型的,支持inheritance、library和複雜的user-defined types等特性。
使用 Solidity,我們可以建立用於投票、群眾募資、匿名拍賣和多重簽名錢包等用途的智能合約。部署合約時,我們應該使用最新發布的 Solidity 版本。 除特殊情況外,只有最新版本會收到安全修復。
智能合約的安全考量
在Solidity安全考量比一般網站開發更為重要的原因是: 它絕大部分都運行跟"錢"有關的事情。也就是我們所開發的智能合約是真的錢在上面運行的。而且我們的智能合約的內容與運作都是公開可見的,也就是代碼怎麼寫的其他人都可以在區塊鍊網路上看得到。
因為在是真的錢在區塊鏈中運行,所以我們更需要嚴肅看待智能合約的安全性。因為一旦發生問題,哪可能上我們損失慘重。
本文將會列出一些常見的陷阱與一般性的安全建議,但不可能是全部。因為電腦科技不可能永遠安全,況且區塊鏈是一門新興科技,我們對此還沒有夠多的經驗,一切才剛剛開始。況且即使我們的智能合約都找不到錯誤,編譯器或區塊鏈平台本也可能存在問題。我們可以在這個清單中找到目前已知的問題表列,這個清單也是機器讀得懂的。
一般性陷阱
私有資訊與隨機性
由於我們的智能合約都區塊鏈上都是公開可見的,甚至我們將局部變量(local variables)與狀態變量(state variables)標示為”private”也一樣。而如果我們不想讓礦工作弊,那麼在智能合約中使用隨機數是非常麻煩的。
重複輸入(Re-Entrancy)
如果我們有兩個智能合約A會跟B之間交互(包含任何價值轉移)都會將控制權從A交給B。這會使得合約B在這個交互完成之前回頭呼叫合約A。讓我們看一下以下一個片段範例:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
if (payable(msg.sender).send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
上面範例的問題並不太嚴重,因為這一個操作的 gas是有限的(因為是使用withdraw函數),但它仍然有一個問題:Ether transfer永遠可以包含代碼執行,因此接收方可能是一個回頭呼叫到“withdraw”的智能合約。 這將讓它可以有多次退款,並基本上抽光這個智能合約中的所有以太幣。 特別是,以下範例智能合約將允許攻擊者多次退款,因為它使用“call”的方式會將無限制的使用gas:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
(bool success,) = msg.sender.call{value: shares[msg.sender]}("");
if (success)
shares[msg.sender] = 0;
}
}
為了避免重複輸入的狀況發生,我們可以使用 Checks-Effects-Interactions模式的方式,如下所示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
uint share = shares[msg.sender];
shares[msg.sender] = 0;
payable(msg.sender).transfer(share);
}
}
Checks-Effects-Interactions 模式確保通過合約的所有代碼執行順序在修改合約狀態(Checks)之前完成對所提供參數的所有必需檢查; 只有這樣它才會對合約狀態進行任何的更改(Effects); 在所有預計的狀態更改都已寫入存儲(Interactions)之後,它可能會呼叫其他合約中的函數。 這是防止重複輸入攻擊的一種常見的萬無一失的方法,其中外部呼叫的惡意合約能夠在整筆交易完成之前回頭去呼叫其之前的原始合約的邏輯來進行雙重花費(double-spend)剩餘的額度、雙重提取餘額等。
我們要注意的是,重複輸入不只是 以太幣轉移的影響,也是對另一個合約的任何函數呼叫的影響,因為我們把控制權將交給另一個合約了。 我們還必須考慮多個合約一起運作的情況。 一個被呼用的合約可以回頭修改我們真正要使用的另一個合約的狀態。
Gas的限制與迴圈
沒有固定迭代次數的迴圈,例如會一直使用存儲值的迴圈,必須謹慎使用:由於block gas的限制,交易只能消耗一定數量的gas。 無論是明確說明能用多少gas還是僅僅由於正常操作,迴圈中的迭代次數都可能超過block gas的限制,這可能導致整個合約在某個點會卡住(因為gas不夠了)。 不過我們還是可以使用view函數,因為它不會消耗gas。 儘管如此,這些卡住的函數可能會被其他合約呼叫,作為鏈上操作的一部分其整個交易就無法完成。
傳送與接收以太幣
目前,無論是合約還是“外部帳號”都沒有規定不可以發送以太幣給別人,就是沒有說帳戶不可以收錢。合約可以對一般性轉帳做出反應並回絕,但有一些方法可以在不建立message call的情況下移動以太幣。一種方法是簡單地“挖礦”到合約地址,第二種方法是使用 selfdestruct(x)。
如果合約收到以太幣(沒有呼叫函數),則執行接收或fallback function。如果它沒有接收或fallback功能,則以太幣將被拒絕(就是拋出exception)。在執行這些函數的期間,合約只能用當下可用的“gas stipend”(2300 gas)。這個stiped不足以修改合約內容(2300這個數字未來可能會因為hard forks而有所變動)。為確保我們的智能合約能夠以這種方式接收以太幣,我們需要檢查receive和fallback函數的 gas 要求。例如,如果我們是在 Remix進行開發,我們在如下畫面看到 。
有一種方法可以使用 addr.call{value: x}(“”) 將更多的 gas 轉發給接收合約。 這與 addr.transfer(x) 基本相同,只是addr.call函數可能會用光我們的 gas,並為接收者提供了執行更昂貴的智能合約操作(並且它返回failure code而不是自動傳播error)。 這可能包括回頭呼叫sending contract或我們可能沒有想到的其他狀態變動。 因此,它為我們提供了極大的靈活性(不管是善意或惡意)。
我們需要用最精確的單位來表示 wei 數量,因為由於缺乏精確度,任何被四捨五入的wei值都會不見。
如果我們想使用 address.transfer 發送以太幣,以下一些細節需要注意:
- 如果接收者是一個智能合約,它會導致它的receive或fallback函數被執行,這反過來又可以回頭呼叫sneding contract。
- 由於call depth超過 1024,發送以太幣可能會失敗。由於呼叫者完全控制call depth,他們可以強制以太幣傳送失敗; 我們需要考慮到這種可能性或使用 send 並確保始終檢查其返回值。 更好的方式是,使用接收者可以提取以太幣的模式來編寫我們的合約,就是接收者用pull的方式。
- 發送以太幣也可能失敗,因為recipient contract的執行需要的 gas 超過了分配量(明確地使用 require、assert、revert) — — 它“用光了gas”(OOG — out of gas)。 如果我們使用帶有返回值檢查的 transfer 或 send函數,這可能會為接收方提供一種阻止sneding contract繼續下去的方法。 同樣,這裡的最佳實踐是使用“withdraw”模式而不是“send”模式。更多使用withdraw的模式請參閱此篇文件。
呼叫Stack depth
外部函數呼叫可能隨時失敗,因為它們超過了最大呼叫stack size 1024。在這種情況下,Solidity 會拋出exception。 駭客可能會在與我們的合約交互之前將 call stack強制設為高值。 由於 Tangerine Whistle 硬分叉,63/64 規則使得呼叫stack depth攻擊變得不切實際。 另外,call stack和expression stack是不相關的,即使它們的大小限制都為 1024 個stack slot。
如果call stack耗盡,.send() 不會拋出異常,而是在這種情況下返回 false。 低階函數 .call()、.delegatecall() 和 .staticcall() 的行為模式是相同的。
授權代理
如果我們的智能合約可以充當代理(proxy),即如果它可以用使用者提供的資料呼叫任一個智能合約,那麼使用者基本上可以假定代理合約的身份是合法的。 即使我們有其他保護措施,最好構建您的合約系統,使代理沒有任何操作權限(甚至對自己也沒有)。 如果需要,我們可以使用第二個代理來完成此操作:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract ProxyWithMoreFunctionality {
PermissionlessProxy proxy;
function callOther(address addr, bytes memory payload) public
returns (bool, bytes memory) {
return proxy.callOther(addr, payload);
}
// Other functions and other functionality
}
// This is the full contract, it has no other functionality and
// requires no privileges to work.
contract PermissionlessProxy {
function callOther(address addr, bytes memory payload) public
returns (bool, bytes memory) {
return addr.call(payload);
}
}
tx.origin
不要用tx.origin進行授群。假設我們有以下這樣一個錢包合約:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
address owner;
constructor() {
owner = msg.sender;
}
function transferTo(address payable dest, uint amount) public {
// THE BUG IS RIGHT HERE, you must use msg.sender instead of tx.origin
require(tx.origin == owner);
dest.transfer(amount);
}
}
現在駭客要欺騙我們將以太幣發送到這個錢包中:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface TxUserWallet {
function transferTo(address payable dest, uint amount) external;
}
contract TxAttackWallet {
address payable owner;
constructor() {
owner = payable(msg.sender);
}
receive() external payable {
TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
}
}
如果我們的錢包檢查了 msg.sender 的授權,它將取得攻擊錢包的地址,而不是所有者地址。 但是通過檢查 tx.origin,它得到了啟動交易的原始地址,仍然是所有者地址。 攻擊錢包會立即拿光我們的錢。
兩進制補碼狀態/Underflows / Overflows
Solidity 的整數(integer)資料類型實際上並不是整數。 當數值較小時,它們類似於整數,但不能表示任意大的數字。
以下的代碼導致overflow,因為加法的結果太大而無法存儲在 uint8 類型中:
uint8 x = 255;
uint8 y = 1;
return x + y;
Solidity 有兩種處理這些overflow的模式:Checked和Unchecked或“wrapping”模式。
預設的checked模式將偵測到overflow並導致assertion失敗。 我們可以使用 unchecked { … } 去掉checked,從而導致overflow被默默地忽略掉。 如果wrapping在Unchecked { … } 中,上面的代碼將返回 0。
即使在checked模式下,也不要假設我們有受到overflkow錯誤的保護。 在這種模式下,overflow永遠都會還原回來。 如果無法避免overflow,這可能會導致智能合約卡在某個狀態。
一般來說,閱讀二進制碼的訊息呈現有其限制,它甚至有一些更特殊的有符號數的極端情況。我們需要嘗試使用 require 將輸入的大小限制在合理的範圍內,並使用 SMT checker找出潛在的overflow。
清除Mapping內的資料
Solidity 的mapping是一種指儲存key/value的資料結構,它不會看key值是不是零。 正因為如此,在沒有關於key裡面的資料是甚麼的情況下有清除mapping是不可能的。 如果將mapping用作dynamic storage array的基本類型,則刪除或塞入array對mapping element是沒有影響的。 例如,如果將mapping用作struct的member field的類型,這個struct是dynamic storage array的基本類型。 在分配包含mapping的結構或array時也會忽略mapping。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Map {
mapping (uint => uint)[] array;
function allocate(uint newMaps) public {
for (uint i = 0; i < newMaps; i++)
array.push();
}
function writeMap(uint map, uint key, uint value) public {
array[map][key] = value;
}
function readMap(uint map, uint key) public view returns (uint) {
return array[map][key];
}
function eraseMaps() public {
delete array;
}
}
上面的例子與其之後的呼叫順序:allocate(輸入10), writeMap(輸入4, 128, 256)。 此時,呼叫 readMap(4, 128) 會返回 256。如果我們呼叫 eraseMaps,state variable array的長度為零,但由於其mapping element不能為零,因此它們的資料在合約的存儲中事沒有被清掉的。 而刪除array後,呼叫 allocate(輸入5) 允許我們再次訪問 array[4],即使沒有再次呼叫 writeMap,當再次呼叫 readMap(4, 128) 還是會返回 256,。
如果必須刪除array 的資訊,可以考慮使用類似於iterable mapping的library,允許我們逐個看完key裡面的資訊並在適當的mapping中刪除它們的值。
次要的細節
不佔用全部 32 個byte的類型可能包含“dirty higher order bits”。 如果我們存取 msg.data,這一點就很重要 — — 它會帶來延展性風險:我們可以製作呼叫函數 f(uint8 x) 的交易,其原始byte參數為 0xff000001 和 0x00000001。 兩者都輸入到合約中,就 x 而言,兩者看起來都像數字 1,但 msg.data 會有所不同,因此如果我們將 keccak256(msg.data) 用於合約的任何內容,我們將得到不同的結果。
不要假設智能合約內的以太幣一開始是零
我們可能會發現自己遇到的一種情況是根據放在"存入(deposited)"和"提取(widthdrew)"以太幣的反變量(counter-variable)確定智能合約內的餘額。
假設我們有一個智能合約,它一開始 是0 wei 。有人存入 10 個以太幣,所以我們在合約餘額裡增加一個變量(也就是加10個以太幣),並斷言所有存入的以太幣等於合約內的餘額。而我們在該智能合約中沒有fallback函數,因此我們是假設沒有人可以在實際的“存入”函數之外存入以太幣。
我們需要改變這種想法。
在以太坊區塊鏈上,有可能以兩種方式存入以太幣,這會危及這些斷言。
首先,可以在部署合約之前將以太幣發送到合約地址。合約地址可以預先被計算,也就是在合約還不存在之前就先發錢過去。因為合約地址是創建智能合約的地址的哈希值加上該地址的隨機數(nonce)。如果知道將使用哪個地址來部署智能合約,則可以在部署合約之前向未來的合約地址發送少量 wei。這樣,合約就已經以非零餘額開始。
另一種選擇是將以太幣發送到智能合約,即使它沒有fallback函數或自動拋出異常。如果以太幣存儲在名為 Contract A 的智能合約中,並且想要將這些以太幣強行發送給 Contract B,則可以使用“selfdestruct”函數。使用Contrat A中的“selfdestruct(AddressOfContractB)”,ContractB將自動接收ContractA中存儲的所有以太幣。而這是無法規避的。
所以,無論我們做什麼,都不要編寫一個只檢查合約餘額的不變量(invariant)。不要假設我們檢查合約餘額的“assert” statement總是與所有記錄的 wei-deposits 相同。
區塊鏈上的隨機性問題
有兩個全局變量被錯誤地用於區塊鏈上的隨機性。一種是timestamp,存儲在“block.timestamp”或“now”中。這是最常見的錯誤。這個timestamp雖然在一定程度上是隨機的,但可能會受到礦工的影響。timestamp最終還是由礦工設定的,而不是獨立的設定。因此,礦工很有可能會影響該時間戳。
另一個變量是 block.number,這也不是一個好主意。這個變量本身並沒有太多用於隨機性,例如,它可以用來確定拍賣期的結束。隨機性仍然是區塊鏈積極研究的問題,但兩種方法慢慢出現
建議事項
嚴肅看到編譯中的警告訊息
如果編譯器告警我一些訊息,我們應該修正它。 即使我們不認為這個告警具有資安疑慮,也可能隱藏在合約中的其他問題。 始終使用最新版本的編譯器來檢視有沒有最新的告警。編譯器發出的 info類型消息並不危險,只是表示編譯器認為可能對我們有用的額外建議。
限制一定大小的以太幣
限制可以存放在智能合約中的以太幣的數量。 如果我們的原始碼、編譯器或平台出現錯誤,這些以太幣可能會不見。 但卻可以限制我們的損失量。
讓合約是小型跟模組化的
使我們的合約是小型且易懂的。 在其他合約或library中挑選出不相關的功能。 關於原始碼質量的一般性建議也適用:限制局部變量的數量、函數的長度等。 記錄我們的函數,以便其他人可以看到我們的意圖以及它是否與代碼的作用不同。
而智能合約的Unit test則可以使用in-memory blockchain的方式測試,列如使用 Ganache或Truffle。
使用Checks-Effects-Interactions模式
大多數函數將首先執行一些檢查(誰呼叫了函數,參數是否在範圍內,它們是否發送了足夠的以太幣,這個人是否有token等)。 這些檢查應首先進行。
作為第二步,如果所有檢查都通過,則應該對當前合約的狀態變量進行變動。 “與其他合約的交互應該是任何功能的最後一步”。
早期的合約延遲了一些效果,並等待外部函數呼叫以非錯誤狀態返回。 而由於之前解釋過重複輸入是一個嚴重的問題。
另外,對已知合約的呼叫可能反過來會導致對未知合約的呼叫,因此最好始終應用Checks-Effects-Interactions模式。
永遠都要包含Fail-Safe模型
雖然使區塊鏈是完全去中心化(意味著將消除任何中介),但包含某種fail-safe機制可能是一個好主意,尤其是對於新的代碼:
你可以在你的智能合約中添加一個函數來執行一些自我檢查,比如“是否有任何以太幣外洩了?”、“代幣的總和是否等於合約的餘額?” 或類似的東西。 我們需要記住,我們不能為此使用過多的gas,因此可能需要通過鏈下計算提供幫助。有關以太幣的單位換算請參考此網站。
如果自我檢查失敗,合約會自動切換到某種“fail safe”模式,例如禁用大部分功能,將控制權交給固定且受信任的第三方,或者只是將合約轉換為“ 把錢還給我的錢包”的這一類合約。
重點總結
- Check-Effects-Interactions Pattern:如果我們會呼叫外部智能合約,請始終確保不要在呼叫後才修改我們的現行合約中的變量,請在之前就確定好。檢查返回值並避免使用低接函數所可能造成的重複輸入。
- 隱私(Privacy):“私有(prviate)”變量在區塊鏈上不是看不見的。而是大家都看得見(可以使用block explorer),只是它不能被其他外部合約看到。
- 迴圈和Gas限制:盡可能避免迴圈。如果我們無法避免使用迴圈,也要避免使用“open end”的迴圈。只在我們始終知道迭代的情況下使用迴圈,並且不要低估我們耗盡 gas 的速度。
- 不要只使用合約餘額。可以將 以太幣強制發送到智能合約,因此不能期望我們的智能合約會有通過使用payable函數紀錄的以太幣數量。
- 使用“withdraw”或“pull”模式而不是“send”或“push”:通過withdraw,我們會有一個允許使用者“withdraw”的專用功能。反之亦然,例如在樂透遊戲中,獲勝者在獲勝後自動獲得轉移的以太幣。Withdraw是一種“pull”以太幣的方法,而send以太幣是一種“push”的方法。