Ethernaut(Lvl 18): Abracadabra!. The meaning of life is ...42
Writing very tiny contracts on the EVM
Table of contents
PermalinkUnderstanding Contract Creation
When a contract is initialized, the following steps occur:
A user or contract sends a transaction to the Ethereum network without specifying a recipient address. This signals to the EVM that it's a contract creation transaction, distinct from a regular send/call transaction.
The EVM compiles the contract code, written in Solidity (a high-level, human-readable language), into bytecode (a low-level, machine-readable language). This bytecode consists of opcodes, which are executed sequentially in a single call stack.
It's crucial to note that the contract creation bytecode comprises both initialization code and the contract's actual runtime code, concatenated in sequential order.
During contract creation, the EVM executes the initialization code until it encounters the first STOP or RETURN instruction in the stack. This stage includes running the contract's constructor() function, resulting in the assignment of a unique contract address.
Once the initialization code is executed, only the runtime code remains in the stack. These opcodes are then copied into memory and returned to the EVM.
Subsequently, the EVM stores this surplus code in the state storage, linked to the new contract address. This runtime code is executed by the stack in all future calls to the new contract.
PermalinkThe Challenge
This challenge needs you to provide the Ethernaut with a Solver
, a contract that responds to whatIsTheMeaningOfLife()
with the right number.
The solver's code needs to be really tiny.10 opcodes at most.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}
MagicNumberHacker
, is designed to exploit a vulnerability in a smart contract that uses the create2
opcode to deploy a new contract. The create2
opcode allows for deterministic contract creation, meaning that given the same input parameters, the same contract address will be generated. This is a powerful feature but can be exploited if not properly secured.
Here's a succinct explanation of how the attack works:
- Initialization: The
MagicNumberHacker
contract is initialized with the address of theIChallenge
contract it targets. This is done in the constructor, which takes the address of theIChallenge
contract as an argument.
- Bytecode Preparation: The contract prepares a specific bytecode (
bytecode
variable) that will be used to create a new contract. This bytecode is hardcoded into the contract.
- Salt Value: A
salt
value is set to0
. Thesalt
is an important parameter in thecreate2
opcode. It ensures that even if the same bytecode is used, the resulting contract address will be unique if the salt is different.
- Contract Creation: The contract uses inline assembly to call the
create2
opcode. Thecreate2
opcode takes three arguments: the endowment (how much Ether to send to the new contract), the bytecode to deploy, and the salt. Thecreate2
opcode is called with0
endowment, the prepared bytecode, and thesalt
value. The result of this operation is the address of the newly created contract, which is stored in thesolver
variable.
- Setting the Solver: Finally, the
MagicNumberHacker
contract calls thesetSolver
function on theIChallenge
contract, passing the address of the newly created contract (solver
). This is the exploit: by controlling thesolver
address, the attacker can potentially manipulate theIChallenge
contract's behavior, depending on how it's programmed to interact with thesolver
.
The key to this attack is the use of the create2
opcode to predictably create a new contract address. If the IChallenge
contract relies on the uniqueness of the solver
address or has specific expectations about the solver
contract's behavior, this attack could be successful.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IChallenge{
function setSolver(address _solver) external;
}
contract MagicNumberHacker{
IChallenge challenge;
constructor(address _challenge){
challenge = IChallenge(_challenge);
}
function attack() public {
bytes memory bytecode = hex"600a600c600039600a6000f3602a60005260206000f3";
bytes32 salt = 0;
address solver;
assembly {
solver := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
}
challenge.setSolver(solver);
}
}
To understand how the bytecode was created, a very clear and understandable example is given here .