- Published on
Ethernaut - Motorbike - Solution
- Authors
- Name
- Marco Besier, Ph.D.
- @marcobesier
Ethernaut - Motorbike - Solution
IMPORTANT: selfdestruct
has been deprecated. Starting from the Cancun hard fork, the underlying opcode no longer deletes the code and data associated with an account and only transfers its Ether to the beneficiary, unless executed in the same transaction in which the contract was created (see EIP-6780). Since a contract has empty code during construction, it's no longer possible to successfully execute the delegatecall
that's required to solve this level. For this reason, we'll only discuss the originally intended pre-Cancun solution in this post.
Contract
// SPDX-License-Identifier: MIT
pragma solidity <0.7.0;
import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";
contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
struct AddressSlot {
address value;
}
// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()"));
require(success, "Call failed");
}
// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback() external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}
// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}
contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
address public upgrader;
uint256 public horsePower;
struct AddressSlot {
address value;
}
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}
// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}
// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}
// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(address newImplementation, bytes memory data) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}
// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}
Solution
The goal of this challenge is to selfdestruct
the Engine
contract.
Before we discuss the solution, I would highly recommend reading EIP-1967 and the Initializable version relevant for this level. In addition, it's a good idea to build a simple UUPS Proxy yourself by following this tutorial or any other introductory UUPS Proxy tutorial on YouTube, etc.
The first difference between a UUPS Proxy and the Transparent Proxy Pattern is that the former has the upgrade logic in the implementation contract while the latter has it in the proxy. The other difference is that UUPS Proxies define a dedicated storage slot that stores the address of the logic contract in order to prevent storage collisions.
In this level, Motorbike
is the proxy and Engine
is the implementation contract. The first thing we notice is that Engine
does not contain any selfdestruct
functionality. Thus, we will have to solve this level by finding a way to upgrade Engine
so that the new version contains a selfdestruct
that we can call.
To upgrade the implementation, Engine
contains an upgradeToAndCall()
function. However, this function can only be called by the upgrader
. Therefore, we first need to find a way to register our player address as the upgrader
.
To achieve this, notice that Motorbike
calls initialize()
via delegatecall()
, leaving the storage of Initializable
unaffected! In particular, the initialized
state variable will continue to be false
, even after Motorbike
has initialized the Engine
! Consequently, we can become the upgrader
by calling initialize()
directly from our player EOA.
First, we determine the Engine
's address via
cast storage <your level instance> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url $SEP_RPC_URL
and subsequently call initialize()
via:
cast send <your Engine address> "initialize()" --rpc-url $SEP_RPC_URL --private-key <your private key>
Now that we are the upgrader
, we deploy the following attacker contract so that we can subsequently upgrade our Engine
to it:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MotorbikeAttacker {
function destroy() external {
selfdestruct(payable(address(0)));
}
}
To complete the attack, we can now call upgradeToAndCall()
from our player EOA, providing MotorbikeAttacker
's address as the first parameter and destroy()
's function selector, 0x83197ef0, as the second.