티스토리 뷰
1. 문제
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.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}
2. 풀이
컨트랙트 바이트코드
컨트랙트 바이트코드는 컨트랙트를 초기화하는 바이트코드와 컨트랙트 실행을 위한 런타임 바이트코드로 나뉩니다. 초기화 바이트코드는 컨트랙트의 constructor 생성자 함수에 해당하는 것이고 런타임 바이트코드는 컨트랙트에서 호출이 가능한 함수들의 로직으로 구성되어 있습니다. 아래의 Contract Creation Code는 초기화 바이트코드 + 런타임 바이트코드 + ABI 인코딩된 생성자의 인수로 구성되어 있습니다.
문제에서는 런타임 바이트코드가 최대 10개의 opcode로 구성되어야함을 요구하고 있습니다.
런타임 바이트코드
최대 10개의 opcode를 사용해 호출되면 42를 반환하는 컨트랙트를 배포해야 합니다.
우선 42를 반환하려면 마지막에는 반드시 RETURN opcode를 사용해야 합니다. RETURN은 메모리에서 offset만큼의 바이트를 건너뛰고 size만큼의 바이트를 복사하여 반환합니다. 예를 들어, 메모리에 0x12345678이 들어있고 offset이 3, size가 2라면 반환되는 값은 0x7800입니다. 읽어 들여야 하는 길이가 메모리에 저장된 값의 길이보다 길다면, 부족한 부분은 0으로 채워집니다.
RETURN이 실행되려면 스택에서 offset과 size를 가져와야 합니다. 따라서 스택에는 offset과 size를 가리키는, 최소 2개의 원소가 들어있어야 합니다.
스택에 원소를 추가하려면 PUSH<M> opcode를 사용해야 합니다. 여기서 M은 0~32 사이의 값입니다. 스택은 가장 마지막에 들어간 원소가 가장 먼저 나오는 구조이므로, size를 먼저 넣고 offset을 넣어줘야 합니다.
여기까지 정리한 opcode의 순서는 다음과 같습니다.
PUSH<M> size
PUSH<M> offset
RETURN
메모리에서 값을 반환하려면 메모리에 저장된 값이 존재해야 합니다. 이 상태로 RETURN을 실행하게 되면 아무것도 반환되지 않습니다.
메모리에 값을 저장하는 opcode는 MSTORE입니다. MSTORE는 메모리에서 offset만큼의 바이트를 건너뛰고 32바이트 크기의 value를 메모리에 씁니다. 예를 들어, offset이 0x2이고 value가 0x2a인 경우, MSTORE를 실행한 뒤 메모리의 상태는 다음과 같습니다.
0000000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000
32바이트보다 길이가 짧은 value는 32바이트 크기가 되도록 왼쪽에 패딩을 추가하여 메모리에 쓰이며, 메모리는 32바이트의 배수 크기로 할당이 되므로 64바이트로 초기화되었습니다.
MSTORE를 실행하려면 스택에서 offset과 value를 가져와야 합니다. 이 또한 RETURN과 마찬가지로 PUSH<M>을 사용해야 합니다.
여기까지 정리한 opcode의 순서는 다음과 같습니다.
PUSH<M> value
PUSH<M> offset
MSTORE
PUSH<M> size
PUSH<M> offset
RETURN
이제 이 컨트랙트에서 항상 42를 반환하도록 비어있는 부분들을 채워넣으면 됩니다. 일단 value는 1바이트 크기의 42이므로 첫번째 줄을 다음과 같이 채워넣을 수 있습니다.
PUSH1 0x2a
PUSH<M> offset
MSTORE
PUSH<M> size
PUSH<M> offset
RETURN
그리고 MSTORE의 offset과 RETURN의 offset이 동일하면 size를 32로 채워넣을 수 있습니다. offset을 64로 채운다고 쳤을 때, 전체 코드는 다음과 같습니다.
PUSH1 0x2a
PUSH1 0x40
MSTORE
PUSH1 0x20
PUSH1 0x40
RETURN
이를 바이트코드로 변환하면 다음과 같습니다.
0x602a60405260206040f3
이렇게 6개의 opcode만을 사용하여 10바이트 크기의 아주 간단한 컨트랙트가 완성되었습니다!
배포
작성된 컨트랙트를 배포하는 과정도 상당히 수고스럽습니다. 배포에 필요한 바이트코드를 직접 구성하거나 solidity의 어셈블리 블록에서 필요한 opcode를 호출해줘야 합니다.
저는 다음과 같이 컨트랙트를 작성하여 앞서 작성한 컨트랙트를 배포하였습니다. deploy 함수는 파라미터로 런타임 바이트코드를 받아 컨트랙트를 생성하고 이벤트를 통해 생성된 주소를 내보냅니다. getCode 함수는 주소를 받아 해당 주소가 컨트랙트라면 런타임 바이트코드를 반환합니다.
contract Deployer {
event Deploy(address deployed);
function deploy(bytes memory bytescode) public {
address deployed;
bytes memory deploycode = abi.encodePacked(
hex"63",
uint32(bytescode.length),
hex"80_60_0E_60_00_39_60_00_F3",
bytescode
);
assembly {
deployed := create(0, add(deploycode, 32), mload(deploycode))
if iszero(extcodesize(deployed)) {
revert(0, 0)
}
}
emit Deploy(deployed);
}
function getCode(address contractAddress) public view returns(bytes memory) {
return contractAddress.code;
}
}
deploy 함수를 자세히 살펴보면 다음과 같이 컨트랙트 배포를 위한 바이트코드를 구성합니다.
bytes memory deploycode = abi.encodePacked(
hex"63",
uint32(bytescode.length),
hex"80_60_0E_60_00_39_60_00_F3",
bytescode
);
0x602a60405260206040f3를 bytescode로 가지는 deploycode는 다음과 같습니다.
0x630000000a80600e6000396000f3602a60405260206040f3
이를 opcode로 분석하여 순서를 나열하면 다음과 같습니다. 왼쪽의 숫자는 바이트 오프셋을 나타냅니다.
0000: PUSH4 0x000000a
0005: DUP1
0006: PUSH1 0x0e
0008: PUSH1 0x00
000a: CODECOPY
000b: PUSH1 0x00
000d: RETURN
000e: PUSH1 0x2a
0010: PUSH1 0x40
0012: MSTORE
0013: PUSH1 0x20
0015: PUSH1 0x40
0017: RETURN
여기서 런타임 바이트코드를 제외한 부분이 solidity로 작성된 컨트랙트의 constructor 함수에 해당하는 부분으로, 컨트랙트를 초기화하기 위해 사용됩니다.
0000: PUSH4 0x000000a
0005: DUP1
0006: PUSH1 0x0e
0008: PUSH1 0x00
000a: CODECOPY
000b: PUSH1 0x00
000d: RETURN
초기화 과정은 다음과 같습니다.
- 스택에 0x0a를 추가합니다. (스택: [a])
- 스택 가장 위에 있는 0x0a를 복사하여 스택에 추가합니다. (스택: [a, a])
- 스택에 0x0e를 추가합니다. (스택: [a, a, e])
- 스택에 0x00을 추가합니다. (스택: [a, a, e, 0])
- 스택에서 세 개의 원소 0, e, a를 꺼내와 CODECOPY의 입력으로 사용합니다. 메모리의 0(destOffset) 번째에 전체 바이트코드의 e(offset) 번째 바이트부터 a(size) 만큼의 바이트를 메모리에 씁니다. CODECOPY 실행 결과 메모리에는 602a60405260206040f300000000000000000000000000000000000000000000, 스택에는 [a]가 남아 있습니다.
- 스택에 0x00을 추가합니다. (스택: [a, 0])
- 스택에서 두 개의 원소 0, a를 꺼내와 RETURN의 입력으로 사용합니다. 메모리의 0(offset) 번째 바이트부터 a(size) 만큼의 바이트를 복사하여 반환합니다.
전체 바이트코드의 e번째 바이트부터 a 만큼의 바이트는 앞서 작성한 런타임 바이트코드를 가리킵니다. 따라서 초기화 바이트코드의 실행결과로 반환되는 것은 런타임 바이트코드입니다.
이렇게 작성된 바이트코드를 zero address로 전송하면 컨트랙트가 생성이 됩니다. 컨트랙트 상에서는 CREATE opcode를 사용하여 이와 동일한 결과를 얻을 수 있습니다.
assembly {
deployed := create(0, add(deploycode, 32), mload(deploycode))
if iszero(extcodesize(deployed)) {
revert(0, 0)
}
}
CREATE opcode의 입력은 다음과 같습니다.
- value: 생성될 계정으로 전송할 이더의 양(wei 단위)입니다. 이더를 보내지 않는 경우 0을 집어넣습니다.
- offset: 메모리에서의 바이트 오프셋을 가리킵니다. add(deploycode, 32)는 메모리에서 deploycode의 실제값이 시작되는 오프셋을 반환합니다.
- size: 메모리에서 읽어들일 바이트의 길이를 가리킵니다. mload(deploycode)는 deploycode의 크기를 반환합니다.
최종적으로 Deployer 컨트랙트를 배포하고 deploy 함수를 호출해 6개의 opcode로 구성된 컨트랙트를 배포합니다. 그리고 이벤트로 반환되는 컨트랙트 주소를 인수로 사용해 인스턴스의 setSolver 함수를 호출하면 문제를 해결할 수 있습니다.
또 다른 풀이
앞서 직접 컨트랙트 초기화 바이트코드를 작성하고 CREATE opcode를 호출하였는데, 이 과정을 다음과 같이 constructor를 사용하여 단순화할 수 있습니다.
contract MeaningOfLife {
constructor() {
assembly {
mstore(0, 0x602a60405260206040f3)
return(22, 10)
}
}
}
mstore(0, 0x602a60405260206040f3)가 실행되고 난 뒤에 메모리의 상태는 다음과 같습니다.
00000000000000000000000000000000000000000000602a60405260206040f3
이 상태에서 return(22, 10)로 메모리의 22번째 offset부터 길이가 10인 바이트를 반환하면 결과적으로 컨트랙트의 런타임 코드로 0x602a60405260206040f3가 남게 됩니다.
3. 참고
'Solidity > Hacking' 카테고리의 다른 글
[Damn Vulnerable DeFi] Truster, Side Entrance (0) | 2024.02.22 |
---|---|
[Damn Vulnerable DeFi] Unstoppable, Naive Receiver (0) | 2024.02.21 |
[Ethernaut] 29. Switch (0) | 2024.02.07 |
[Ethernaut] 28. Gatekeeper Three (1) | 2024.02.06 |
[Ethernaut] 27. Good Samaritan (1) | 2024.02.05 |