二十三種 DeFi 安全事故彙總:智能合約風險與防範
撰文:Austin Zhang,Jon Li,Asymmetries Technologies
智能合約的安全性問題一直是業界的一個重點話題,由於程式員的某些疏忽造成了思維和邏輯上的漏洞,從而導致黑客有了可乘之機。我們搜集了目前在 DeFi 領域已經發生了安全事故的智能合約,並根據我們編寫的示例代碼來實證分析其中的原因,希望能給到同事和同行們一些啟示。
(一)重入攻擊
主要攻擊方式之一:合約調用惡意外部合約結束之前,惡意外部合約函數反向調用原合約函數利用相關漏洞。
(1)示例代碼
(2)案例1:2021 年 12 月 22 日 Uniswap V3 流動性管理協議 Visor 被盜 120 ETH
事故原因:deposit 函數沒有防重入鎖也沒有驗證 from 地址是否是合法的 Visor 合約地址。攻擊者傳入攻擊合約地址,重複調用 deposit 函數繞過取款金額檢查多次取款。
(3)案例2:2021 年 6 月 5 日 BurgerSwap 被盜 700 萬美金
事故原因:類似 Uniswap 的原創 dex,分為 Platform 和 Pool 兩個合約。Platform 類似 Uniswap 的 Router,Pair 類似 Uniswap 的 Pool,開發者錯誤的將 K 值校驗放在 Platform 計算,攻擊者在 Platform 中進行重入攻擊,多次以舊的 K 值換取代幣,造成流動性提供者損失。
(4)解決方案:調用外部合約前確保所有中間狀態變量已更新並使用再入鎖(例如 OpenZeppelin's ReentrancyGuard)。
(二)未檢查函數返回值
調用外部合約函數時,有些函數調用失敗不會拋出錯誤回滾交易而是返回 false,如果忘記檢查函數返回值會導致誤以為調用成功。
(1)示例代碼
(2)案例:2021 年 4 月 4 日 ForceDao 到被攻擊損失 183 ETH
事故原因:Force 代幣的 transferFrom 餘額不足時返回 false 而不是直接回滾交易,合約中未做判斷導致轉帳失敗時也被認為成功,可以換取到對應代幣。
(3)解決方案:使用 call 函數調用外部合約時必須檢查調用是否成功。 注:call調用外部合約未匹配到函數時,會調用外部合約 fallback 或者 receive 函數,如果外部合約有定義 receive 函數且 call 函數未攜帶 calldata 則會調用外部合約 receive 函數,其他情況調用fallback函數。
(三)未正確設置函數可見性
Solidity 中函數默認為 public,可以被外部調用,一旦未將關鍵函數設置為 Private,就會導致安全風險。
(1)示例代碼
(2)案例1:2022 年 1 月 22 日 Dex Crosswise 被攻擊損失 80 萬美金
事故原因:Crosswise 雖然實現了權限驗證函數 onlyOwner,但忘記設置 setTrustedForwarder 為 private,導致被攻擊者利用,將自己設置為池子的 Owner 將代幣全部轉走。
(3)案例2:2020 年 6 月 18 日 跨鏈橋 Bancor Network 被攻擊損失 14 萬美金
事故原因:合約用於轉帳的函數默認為 public,攻擊者可以直接調用轉走合約中的代幣。
(4)解決方案:提款函數事關合約資產的轉移,需謹慎設置權限控制,確保初始化函數只能運行一次。
(四)未驗證 Map 中 Key 不存在的情況
Solidity 中的 Mapping 在獲取對應 Key 的 Value 時,如果 Key 不存在,會返回對應類型的默認值,而不是報錯。例如 Mapping(int → int),如果對應 int 的 Key 不存在,會返回默認值 0。
(1)示例代碼
(2)案例:2021 年 7 月 11 日 跨鏈橋 ChainSwap 被攻擊損失 400 萬美金
事故原因:ChainSwap 依賴其網絡中的 validator 進行轉帳。為了限制 validator 一次轉走超過其質押的代幣,設置了配額。結果合約中存在漏洞可以繞過配額限制,當地址變量 signatory 不存在時,authQuotes[signatory] 和 lasttimeUpdateQuoteOf[signatory] 會返回 0 ,導致配額計算錯誤返回預期外的大量配額。
(3)解決方案:使用 map 時必須檢查 key 是否存在。
(五)在狀態變更前進行轉帳
轉帳時有可能被重入,利用未變更的狀態進行攻擊。
(1)案例:2021 年 8 月 17 日 XSURGE 被攻擊損失 500 萬美金
事故原因:在轉帳後才修改 totalSupply,轉帳時被重入另外一個未加重入鎖的函數損失 500 萬美金。
(2)解決方案:使用了再入鎖也要在所有狀態變更之後再轉帳。
(六)初始化函數未做調用和權限限制
很多合約需要初始化子合約,例如 Uniswap 需要通過 Factory 合約初始化 Pool 合約,這時候如果忘記對子合約的初始化函數做權限和重複初始化限制,可能被攻擊者進行惡意初始化。
(1)案例:2021 年 8 月 11 日 Punk Protocol 被攻擊損失 400 萬美金
事故原因:池子的 initialize 函數未做權限和重複調用限制,攻擊者調用該函數將自己設置為 Forge 管理員權限,並調用 withdrawToForge 將池子所有資金都發送到攻擊者地址。
(2)解決方案:初始化函數必須設置成只能初始化一次。
(七)未正確檢查對應合約函數實現
通常智能合約被調用的函數不存在時會報錯,但如果合約實現了 fallback 函數,則會自動調用 fallback 函數。有時 fallback 函數並不會報錯,導致調用方誤以為調用成功。
(1)案例:2022 年 1 月 18 日跨鏈橋 Multichain 被攻擊損失 450 ETH
事故原因:通常 ERC20 的合約會實現 permit 函數,用於簽名檢查與授權操作(該函數類似 approve,可以借由預生成的簽名由其他合約調用,節省用戶的 gas 費)。但 WETH、PERI、OMT、WBNB、MATIC、AVAX 六種代幣的合約沒有實現 permit 卻實現了 fallback,Multichain 在檢查這些代幣的權限時誤以為用戶已經授權轉帳給攻擊者,導致代幣被盜。
(2)解決方案:不同代幣的實現方式不同,引入新代幣之前應仔細檢查其具體實現。
(八)未正確處理帶轉帳費的代幣
有些代幣在轉帳時會銷毀一部分轉帳費用,導致實際收到的代幣餘額偏少,如果開發者沒考慮到這一點,以轉帳值計算,會導致出現偏差。
(1)案例:2021 年 8 月 19 日 Pinecone 被盜 20 萬美金
事故原因:Pinecone 使用其代幣 PCT 作為資金池的質押代幣,PCT 轉帳會有手續費的損耗。合約並沒有考慮相關損耗導致用戶份額和質押的 PCT 總額出現偏差,被攻擊者利用領取多餘的獎勵。
(2)解決方案:謹記不是所有的代幣轉帳費都為 native token。
(九)簽名驗證漏洞
簽名被重複使用,或者利用椭圓曲線簽名算法的對稱性,根據已有簽名構造合法簽名。
(1)案例:2021 年 7 月 12 日 AnySwap 被盜 800 萬美金
事故原因:對交易簽名除了私鑰外需要一個隨機數 R,但是 Anyswap 部署新合約失誤,導致在 BSC 上的 V3 路由器 MPC 帳戶下有兩個交易具有相同的 R 值簽名,攻擊者反推到這個 MPC 賬戶的私鑰轉走了被盜資金。
(2)解決方案:使用EIP-712標準驗證簽名,參考OpenZeppelin的實現:https://docs.openzeppelin.com/contracts/3.x/api/drafts。
(十)未考慮合約餘額可能產生的變化
礦工挖出塊時或者智能合約調用 selfdestruct 函數銷毀自己時可以向任意地址強行打幣改變其原生代幣的餘額。當使用餘額函數返回值作為判斷條件時,餘額有可能被強行改變導致風險,極端情況下甚至導致合約拒絕服務(DoS)。
(1)示例代碼
即使捐贈合約不能接受代幣轉帳,合約餘額也可能在部署後被改變,嚴格檢查已空投總量與合約餘額之和等於總供應量可能導致捐贈合約拒絕服務(Dos)。
(2)解決方案:在合約中避免對合約餘額做嚴格相等的檢查。
(十一)使用 delegatecall 調用外部合約
delegatecall 可以將對應合約的函數代碼內嵌到當前上下文中執行,就像調用內置函數一般。如果不小心調用了惡意合約極易導致攻擊。
(1)示例代碼
當攻擊者調用 forward 函數並傳入 Attack 合約地址以及函數 setOwner() 作為參數時,Proxy 合約owner 將被修改為攻擊者地址。
(2)解決方案:不推薦使用 delegatecall調用外部合約。
(十二)授權 tx.origin
tx.origin 是交易的發起者地址,合約如果使用 tx.origin 做權限檢查,當合約的授權用戶與惡意合約交互時,惡意合約調用合約即可通過合約權限檢查。
(1)示例代碼
當 MyWallet 合約 owner 使用 transferTo 函數向 Attack 合約轉帳時,Attack 合約會 重入MyWallet 合約,並調用 transferTo 函數,此時 tx.origin 仍然為 MyWallet owner,require 條件滿足,MyWallet 餘額將被全部轉移至 Attack 合約。
(2)解決方案:不使用 tx.origin 做權限檢查。
(十三)交易排序競爭
全節點運行者可以在交易被確認之前獲取交易信息,進而根據獲取的交易信息,構造高手續費交易,讓礦工優先打包自己的交易以執行對自己有利的策略。例如,謎語合約獎勵最快找出謎底的用戶,惡意用戶可以在獲悉誠實用戶提交的謎底後,構造高手續費交易優先誠實用戶提交謎底,從而獲取獎勵;又如當用戶更新授權額度時,被授權用戶可以在更新授權額度交易被確認之前轉移舊的授權額度,如此,被授權人實際獲得的授權額度為兩次授權額度之和。
解決方案:針對謎語合約,獲得謎底的用戶先提交「隨機數+自身地址+謎底」的哈希值,謎語合約存儲該哈希值後,用戶再提交隨機信息與答案,合約檢查哈希值匹配後再發放獎勵;更新授權額度時先置零授權額度。
(十四)使用 block.timestamp 或者 block.number 作為合約時間參考
block.timestamp 與 block.number 都不能獲得精確都時間,用作智能合約的時間參考會引入潛在的風險。
解決方案:使用 oracle 獲取時間信息。
(十五)Denial-of-Service(DoS) 拒絕服務
調用外部合約可能永久失敗導致本合約不能接受新的指令,例如當合約主動對另外一個合約轉帳,而被轉帳合約沒有接受轉帳的函數時,轉帳失敗,此時合約可能進入拒絕服務狀態。
(1)示例代碼
當合約向其中一個賬號轉帳失敗會導致所有轉帳全部失敗。
(2)解決方案:合約調用外部合約時可能出現的失敗,合約需包含處理調用失敗情況的代碼,防止合約進入拒絕服務狀態。
(十六)使用鏈屬性作為隨機源
鏈屬性如 block.timestamp, blockhash, bock.difficulty 以及其他屬性可被礦工操控,存在風險。
解決方案:考慮使用 RANDAO,oracle 或比特幣區塊 hash 作為隨機源。
(十七)繼承順序錯誤
多個被繼承合約都定義了同一個函數時,繼承合約調用該函數的優先級由繼承順序決定,錯誤的繼承順序將導致函數調用錯誤。
解決方案:繼承順序說明請參考官方實例:https://solidity-by-example.org/inheritance/。
(十八)Gas不足攻擊
多簽情況下或者需要其他人幫自己代付 Gas 時,用戶準備好簽名交易並交給代執行人,代執行人再將用戶交易提交給執行合約,代執行人可以提前審查用戶代交易,惡意的代執行人或當交易內容不利於代執行人時,可以通過限制Gas的供給,使交易的執行失敗,從而阻止交易的執行。
(1)示例代碼
當Relayer調用者通過限制Gas使用導致某個交易失敗,那麼失敗的交易將永遠不能再被提交。
(2)解決方案:選擇信任的代執行人,或者在執行合約中檢查代理人提供的Gas費是否足夠。
(十九)函數類型變量跳轉
solidity 支持函數類型變量,當函數類型變量使用匯編指令賦值時,函數類型變量有可能被指向惡意構造當函數。
解決方案:如無必要,盡量避免在智能合約中使用匯編指令。
(二十)Gas Limit 服務拒絕攻擊(DoS)
區塊設置有 Gas 使用上限,如果合約當執行超過了區塊Gas使用上限,則合約永遠不能被執行成功。
(1)示例代碼
當操作的循環次數過大時,執行合約所需Gas將超過區塊上限,導致合約執行失敗。
(2)解決方案:在智能合約中謹慎操作大數組,或循環。
(二十一)abi.encodePacked() 哈希碰撞
abi.encodePacked() 采用非填充序列化,當序列化參數包含多個變長數組時,攻擊者可以在保持所有元素順序不變的前提下,改變兩個變長數組的元素,如此序列化的結果相同。
(1)示例代碼
通過構造 addUser 的輸入,攻擊者可以將 regularUsers 的成員加入 admins 成員,但是構造的輸入和原輸入的簽名相同。
(2)解決方案:使用定長數組,或者不讓調用者傳入 abi.encodePacked() 的參數,或者使用 abi.encode()。
(二十二)transfer() 和 send() 函數 Gas 不足
transfer() 和 send() 函數使用 2300 gas 以防止重入攻擊,公鏈升級後可能導致 gas 不足。
解決方案:推薦使用 call() 函數,但需做好重入攻擊防護。
(二十三)鏈上未加密隱私數據
鏈上數據完全透明,合約的private關鍵字不能阻止合約的隱私數據洩漏。
(1)示例代碼
雖然 players為 private,但攻擊者仍然可以通過解析鏈上數據讀取 players。
(2)解決方案:隱私數據需要加密放在鏈上。
以上是我們分析和總結的二十三種安全事故類型匯總,希望能夠給到您些許參考和啟示。