Ethernaut Solution(2/2)
11. Elevator (difficulty 4/10)
This elevator won’t let you reach the top of your building. Right?
“이 엘리베이터는 빌딩 꼭대기까지 가지 않습니다. 맞습니까?” 문제가 더 어렵게 느껴집니다.😅
솔리디티는 간혹 예상치 못한 방향으로 동작할 때가 있습니다. 문제의 컨트랙트를 살펴보겠습니다. 우선 Building이라는 인터페이스가 있고 Elevator라는 컨트랙트가 있습니다. Building 인터페이스를 구현하면서 Elevator 컨트랙트를 사용할 것입니다.
! building.isLastFloor(_floor)
가 true이면 조건문으로 들어갑니다. 이것은 isLastFloor가 false이면
마지막 층이 몇 층인지 상관없이 마지막 층을 나타내는 bool top이 true가 될 수 없는 것처럼 보입니다.
그런데 여기서 간과한 문제가 있습니다. isLastFloor는 인터페이스로 정의되어 있고 구현하기 나름이라는 점입니다. 또 위의 조건을 잘 보면 isLastFloor를 두 번 호출하고 있습니다. 다시 말해서 isLastFloor의 두 번째 호출해서도 첫 번째와 동일한 값을 리턴할 것이라고 생각하는 것이죠.
만약에 isLastFloor를 첫 번째에는 false, 이렇게 해서 조건문으로 들어간 다음에, 조건문 내에서, 두 번째에는 true를 리턴하게 만들면 어떻게 될까요? 이렇게 하면 모든 층에 갈 수 있는 Elevator 컨트랙트가 되는 것입니다(토글 스위치를 생각하면 될 것 같습니다).
다음과 같은 결과가 나오면 성공입니다.
그런데 잠깐만! isLastFloor 메소드는 상태변수를 읽을 수만 있는 view 메소드인데 toggleFlag 상태변수를 어떻게 바꿀 수 있죠? 😕 그것은 솔리디티 컴파일러 버전에 따라 다릅니다. 여기서 사용한 0.4.25 이하에서는 view 로 지정했다고 하여 컴파일 오류가 발생하지는 않습니다. 그냥 경고만 할 뿐이죠. 0.5.0 이상에서는 보다 엄격하게 view로 지정된 메소드에서 상태변수를 바꾸는 코드는 컴파일 오류가 발생합니다.
12. Privacy (difficulty 8/10)
Unlock this contract to beat the level.
주어진 컨트랙트의 bool locked = false로 만들면 되는 문제입니다. unlock 메소드는 _key를 받아서 배열 data의 세 번째 아이템과 같은지 비교해서 같으면 locked = false로 만듭니다.
지난 8번 문제 Vault에서 본 것처럼 상태변수가 private라고 해서 읽지 못하는 것은 아닙니다. 지난 문제와 다른 점은 고정길이 배열인 경우에 storage의 레이아웃이 어떻게 되는지, 또 타입 캐스팅이 어떻게 이루어지는지 알아야 합니다.
- Understanding how storage works
- Understanding how parameter parsing works
- Understanding how casting works
컨트랙트의 상태변수는 다음과 같습니다. bytes32[3] private data
에 우리가 찾으려고 하는 데이터가 있을 겁니다. 지난 번에 설명한 것처럼
각 슬롯은 32바이트(256비트) 크기로 0번 슬롯부터 차례로, 연속적으로 할당됩니다. 슬롯 크기보다 작은 데이터 타입은 “tightly packed”하게 저장됩니다.
여기서 알아두어야 할 것은 상태변수를 constant로 선언하는 경우는 storage에 할당되지 않는다는 점입니다. constant는 값 타입만 지정할 수 있습니다.
The compiler does not reserve a storage slot for these variables, and every occurrence is replaced by the respective constant expression (which might be computed to a single value by the optimizer).
자, 그렇다면 상태변수 선언부를 보면서 레이아웃을 짐작해보겠습니다. 우선 다음과 같이 트러플 콘솔을 통해 Ropsten에 연결한 후 web3.eth.getStorageAt
으로 조회합니다.
첫 번째 슬롯입니다.
두 번째 세 번째, 네 번째, 차례로 조회해봅시다. 그럼 다음과 같은 결과를 얻을 수 있습니다(데이터는 달라질 수 있습니다).
다섯 번째(슬롯 인덱스 4)부터는 데이터가 없군요. 따라서 저장 슬롯은 모두 4개를 사용하고 있음을 알 수 있습니다. 한 슬롯의 크기가 32바이트(256비트) 이므로 안에 들어갈 수 있는 것들은 4개의 상태변수들입니다. 슬롯 내에서는 뒤에서부터 순서대로 채워집니다.
아래와 같이 그릴 수 있겠습니다.
SLOT 0 | ||||
uint16 awkwardness | uint8 denomination | uint8 flattening | bool locked | |
uint16(now) | 255 | 10 | true | |
0x0000...0000 | aa43 | ff | 0a | 01 |
고정길이 배열은 어떻게 될까요? bytes32이므로 한 슬롯씩 차지할 것입니다.
SLOT 1 | SLOT 2 | SLOT 3 |
bytes32 data[0] | bytes32 data[1] | bytes32 data[2] |
0x1b1f...4d88 | 0x12c7...a328 | 0x7310...7a5c |
결국 우리가 알아내려고 하는 키 값은 data[2]입니다. 문제에서는 bytes16(data[2])으로 캐스팅했으므로 Remix에서 다음과 같이 캐스팅해서 키 값을 알 수 있습니다.
bytes16은 첫 번째 16바이트만 잘라내면 됩니다. 이제 unlock 메소드를 실행하면 되겠군요!
13. Gatekeeper One (difficulty 5/10)
개인적으로 제일 어렵다고 생각되는 문제입니다.
Make it past the gatekeeper and register as an entrant to pass this level.
이 문제는 3개의 “Gatekeeper”가 존재합니다. 그것을 통과해서 enter 메소드를 실행하고 entrant에 계정 주소를 “등록”하면 되는 문제입니다. 각각의 “Gatekeeper”는 modifier로 메소드에 적용되어 있습니다.
modifier는 왼쪽 gateOne부터 차례로 적용됩니다. 첫 번째 gateOne은 지난 Telephone에서 본 것처럼 tx.origin과 msg.sender를 구분하는 것입니다.
두 번째는 gateTwo가 실행될 때 남은 가스가 8191로 나누었을 때 나머지가 0이 되어야 통과합니다. 메소드 실행시 가스를 지정할 수 있으므로 8191로 나누어 떨어지도록 가스를 지정하면 될 것 같습니다(msg.gas는 gasleft()로 변경되었습니다).
세 번째 gateThree가 상당히 난해하게 보입니다. 3개의 조건을 만족해야 통과할 수 있는데, 사실 그 3개가 _gateKey
를 짐작할 수 있는 힌트가 됩니다.
우선 두 번째부터 살펴보겠습니다. 가스의 소비 패턴은 컴파일러 버전에 따라 조금씩 다르다고 합니다. 따라서 문제의 컨트랙트와 동일한 컴파일러 버전 0.4.18로 맞추어야 합니다. 우리가 관심을 가져야 하는 것은 gateTwo에 진입했을 때 남은 가스입니다. 처음 메소드를 실행할때 가스를 알고 있으니 gateTwo까지 왔을 때의 남은 가스를 알면 그 차이가 소모된 가스가 될 것입니다.
그렇다면 그 값에 더하여 8191의 배수로 가스를 지정하면 gateTwo를 통과할 것입니다.
즉 msg.gas % 8191
연산을 수행하는 시점에 가스는 8191으로 나누어 떨어지는 값이 남아 있기만 하면 됩니다(물론 이후 트랜잭션이 완료될 수 있는 충분한 가스가 필요합니다).
우선 Remix에서 다음과 같은 컨트랙트를 작성합니다. Runtime 설정은 Javascript VM
으로 맞추고, 컴파일러 버전도 0.4.18로 변경합니다.
여기서 import된 컨트랙트는 문제에서 주어진 컨트랙트입니다. 위 컨트랙트에서 보는 것처럼 메소드 호출할 때 가스를 지정해줄 수 있습니다. gateKey는 아직 모르기 때문에 임의의 값을 주고 시작합니다. 우선 gateTwo를 통과하기 위한 가스를 알아내는 것이 목적입니다. 일단 가스 500,000으로 메소드를 실행해보기로 하겠습니다.
run()을 실행하면 오류가 발생할 것입니다. 나중에 알게 되겠지만 이것은 일부러 발생시킨 오류입니다. Remix의 Debugger를 활용해서 가스를 살펴보기로 합니다.
콘솔 윈도우에서 Debug를 클릭하면 디버거 화면으로 이동합니다. Step over forward
를 여러 번 클릭해서 Gatekeeper.sol로 진입합니다. 이 때 디버거의 remaining gas에
표시되는 값을 주의깊게 봐야 합니다. 500,000이 표시되면 enter() 메소드가 실행되기 시작하는 시점입니다. 계속 Step over forward를 클릭해서
remaining gas가 줄어드는 것을 관찰합니다.
어느 순간에 드디어 modifier가 실행되기 시작하는데 이 때부터는 Step into
를 클릭합니다. 중요한 것은 gateTwo에서 다음과 같은 나머지
연산 부분에서 remaining gas가 얼마인지 확인해야 한다는 것입니다. Remix에서는 실행되는 코드 부분이 반전되어 표시되므로 어느 위치인지 알 수 있습니다.
아래와 같이 499,785가 나왔습니다. 그렇다면 enter()메소드를 실해할 때 주입한 가스 500,000과 차이는 215가 됩니다. 따라서 gateTwo까지 실행할 때 소모된 가스는 215가 되겠군요!
그렇다면 남은 가스가 8191의 정수 배가 된다면 require(msg.gas % 8191 == 0)
을 통과할 것입니다. 현재 499,785는 당연히 조건을 만족시키지 못하므로
트랜잭션이 실패한 것입니다.
가스를 다음과 같이 지정하겠습니다.
컨트랙트는 다음과 같이 수정하면 되겠습니다.
그 다음에는 세 번째 관문 bytes8 타입의 gateKey를 알아내야 합니다. 다음 조건으로부터 어떻게 알아낼 수 있을까요? 잘 보면 이미 알고 있는 값이 있습니다. 바로 tx.origin
입니다.
이것은 메타마스크의 지갑 계정이 될 것입니다. 결국 gateKey는 지갑 계정으로부터 만들어지는 것임을 짐작할 수 있습니다.
위의 조건 모두 타입 캐스팅한 것들이 비교 연산자로 같거나 다름을 나타내고 있습니다. 일반적으로 큰 타입을 작은 타입으로 바꾸면 데이터가 소실되고 서로 다른 값이
나옵니다. 그런데 두 개의 ==
조건을 보면 좀 이상하다는 것을 알 수 있습니다.
동일한 값을 uint32와 uint16으로 타입 캐스팅을 한 것이 같다면 작은 타입 uint16(16비트) 범위의 값이라는 것을 알 수 있습니다. 예를 들어 다음 식은 true입니다. 즉 타입 캐스팅이 되더라도 어차피 16비트 범위 내의 값이므로 같은 값이 되는 것입니다.
하지만 단 한 비트라도 범위를 넘어서면 타입 캐스팅한 결과는 다를 수 밖에 없습니다.
결국 gateKey는 bytes8로 64비트인데 그 중에 하위 32비트는 다음과 같을 것입니다.
그렇다면 나머지 상위 32비트는 어떻게 알 수 있을까요? 다음 조건을 봅시다.
이 조건은 require(uint32(_gateKey) == uint16(_gateKey))
와 반대라고 생각하면 되겠습니다. 즉 동일한 값을 서로 다른 타입으로 캐스팅하면
다르다는 것인데 이것은 상위 32비트 내에는 0이 아닌 것이 반드시 한 비트라도 있다는 말이 되겠습니다.
49290을 bytes8로 바꾸면 다음과 같습니다.
여기서 *는 모두 0이 아니면 어떤 값이라도 상관없겠습니다. 중요한 것은 하위 16비트가 되겠네요.
Ropsten에 배포한 후 run()을 실행하고 결과를 확인합니다.
참고적으로 &연산을 통한 비트 마스킹을 통해서도 구할 수도 있습니다. 물론 여기서 상위 32비트 마스크는 어느 한 비트라도 1이 존재하면 되겠습니다(0xF0000000000FFFF, 0x0F000000000FFFF 등등 모두 가능).