智能合約的漏洞與攻擊-漏洞1
近期在 DeFi 圈裡,發生了不少智能合約的漏洞與攻擊事件,每次都會使數位資產受到一定程度的損害。小則價格下跌,大則代幣被洗劫一空。至於像那種 lendf.me 的事件,先被洗劫又還回來的情況,真的只能說是幸運,如果有下次還會還回來嗎?
我把合約造成的問題分為三種,1、漏洞 2、攻擊 3、管理/機制問題。漏洞指的是開發合約時,對以太坊本身不夠了解所造成的錯誤使用,包括區塊鏈本身及合約語言,而產生的資產損失。攻擊指的是以太坊或智能合約本身出現的問題,而非開發者所導致的遭受攻擊。管理/機制問題,指的是因為功能或是某種機制的設計錯誤,而導致的損失或遭受攻擊。
管理/機制的部份,大多跟實際的業務邏輯有關,並且個案情況都不相同,因此這裡我們不個別說明,僅區分為漏洞及攻擊兩種討論。
整數溢出及下溢
溢出 overflow 發生在數值超過最大值時,比如 60+200 會超過 uint8 的最大值而變成 4。
下溢 underflow 發生在數值小於最小值時,比如 60-200 會低於 uint8 的最小值而變成 116。
在我的經驗裡,新手開發者最常發生在 uint8 uint16 ,因為它們的數值範圍較小。而進階開發者則較容易發生在數值範圍較大的 uint24 之類的。
要完全避免這種問題,應該使用另外建立的程式庫來檢查、計算。或是使用 OpenZeppelin 的 SafeMath 程式庫,用 add、sub、mul、div 、mod 來取代 +=*/%等運算操作
依賴時間戳 Timestamp
用 now 或 block.timestamp 取得的區塊時間戳,產出這個區塊的礦工在產出時可以操作這個時間戳的。使用區塊的時間戳時要注意下面的三點。
時間戳是可操作的
礦工在區塊驗証之後15秒內可以自行確定時間戳的值,所以如果你的合約是屬於樂透彩票類的合約,使用 timestamp 來做為中獎的隨機數種子,那可能會有礦工進來買你的彩票,然後透過操作 timestamp 來讓礦工自己中獎。
所以時間戳不應該用來做為隨機數種子。
15秒規則
以太坊的參考手冊黃皮書中沒有限制多少時間要產生多少區塊,但是區塊的 timestamp 必需比父級區塊的時間來得大。但是大多的協議還是拒絕超過15秒的區塊,所以依賴於時間的事件,還是可以15秒為基礎來進行變化。
不要用 block.numer 來預估時間
雖然可以用 block.numer 及平均的區塊時間來預估時間,但是這樣的區塊時間還是可能會改變,可能會破壞合約的功能。所以最好還是不要使用 block.number 來推估時間。
透過 tx.origin 驗証授權
tx.origin 是一個全局變數,返回發起交易的地址。要注意的是,合約可以被其他合約調用,所以當攻擊合約使用 fallback function 來調用時,使用 tx.origin 來驗証授權,就可能出現漏洞。
要防止這種漏洞產生,可以使用 msg.sender 來驗証授權。
浮動式的編譯器版本號 Floating pragma
這樣的設計本來是為了做好程式碼的最佳化,所以指定一個編譯器版本號。但是這個設計就可能會讓開發者指定了一個有問題的編譯器版本,可能會導致 bug ,讓合約處理危險之中。
在開源項目裡面,指定編譯器的版本號,也代表這個程式跟編譯器版本的配合,是經過測試的。除了 library 跟 package 之外,編譯器的版本號要對應本地的編譯器版本。
編譯器版本太舊
新的編譯器會修正已發現的編譯器問題,所以開發者應該保持最新的編譯器版本。
功能函數預設的可見性
函數的可見性可以指定為 public, private, internal, external. 功能函數可見性的預設值是public ,如果開發者沒有指定正確的可見性,沒有把應該私有的設為 private , 就會引來攻擊,可能意外的導致合約狀態改變。
狀態變數預設的可見性
通常開發者會宣告函數的可見性,但是變數的可見性卻很少宣告。狀態變數可以宣告為 public private internal 。還好預設的可見性是 internal 而不是 public 。
這個重點是要明確,明確地限定誰可以訪問這個變數。
沒有保護的 ether 提款
如果沒有適當的訪問控制,有心人可能就可以從合約裡面提領出原本不該提領的 ether 。這可能是由錯誤命名的構造函數所導致的,比如說讓任何人都可以重新初始化合約,進而填入可以操作合約餘額的地址。要防止這種漏洞,限制只允許被授權或按計畫的人提款。然後,構造函數一定要正確地命名。
錯誤的構造函數名稱
在 Solidity 0.4.2 之前,構造函數的名稱只能跟合約同名。在某些情況下,這就可能出問題,比如說,這個合約被改名後重新使用,但是構造函數名稱沒有更改,變成了一般的可調用函數。
在比較新的編譯器版本,可以使用 constructor 這個關鍵字來定義構造函數。這樣就可以避免這個漏洞。
未檢查回傳值
如果低層級調用的返回值未經檢查,可能在調用的函數失敗的時候程式還能繼續往下執行。這樣就會導致程式意外的行為,破壞程式的邏輯。而且調用失敗可能是由攻擊者造成的,這個情況可能被進一步的利用,產生其他種類的問題。
Solidity 裡的低層級調用有 address.call() address.callcode() address.delegatecall() address.send() 或是使用合約來調用,像是 ExternalContract.doSomething() 。這些低層級調用不會 throw exception 而是返回 false 。
使用低層級調用時,確定檢查返回值來處理可能失敗的調用。
沒有保護 selfdestruct 命令
selfdestruct 是刪除合約的命令。如果發生錯誤或不足的權限控制,惡意人士就會用來刪除合約。如果必要的話,可以使用多重簽名來防止這種攻擊。
這種攻擊曾經在 2017年發生,叫做 Parity attack 。一個匿用戶發現並且利用了存在智能合約程式庫裡的漏洞,把他自己變成合約的擁有者,然後刪除了合約,這導致 587 個錢包裡面的資金共 513774.16 eth 被堵住了。
未初始化的存儲指針
資料在 EVM 裡面以 storage memory calldata 三種不同方式儲存。充分了解及正確地初始化是重點。錯誤的初始化存儲指針或是未初始化,就會導致合約漏洞。
在 Solidity 0.5.0 版本之後,未初始化的存儲指針就不再是問題了,因為未初始化的存儲指針不會被編譯。但是充分了解什麼時候使用什麼存儲指針,還是很重要。
Assert 違規
從 Solidity 0.4.10 開始,提供 assert() require() revert() 三個錯誤處理函數,這裡針對 assert 來說明
正式的說 assert() 是為了檢查一個不變量,非正式的說 assert() 是一個過度的保鏢,保護合約的過程中也消費燃料。一個運作正常的合約不應該觸及 assert 描述。要嘛是錯誤的使用了 assert() ,要嘛是合約裡有個 bug 把 assert() 置於無效的狀態之中。
如果 assert() 檢查的條件是不變的,可以使用 require() 來代替。
使用已廢棄的函數
有些函數 Solidity 已經廢棄,由更好的函數來代替。不要用到已經廢棄的函數是很重要的,因為會導致意外的效果和編譯錯誤。
下表是已經廢棄的和取代的函數
Delegatecall 調用不被信任的調用目標
Delegatecall 是一種特殊的消息調用,幾乎等同於正規的消息調用,除了1、目標地址的執行環境是在調用合約之中 2、msg.sender 3、msg.value 三項之外,其他全部相同。
delegatecall 實際上可以看做,委托其他合約來修改調用合約的變量。
由於 delegatecall 給予了其他合約很大的權限,所以只能用來調用已經被信任的合約。如果調用的地址是來自於外部,那就必須驗証這個地址是否被信任。
簽名的延展性
通常會以為智能合約使用了加密簽名系統,所以簽名是唯一的,但實際上並不是。Ethereum中的簽名,可以在沒有私鑰的情況下修改,而且是有效的。
例如,橢圓密鑰加密中的v,r,s 值,以正確的方式修改這三個值,可以得到一個有效的簽名,但其實你並沒有正確的密鑰。
要防止這個問題,不要在合約中用簽名來檢查以前的消息簽名,有點繞口,就是不要為了檢查過往的簽名,而在合約裡產生簽名。因為惡意的用戶會找到並且重建簽名。
缺少對簽名重製攻擊的保護
有時候合約需要驗証簽名來提高可用性和燃料成本,就要考慮到實現簽名驗証。
要對簽名重製攻擊的保護,合約應該只允許新的 hash 被處理。這樣能防止惡意用戶反復的執行猜測其他用的簽名。
特別安全的簽名驗証,應該考慮以下的建議:
儲存每個由合約產生的消息的 hash ,在執行函數前檢查消息hash 跟已存在的hash
包括地址在內,確定消息只用在單一合約內
不產生消息hash包括簽名。
重復的狀態變數
在 Solidity 裡面,同名的變數是可能發生的,但是會導致超乎預期的副作用。發生在合約的繼承時特別難以區分,試想一下,父子合約都有同名的 a 變數,但是值是不相同的。在子合約裡使用 a 的值來執行自父合約繼承而來的函數,此時這個函數的結果就會是錯誤的,因為在子合約裡 a 的值跟父合約裡的值已經不一樣了。
要防止這個漏洞,我們就檢查合約系統內所有的合約,看內部是否有重名的變數。編譯器在編譯時也會提出警告,所以注意編譯器的警告也是很重要的。
來自鏈屬性的弱隨機來源
在 Ethereum 的某些應用,使用隨機數來建立公平性。但是隨機數的建立在 Ethereum 上是很困難的,可能存在一些陷阱。
使用鏈的屬性,像是 block.number blockhash block.diffculty 來產生偽隨機數。問題在於礦工可以修改這些數值。
例如在某些博彩應用,有上百萬的彩池,利益足以讓礦工產出一個以上的區塊,然後選擇會贏得彩池的區塊。聽起來不容易辦到,但其實只要權益夠高,這是可以被辦到的。
要防止礦工操控隨機數,有幾種方法
- 一個承諾的方案,像是 RANDAO 。由所有參與者共同管理的去中心化組織,來產生隨機數。
- 來自預言機的外部來源
- 用比特幣的區塊 hash , 或是更去中心化,挖礦更貴的網路。
未完待續…
參考
https://swcregistry.io/
https://github.com/KadenZipfel/smart-contract-attack-vectors
https://medium.com/better-programming/the-encyclopedia-of-smart-contract-attacks-vulnerabilities-dfc1129fdaac
https://solidity.readthedocs.io/en/latest/bugs.html
https://www.parity.io/a-postmortem-on-the-parity-multi-sig-library-self-destruct/