2022 年 6 月 28 日
ccryptoo 1 1652595272

Metamask開發公司ConsenSys:給 Solidity 開發者的 16 個安全建議

以下將重點關注 Solidity 的安全開發建議,這些建議也可能對用其他語言開發智能合約具有指導意義。本文源自 Consensys 的部落格文章《ConsensysSolidity Best Practices for Smart Contract Security》,由動區專欄作者 BlockBeats:0xAA 編譯及整理。
(前情提要:中國 12 歲神童發佈「Solidity智能合約教程」還空投代幣,現因不明原因下架
(事件背景:微軟 淡馬錫參投,ConsenSys融資4.5億美元!宣布MetaMask將推出DAO、發行代幣

 

文是 Metamask 的背後公司(Consensys)在 2020 年 8 月寫的一篇文章,關於智能合約安全,其中給了Solidity 開發者 16 條安全建議,並包含程式碼樣例。

這篇文章寫於一年半前,那時候 solidity 版本才到 0.5,現在已經是 0.8 了,很多函數都不同。但很多建議至今仍然適用。

如果您已經牢記智能合約的安全理念並且正在處理 EVM 的特性,那麼是時候考慮一些特定於 Solidity 編程語言的安全模式了。在本綜述中,我們將重點關注 Solidity 的安全開發建議,這些建議也可能對用其他語言開發智能合約具有指導意義。

好了,讓我們開始吧。

1. 正確使用assert(), require(), revert()

便利函數 assert 和 require 可用於檢查條件,如果條件不滿足則拋出異常。

assert 函數只能用於測試內部錯誤和檢查不變量。

應該使用 require 函數來確保滿足有效條件,例如輸入或合約狀態變量,或者驗證來自外部合約調用的返回值。(0xAA 注: solidity 在 0.8.4 版本引入自定義 error 功能,所以這個版本之前用 require,之後用 revert-error 來確保滿足有效條件)

遵循這種範式可以讓形式化分析工具來驗證無效操作碼永遠不會被運行:這意味著程式碼中沒有不變量被違反並且被形式化驗證。

ccryptoo 1 1652595272

2. modifier 僅用於檢查

修飾符(modifier)內的程式碼通常在函數體之前執行,因此任何狀態更改或外部調用都會違反Checks-Effects-Interactions 模式。此外,開發人員也可能不會注意到這些語句,因為修飾符的程式碼可能遠離函數聲明。例如,修飾符的外部調用可能導致重入攻擊:

ccryptoo 1 1652595273

在這種情況下,Registry 合約可以通過調用 isVoter() 中的 Election.vote() 進行重入攻擊。

注意:使用 modifier 替換多個函數中的重複條件檢查,例如 isOwner(),否則在函數內部使用 require 或 revert。這使您的智能合約程式碼更具可讀性和更易於審計。

3. 注意整數除法的捨入

所有整數除法都向下舍入到最接近的整數。如果您需要更高的精度,請考慮使用乘數,或同時存儲分子和分母。

(將來,Solidity 會有浮點類型,這會讓這更容易。)

ccryptoo 1 1652595273 1

使用乘數可以防止四捨五入,在將來使用 x 時需要考慮這個乘數:

ccryptoo 1 1652595273 2

存儲分子和分母意味著你可以計算 numerator/denominator 鏈下的結果:

ccryptoo 1 1652595274

4. 注意抽象合約 abstract 和接口 interface 之間的權衡

接口和抽象合約都為智能合約提供了一種可定制和可重用的方法。Solidity 0.4.11 中引入的接口類似於抽象合約,但不能實現任何功能。接口也有限制,例如不能訪問存儲或從其他接口繼承,這通常使抽象合約更實用。雖然,接口對於在實現之前設計合約肯定有用。此外,重要的是要記住,如果合約繼承自抽象合約,它必須通過覆蓋實現所有未實現的功能,否則它也將是抽象的。

5. Fallback function 後備函數

0xAA 注:Solidity 0.5.0 時還沒有 receive 函數且 fallback 函數當時也直接聲明為 function()。關於最新版本的fallback 函數教程,請看連接

  • 保持 fallback function 簡單

當合約被發送一個沒有參數的消息(或者沒有函數匹配)或,fallback function 會被調用。當被.send() 或.transfer 觸發時,fallback function 只能訪問 2300 gas。如果您希望能夠從 send() 或 .transfer() 接收 ETH,那麼您在後備函數中最多可以做的就是記錄一個事件。如果需要計算更多 gas,請使用適當的函數。

ccryptoo 1 1652595274 1

  • 檢查回退函數中的數據長度

由於 fallback function 不僅在普通以太傳輸(沒有msg.data)時調用,並且也在沒有其他函數匹配時調用,如果後備函數僅用於記錄接收到的ETH,則應檢查數據是否為空。否則,如果你的合約使用不正確,調用了不存在的函數,調用者將不會注意到。

ccryptoo 1 1652595274 2

6. 顯式標記應付函數和狀態變量

從 Solidity 0.4.0 開始,每個接收以太幣的函數都必須使用 payable 修飾符,否則如果交易有 msg.value gt; 0 將被 revert。

注意:可能不明顯的事情: payable 修飾符僅適用於來自external 合約的調用。如果我在同一個合約的 payable 函數中調用了一個非 payable 函數,這個非 payable 函數不會失敗,儘管 msg.value 不為零。

7. 顯式標記函數和狀態變量的可見性

明確標記函數和狀態變量的可見性。函數可以指定為 external,public,internal 或private。請理解它們之間的差異,例如,external 可能足以代替 public。而對於狀態變量,external 是不用的。明確標記可見性將更容易捕捉關於誰可以調用函數或訪問變量的錯誤。

External 函數是合約接口的一部分。external 函數 f 不能在內部調用(即 f() 不工作,但 this.f() 工作)。外部函數在接收大量數據時效率更高。Public 函數是合約接口的一部分,既可以在內部調用,也可以通過消息調用。對於公共狀態變量,會生成一個自動 getter 函數。Internal 函數和狀態變量只能在內部訪問,不使用 this. Private 函數和狀態變量僅對定義它們的合約可見,而在派生合約中不可見。注意:合約內的所有內容對區塊鏈外部的所有觀察者都是可見的,甚至是 Private 變量。

ccryptoo 1 1652595275

8. 將編譯指示鎖定到特定的編譯器版本

合約應該使用與它們經過最多測試的相同編譯器版本和標誌來部署。鎖定pragma 有助於確保合約不會被意外部署,例如使用可能具有更高風險未發現錯誤的最新編譯器。合約也可能由其他人部署,並且 pragma 指示原作者預期的編譯器版本。

ccryptoo 1 1652595275 1

注意:浮動 pragma 版本(即^0.4.25)可以用 0.4.26-nightly.2018.9.25 編譯,但不應使用 nightly 版本來編譯生產程式碼。

警告:當合約打算供其他開發人員使用時,可以允許 Pragma 語句浮動,例如庫或 EthPM 包中的合約。否則,開發人員需要手動更新編譯指示才能在本地編譯。

9. 使用事件來監控合約活動

有一種方法可以在部署後監控合約的活動是很有用的。實現這一點的一種方法是查看合約的所有交易,但這可能還不夠,因為合約之間的消息調用不會記錄在區塊鏈中。此外,它只顯示輸入參數,而不是對狀態進行的實際更改。事件也可用於觸發用戶介面中的功能。

ccryptoo 1 1652595275 2

在這裡,Game 合約將內部調用 Charity.donate(). 該交易不會出現在 Charity 的外部交易列表中,而只在內部交易中可見。

事件是記錄合約中發生的事情的便捷方式。發出的事件與其他合約數據一起留在區塊鏈中,可供將來審計。這是對上述示例的改進,使用事件來提供慈善機構的捐贈歷史。

ccryptoo 1 1652595276

在這裡,無論是否直接通過合約的所有交易都 Charity 將與捐贈的金額一起顯示在該合約的事件列表中。

注意:優先使用更新的 Solidity 結構。首選結構/別名,例如 selfdestruct (而不是suicide) 和 keccak256 (而不是sha3)。類似的模式 require(msg.sender.send(1 ether)) 也可以簡化為使用 transfer(),如 msg.sender.transfer(1 ether). 查看 Solidity 更改日誌以了解更多類似更改。

10. 請注意,「內置」函數可能會被隱藏

目前可以在 Solidity 中隱藏內置的全局變量。這允許合約覆蓋內置插件的功能,例如 msg 和 revert()。儘管這是有意為之,但它可能會誤導合約用戶對合約的真實行為。

ccryptoo 1 1652595276 1

合約用戶(和審計員)應該了解他們打算使用的任何應用程序的完整智能合約源程式碼。

11. 避免使用 tx.origin

永遠不要 tx.origin 用於授權,另一個合約可以有一個方法來調用你的合約(例如,用戶有一些資金)並且你的合約將授權該交易,因為你的地址位於 tx.origin。

ccryptoo 1 1652595276 2

您應該使用 msg.sender 授權(如果另一個合約調用您的合約msg.sender 將是該合約的地址,而不是調用該合約的用戶的地址)。

警告:除了授權問題外,tx.origin 將來有可能從以太坊協議中刪除,因此使用的代碼tx.origin 將與未來版本不兼容.。Vitalik:不要假設 tx.origin 將繼續存在。

還值得一提的是,通過使用 tx.origin 您會限制合約之間的互操作性,因為使用 tx.origin 的合約不能被另一個合約使用,因為合約不能是 tx.origin。

12. 時間戳依賴

使用時間戳執行合約中的關鍵功能時,有三個主要考慮因素,尤其是當操作涉及資金轉移時。

時間戳操作

請注意,區塊的時間戳可以由礦工操縱。考慮這個合約:

ccryptoo 1 1652595277

當合約使用時間戳播種一個隨機數時,礦工實際上可以在區塊被驗證後的15 秒內發布一個時間戳,從而有效地允許礦工預先計算一個更有利於他們中獎機會的選項。時間戳不是隨機的,不應在該上下文中使用。

13. 15 秒規則

黃皮書(Ethereum 的參考規範)沒有規定多少塊可以在時間上漂移的限制,但它確實規定每個時間戳應該大於其父時間戳。流行的以太坊協議實現 Geth 和 Parity 都拒絕未來時間戳超過 15 秒的塊。因此,評估時間戳使用的一個好的經驗法則是:如果您的時間相關事件的規模可以變化 15 秒並保持完整性,那麼可以使用block.timestamp.

避免block.number 用作時間戳

可以使用 block.number 屬性和平均塊時間來估計時間增量,但這不是未來的證據,因為出塊時間可能會改變(例如分叉重組和難度炸彈)。但在只持續幾天的銷售中,15 秒規則允許人們獲得更可靠的時間估計。

14. 多重繼承注意事項

在Solidity 中使用多重繼承時,了解編譯器如何構成繼承圖非常重要。

ccryptoo 1 1652595277 1

部署合約時,編譯器將從右到左線性化繼承(在關鍵字is 之後,父項從最基類到最派生列出)。這是合約 A 的線性化:

Final lt;- B lt;- C lt;- A

線性化的結果將產生 fee = 5 的值,因為 C 是最接近衍生的合約。這似乎很明顯,但想像一下 C 能夠隱藏關鍵函數、重新排序布爾子句並導致開發人員編寫可利用的合約的場景。靜態分析目前不會引發被遮蓋的函數的問題,因此必須手動檢查。

為了幫助做出貢獻,Solidity 的 Github 有一個包含所有繼承相關問題的項目

15. 使用接口類型而不是地址來保證類型安全

當函數將合約地址作為參數時,最好傳遞接口或合約類型而不是純 address。因為如果函數在源程式碼的其他地方被調用,編譯器將提供額外的類型安全保證。

在這裡,我們看到了兩種選擇:

ccryptoo 1 1652595277 2

可以從下面示例中看出使用 TypeSafeAuction 合約的好處。如果 validateBet() 使用 address 參數或合約類型而不是 Validator 合約類型,編譯器將拋出此錯誤:

ccryptoo 1 1652595278

16. 避免extcodesize 用於檢查外部擁有的帳戶

以下修飾符(或類似的檢查)通常用於驗證調用是來自外部擁有的帳戶(EOA)還是合約帳戶:

ccryptoo 1 1652595278 1

這個想法很簡單:如果一個地址包含程式碼,它就不是一個 EOA,而是一個合約帳戶。但是,合約在構建期間沒有可用的源程式碼。這意味著在構造函數運行時,它可以調用其他合約,但 extcodesize 在它的地址返回零。下面是一個最小的例子,展示瞭如何繞過這個檢查:

ccryptoo 1 1652595278 2

因為可以預先計算合約地址,所以如果它檢查一個在 block n 處為空,但在 block n 之後被部署的合約,依然會失敗。

警告:這個問題很微妙。如果您的目標是阻止其他合約調用您的合約,那麼 extcodesize 檢查可能就足夠了。另一種方法是檢查的值 (tx.origin == msg.sender)`,儘管這也有缺點。

在其他情況下,extcodesize 可能會為您服務。在這裡描述所有這些超出了範圍。了解 EVM 的基本行為並使用您的判斷。

📍