Ethernaut #18 MagicNum
MagicNum
Challenge
To solve this level, you only need to provide the Ethernaut with a Solver
, a contract that responds to whatIsTheMeaningOfLife()
with the right number.
Easy right? Well… there’s a catch.
The solver’s code needs to be really tiny. Really reaaaaaallly tiny. Like freakin’ really really itty-bitty tiny: 10 opcodes at most.
Hint: Perhaps its time to leave the comfort of the Solidity compiler momentarily, and build this one by hand O_o. That’s right: Raw EVM bytecode.
Good luck!
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract MagicNum {
address public solver;
constructor() public {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}
Background
So this was challenge that too me the longest compared to the rest, even though I’m fairly confidednt with touching bytecode and have seen the Ethereum VM and its bytecode before. Even though I was ranther confident that I could write basic some functionality in raw EVM bytecode and I have seen all the details that I would need to get this going, I have never put them together.
So I will try to summarize the basics here.
Bytecode
Understanding the EVM bytecode is actually fairly simple, its just a Harvard-Architecture stack based machine with most functionality you would expect from any machine such as push, pop, load, store, add, xor etc. Other than that it has some special functionality that is specific on the blockchain, like how you interact with other contracts, and how to access blockchain specific variables such as the value sent with a transaction etc. I have a rather strong background in comptuer architecture (started a PhD which I never finished) so this was not a problem to me.
The opcodes that are needed for this challenge are the PUSH1
, CODECOPY
and RETURN
opcodes, nothing else!
PUSH1
is an opcode that takes a 1-byte immediate argument and pushes it to the stackCODECOPY
is an opcode that will pop three values off the stackdestOffset
,offset
andlength
and copy fromoffset
in the code segment intodestOffset
in memorylength
bytes.RETURN
is an opcode that will pop two values off the stackoffset
andlength
and will returnlength
bytes to the caller starting fromoffset
in memory
For further details there is a plethora of resources online, so I will be just leave a few here
The assembly of the opcodes is also straight forward. Each of the three instructions has a single byte identifier, and PUSH1
takes a second byte as its argument. The encoding is in big-endian.
In the following example
// return(offset=0, length=32)
PUSH1 0x20 // length -- opcode: 0x6020
PUSH1 0x00 // offset -- opcode: 0x6000
RETURN // -- opcode: 0xf3
the bytecode would become 0x60206000f3
.
Contract Interaction
Most people are familiar with solidity, and solidity is a high level language and has the notion of functions. We can interact with a contract using multiple different functions, for example we can call the transfer
function or the approve
function on an ERC20
contract. One could think that the offset to which code should execute could depend on the function called, but that is not the case. The EVM is similar to a standard binary in that regard. When the contract is called, its code is loaded and execution starts from the entry point (which for EVM is the first byte in the code – no ELF stuff here).
The notion of function calls is build in the contract call its self. The code loaded will parse the data that is provided to it and decide what to do. If its solidity code, it will most likely check if the provided method identifier is known and do what it has to do accordingly. If its not solidity code it could do something else. In our case it will just start executing our code. Again in this regard its like a standard binary. It loads and executes, if you have logic in order to do special functionality depending on user input, it will, otherwise it will just do what it was programed to do.
For further details here is a resource I used out of many out there
Deployment
The final part is deploying the contract. Once more I had the understanding of what this was, and what its purpose was, but never got to work with it in any detail. So the way it works is this, when a contract is deployed the transaction data is executed as EVM bytecode and whatever is returned by it, is stored as the contract code.
At first I thought that hardhat ContractFactory.deploy
would handle this for me, so I wrote a simple payload that just returns 42. I thought that the ABI and the bytecode should have enough information to be able to call the constructor. But it probably does not, the deploy function expects the deploy bytecode. What ended up happening was a smart contract with only a single byte of bytecode, this was an invalid opcode with id 42!
$ curl -s http://127.0.0.1:8545 -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_getCode","params":["0xf9c5166Fc52A96a3B47D5724DB645CC7E7a06108", "latest"],"id":1}' | jq
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x000000000000000000000000000000000000000000000000000000000000002a"
}
While I wasn’t sure what was going on I changed the ABI of the whatIsTheMeaningOfLife
function to a state changing function and called it so that I could debug it using its transaction hash. There is probably a more elegant way to do this but it did the job.
Running debug_traceTransaction
on the transaction hash gave this
$ curl -s http://localhost:8545 -X POST -H "Content-Type: application/json" --data '{"method":"debug_traceTransaction","params":["0xb03b4da8727bfa90b7a8ba3dee28942beb2899bc0cb0f26384f17600e8bcf0a8"],"id":1,"jsonrpc":"2.0"}' | jq
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"gas": 21052,
"failed": false,
"returnValue": "",
"structLogs": [
{
"pc": 0,
"op": "STOP",
"gas": 0,
"gasCost": 0,
"depth": 1,
"stack": [],
"memory": [],
"storage": {}
}
]
}
}
For further details here is a resource I used out of many out there
Attack
Payload
In order to get this to work I need 2 pieces of bytecode. The main bytecode that will be executed and the deployment bytecode. The main bytecode should be limited to a max of 10 opcodes. The simplest solution would look like this.
// mstore(0x00, 0xa2)
PUSH1 0xa2 // bytecode 0x60a2
PUSH1 0x00 // bytecode 0x6000
MSTORE // bytecode 0x52
// return(0x00, 0x20)
PUSH1 0x20 // bytecode 0x6020
PUSH1 0x00 // bytecode 0x6000
RETURN // bytecode 0x3f
The question that naturally arises is where should this be placed for it to work as expected. And as already mentioned above, if this is the start of the smart contract code then any code that hits the contract will simply execute this bytecode. As simple as that. No need for method identifiers or payable checks or anything.
If we assemble the bytecode would look like 0x60a2600052602060003f
which is 10 bytes long.
Deployment
In order to deploy this contract, a piece of code needs to be written that will return the bytecode above.
// copycode(0x00, 0x0c, 0x0a)
PUSH1 0xa2 // bytecode 0x600a
PUSH1 0x00 // bytecode 0x600c
PUSH1 0x00 // bytecode 0x6000
COPYCODE // bytecode 0x39
// return(0x00, 0x0a)
PUSH1 0x20 // bytecode 0x600a
PUSH1 0x00 // bytecode 0x6000
RETURN // bytecode 0x3f
// payload follows
// ...
If we assemble the bytecode would look like 0x600a600c600039600a60003f
which is 12 bytes long.
This code will copy from the code being executed (the data of the transaction in this case) starting from position 12 to memory at position 0 for 10 bytes. If we skip the fisrt 12 bytes we land exactly after the deployment bytecode, hence the start of the payload bytecode. Then this memory region is returned.
Combining Payload and Deployment bytecode
The final EVM assembly would look like this
// -------- DEPLOYMENT --------
// copycode(0x00, 0x0c, 0x0a)
PUSH1 0xa2 // bytecode 0x600a
PUSH1 0x00 // bytecode 0x600c
PUSH1 0x00 // bytecode 0x6000
COPYCODE // bytecode 0x39
// return(0x00, 0x0a)
PUSH1 0x20 // bytecode 0x600a
PUSH1 0x00 // bytecode 0x6000
RETURN // bytecode 0x3f
// -------- PAYLOAD --------
// mstore(0x00, 0xa2)
PUSH1 0xa2 // bytecode 0x60a2
PUSH1 0x00 // bytecode 0x6000
MSTORE // bytecode 0x52
// return(0x00, 0x20)
PUSH1 0x20 // bytecode 0x6020
PUSH1 0x00 // bytecode 0x6000
RETURN // bytecode 0x3f
And the final bytecode would look like this 0x600a600c600039600a60003f60a2600052602060003f
.
We should keep in mind that the the opcode limit is on the contract code, and not the deployment code, so this would satisfy the constraint.
Steps
- Deploy the bytecode above
- Call the
setSolver
function on the target contract with the address of the deployed contract
magic-num-solve.js
const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");
function evm_push1(n) {
return ethers.utils.hexConcat(["0x60", n])
}
function evm_codecopy(destOffset, offset, length) {
return ethers.utils.hexConcat([
evm_push1(length),
evm_push1(offset),
evm_push1(destOffset),
"0x39"
])
}
function evm_return(offset, length) {
return ethers.utils.hexConcat([
evm_push1(length),
evm_push1(offset),
"0xf3"
])
}
function evm_mstore(offset, value) {
return ethers.utils.hexConcat([
evm_push1(value),
evm_push1(offset),
"0x52"
])
}
async function main() {
magicNumAddress = "0x972Ca37E06bB169d949265a1bCe62AD0dfF46180";
contract = await ethers.getContractAt("MagicNum", magicNumAddress);
const accounts = await hre.ethers.getSigners();
account = accounts[0];
let abi = [
"constructor()",
"function whatIsTheMeaningOfLife() view returns (uint256)",
];
// contract bytecode
bytecode = ethers.utils.hexConcat([
evm_mstore(0x00, 0x2a),
evm_return(0x00, 0x20)
])
// bytecode to deploy contract
deployBytecode = ethers.utils.hexConcat([
evm_codecopy(0x00, 0x0c, 0x0a),
evm_return(0x00, 0x0a)
])
// deploy tiny contract
factory = new ethers.ContractFactory(abi, ethers.utils.hexConcat([deployBytecode, bytecode]), account);
tinyContract = await factory.deploy();
await tinyContract.deployed()
console.log("Tiny contract address : " + tinyContract.address)
answer = await tinyContract.connect(account).whatIsTheMeaningOfLife()
console.log("whatIsTheMeaningOfLife? " + answer)
// submit solver
tx = await contract.connect(account).setSolver(tinyContract.address)
receipt = await tx.wait()
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
$ npx hardhat run scripts/magic-num-solve.js --network rinkeby
Tiny contract address : 0x81c8E48D7f2b6fb78781B23CbBbc5bc590773446
whatIsTheMeaningOfLife? 42
Success Message
Congratulations! If you solved this level, consider yourself a Master of the Universe.
Go ahead and pierce a random object in the room with your Magnum look. Now, try to move it from afar; Your telekinesis habilities might have just started working.