Ethernaut 18 MagicNumber
Ethernaut 18 MagicNumberEthernaut 18 MagicNumberEthernaut 18 MagicNumber 문제 풀이Ethernaut 18 MagicNumber writeup
Contents
문제
지문
To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte 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 bytes 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.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\\\\_______/\\\\\\\\\\\\\\\\\\_____
__________/\\\\\\\\\\_____/\\\\\\///////\\\\\\___
________/\\\\\\/\\\\\\____\\///______\\//\\\\\\__
______/\\\\\\/\\/\\\\\\______________/\\\\\\/___
____/\\\\\\/__\\/\\\\\\___________/\\\\\\//_____
__/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_____/\\\\\\//________
_\\///////////\\\\\\//____/\\\\\\/___________
___________\\/\\\\\\_____/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_
___________\\///_____\\///////////////__
*/
}배경지식
솔리디티 컨트랙트의 바이트코드는 크게 creation code와 runtime code로 나뉜다.
creation code는 컨트랙트를 배포할 때 한 번 실행되는 코드다. constructor도 이 단계에 포함된다. 이 코드는 메모리에 실제로 저장될 runtime code를 만들고, 마지막에 return으로 그 바이트코드를 반환한다.
runtime code는 배포가 끝난 뒤 컨트랙트 주소에 저장되는 코드다. 이후 외부에서 컨트랙트를 호출하면 이 코드가 실행된다. extcodesize로 확인되는 코드 크기도 creation code가 아닌 runtime code의 크기다.
이번 문제의 solver 코드 길이 10바이트 제한은 배포 후 남는 runtime code에 적용된다. 그래서 솔리디티 컴파일러가 만들어주는 일반 컨트랙트 대신, 직접 10바이트짜리 런타임 코드를 만들어서 배포해야 한다.
EVM 명령어는 주로 stack을 사용한다. 예를 들어 PUSH1 0x2a는 1바이트 값 0x2a를 stack에 올린다. MSTORE는 stack에서 offset과 value를 꺼내 메모리에 32바이트 값을 저장하고, RETURN은 메모리의 특정 구간을 호출 결과로 반환한다.
이번에 필요한 opcode는 세 개뿐이다.
| Opcode | 명령어 | 의미 |
| 0x60 | PUSH1 | 1바이트 값을 stack에 올린다 |
| 0x52 | MSTORE | 메모리의 offset 위치에 32바이트 값을 저장한다 |
| 0xf3 | RETURN | 메모리의 offset부터 length만큼 반환한다 |
Ethernaut은 whatIsTheMeaningOfLife()를 호출했을 때 반환값만 확인하므로 selector는 검사하지 않아도 된다. 런타임 코드가 어떤 calldata가 들어와도 무조건 42를 반환하면 조건을 만족한다.
문제 코드 분석
address public solver;
function setSolver(address _solver) public {
solver = _solver;
}MagicNum은 solver 주소를 저장하는 기능만 제공한다. 별도의 권한 체크도 없고, _solver가 어떤 코드인지 setSolver 단계에서 검증하지도 않는다.
검증은 Ethernaut 레벨 완료 과정에서 이루어진다. 저장된 solver를 대상으로 whatIsTheMeaningOfLife()를 호출했을 때 32바이트 정수 42가 반환되고, solver의 런타임 코드 크기가 10바이트 이하여야 한다.
반환해야 하는 값은 42다. 16진수로는 0x2a이고, ABI에서 uint256 반환값은 32바이트로 표현된다.
0x2a를 메모리 0번 위치에 32바이트 값으로 저장하고, 메모리 0번부터 32바이트를 반환하면 된다.
PUSH1 0x2a
PUSH1 0x00
MSTORE
PUSH1 0x20
PUSH1 0x00
RETURN이를 바이트코드로 쓰면 다음과 같다.
60 2a 60 00 52 60 20 60 00 f3길이를 세어보면 정확히 10바이트다.
602a60005260206000f3MSTORE는 32바이트 단위로 값을 저장한다. 0x2a는 uint256 값으로 저장되므로 반환 데이터는 다음 32바이트다.
000000000000000000000000000000000000000000000000000000000000002a10바이트짜리 코드를 그냥 트랜잭션 data로 보내면 배포가 되지 않는다. 컨트랙트 생성 트랜잭션의 data는 먼저 creation code로 실행되고, 그 실행 결과로 반환된 바이트열이 컨트랙트의 runtime code로 저장된다.
따라서 배포용 컨트랙트의 constructor에서 메모리에 602a60005260206000f3를 넣고, 그 10바이트만 반환하면 된다.
constructor() {
assembly {
mstore(0, 0x602a60005260206000f3)
return(22, 10)
}
}mstore(0, value)는 value를 32바이트로 메모리에 저장한다. 값은 오른쪽 정렬되므로 10바이트짜리 런타임 코드는 메모리의 22번 offset부터 시작한다.
메모리 구조는 다음과 같다.
00 ... 00 60 2a 60 00 52 60 20 60 00 f3
<--22B--> <------------- 10B ------------->그래서 return(22, 10)을 써야 우리가 만든 10바이트만 runtime code로 저장된다.
풀이
whatIsTheMeaningOfLife()를 Solidity 함수로 구현하는 대신, 어떤 호출이 들어와도 42를 반환하는 10바이트짜리 runtime code를 직접 만든다.
런타임 코드는 602a60005260206000f3다. 이 코드는 메모리에 0x2a를 32바이트 값으로 저장한 뒤, 그 32바이트를 그대로 반환한다. 함수 selector를 검사하지 않기 때문에 whatIsTheMeaningOfLife() 호출에도 42를 반환한다.
배포할 때는 constructor에서 이 런타임 코드를 반환하게 만들고, 배포된 Attack 컨트랙트 주소를 setSolver에 넘기면 된다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
constructor() {
assembly {
mstore(0, 0x602a60005260206000f3)
return(22, 10)
}
}
}
Challenge
Description
To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte 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 bytes 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!
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\\\\_______/\\\\\\\\\\\\\\\\\\_____
__________/\\\\\\\\\\_____/\\\\\\///////\\\\\\___
________/\\\\\\/\\\\\\____\\///______\\//\\\\\\__
______/\\\\\\/\\/\\\\\\______________/\\\\\\/___
____/\\\\\\/__\\/\\\\\\___________/\\\\\\//_____
__/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_____/\\\\\\//________
_\\///////////\\\\\\//____/\\\\\\/___________
___________\\/\\\\\\_____/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_
___________\\///_____\\///////////////__
*/
}Background
A Solidity contract's bytecode is split into creation code and runtime code.
creation code runs once when the contract is deployed. The constructor is part of this phase. This code builds the runtime code that will actually be stored, and finally returns that bytecode with return.
runtime code is the code stored at the contract address after deployment. This code runs whenever the contract is called afterwards, so the code size reported by extcodesize is the size of the runtime code, not the creation code.
The 10-byte limit on the solver's code applies to the runtime code left after deployment. So instead of a regular contract produced by the Solidity compiler, you need to hand-build a 10-byte runtime code and deploy it.
EVM instructions mostly operate on the stack. For example, PUSH1 0x2a pushes the 1-byte value 0x2a onto the stack. MSTORE pops an offset and a value from the stack and stores a 32-byte value in memory, and RETURN returns a given region of memory as the call result.
Only three opcodes are needed here.
| Opcode | Instruction | Meaning |
| 0x60 | PUSH1 | Pushes a 1-byte value onto the stack |
| 0x52 | MSTORE | Stores a 32-byte value at the given memory offset |
| 0xf3 | RETURN | Returns length bytes of memory starting at offset |
Ethernaut only checks the return value when it calls whatIsTheMeaningOfLife(), so the code does not need to check the selector. If the runtime code returns 42 for any calldata, the condition is satisfied.
Code analysis
address public solver;
function setSolver(address _solver) public {
solver = _solver;
}MagicNum only provides a way to store the solver address. There is no access check, and setSolver does not validate what code _solver has.
Validation happens during Ethernaut's level completion. Calling whatIsTheMeaningOfLife() on the stored solver must return the 32-byte integer 42, and the solver's runtime code size must be 10 bytes or less.
The value to return is 42. In hex that is 0x2a, and in the ABI a uint256 return value is encoded as 32 bytes.
Store 0x2a as a 32-byte value at memory offset 0, then return 32 bytes starting at memory offset 0.
PUSH1 0x2a
PUSH1 0x00
MSTORE
PUSH1 0x20
PUSH1 0x00
RETURNAs bytecode, this is:
60 2a 60 00 52 60 20 60 00 f3That is exactly 10 bytes.
602a60005260206000f3MSTORE stores values in 32-byte units. 0x2a is stored as a uint256, so the return data is these 32 bytes:
000000000000000000000000000000000000000000000000000000000000002aSending the 10-byte code directly as transaction data does not deploy it. The data of a contract creation transaction is first executed as creation code, and the byte string returned by that execution is stored as the contract's runtime code.
So the deployer contract's constructor should put 602a60005260206000f3 into memory and return just those 10 bytes.
constructor() {
assembly {
mstore(0, 0x602a60005260206000f3)
return(22, 10)
}
}mstore(0, value) stores value in memory as 32 bytes. The value is right-aligned, so the 10-byte runtime code starts at memory offset 22.
The memory layout:
00 ... 00 60 2a 60 00 52 60 20 60 00 f3
<--22B--> <------------- 10B ------------->return(22, 10) makes sure only our 10 bytes are stored as the runtime code.
Solution
Instead of implementing whatIsTheMeaningOfLife() as a Solidity function, we hand-build a 10-byte runtime code that returns 42 for any call.
The runtime code is 602a60005260206000f3. It stores 0x2a in memory as a 32-byte value and returns those 32 bytes as is. Since it does not check the function selector, it also returns 42 for a whatIsTheMeaningOfLife() call.
For deployment, make the constructor return this runtime code, then pass the deployed Attack contract address to setSolver.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
constructor() {
assembly {
mstore(0, 0x602a60005260206000f3)
return(22, 10)
}
}
}