이번 시리즈는 자주 사용되는 프록시 패턴들을 살펴보고 실제 취약점 사례를 리뷰하면서 프록시 패턴을 안전하게 사용하는 방식에 대해 알아본다. 이 글을 읽는 사람은 프록시 컨트랙트가 무엇인지 이미 알고 있다고 가정한다.
살펴 볼 패턴은 아래 5개다. Minimal Proxy를 제외한 4개 프록시는 모두 implementation컨트랙트의 주소를 변경하여 업그레이드가 가능한 프록시다.
•
Transparent Upgradeable Proxy
•
UUPS Proxy(ERC-1822)
•
Beacon Proxy
•
Diamond Proxy(ERC-2535)
•
Minimal Proxy
위 패턴들에 대해 알아보기 전에, 이번 글에서는 우선 많은 프록시 패턴에서 따르고 있는ERC-1967 표준을 살펴볼 것이다.
ERC-1967: 스토리지 충돌(storage collision)방지
ERC-1967 표준은 Implementation을 업그레이드하는 과정에서 발생할 수 있는 Proxy와 Implementation 간의 스토리지 충돌을 방지하기 위해 만들어졌다. Transparent Upgradeable Proxy뿐만 아니라 UUPS Proxy 패턴도 이 표준을 따른다(Beacon Proxy도 엄밀히 말하면 ERC-1967을 따른다고 할 수 있지만, 방식이 약간 다르다).
프록시의 스토리지 충돌
스토리지가 충돌한다는 게 무슨 의미일까? 이를 이해하려면 Solidity에서 스토리지가 쓰이는 방식을 이해해야 한다. 스토리지에 관련된 내용을 여기서 모두 설명하기에는 내용이 방대하므로, 지금은 이렇게만 이해하고 넘어가자.
스토리지는 가변 길이를 가지는 상태변수(string, bytes, structure, array 등)를 제외하고는 차례대로 쓰인다. 스토리지는 256비트(32바이트) 단위로 데이터를 저장하며, 각 단위를 ‘슬롯’이라고 한다. 여러 개의 작은 변수가 하나의 슬롯에 같이 저장될 수 있으며, 슬롯의 남은 공간이 데이터를 저장하기에 충분하지 않은 경우 다음 슬롯으로 넘어간다.
예를 들어, uint32 변수 3개, uint128 변수 1개는 같은 슬롯에 저장된다. 현재 남은 슬롯 공간은 4바이트이므로 만약 address 변수가 3번째로 선언되었다면 이는 다음 슬롯에 저장된다.
프록시 컨트랙트가 아주 아주 중요한 상태변수인 admin과 implementation을 갖고 있으며, Implementation으로의 호출이 Proxy를 통해 delegatecall된다는 것을 떠올려보자.
Implementation 또한 자신의 상태변수를 가질 수 있다. Proxy를 통해 Implementation의 상태변수를 변경하는 연산을 한다면 의도와 달리 Proxy의 중요한 상태변수인 admin과 implementation이 변경될 수 있다.
아래 예시에서 Proxy의 첫번째 슬롯에는 admin과 implementation이 저장되어 있다. Implementation의 첫 번째 슬롯에는 index가 저장된다. Proxy에 Implementation을 지정하고 initialize를 호출하면, index를 변경하려 했지만 Proxy의 컨텍스트에서 첫 번째 슬롯의 첫 변수는 admin이므로 의도치 않게 admin이 _index값으로 덮어 씌워진다. 더이상 프록시는 올바르게 동작하지 않을 것이다.
// Proxy
contract MockProxy{
address admin;
address implementation;
// ...
}
// Implementation
contract MockImpl{
uint256 index;
function initialize(uint256 _index){
index = _index;
}
}
Solidity
복사
프록시를 위한 스토리지 슬롯 지정
ERC-1967은 이를 방지하기 위해 두 변수(admin, implementation)들의 값을 특정한 slot number의 스토리지에 저장하도록 제안한다. 변수 순서에 상관없이 admin은 n번째 슬롯에, implementation은 y번째 슬롯에 저장하겠다는 것이다. slot number는 두 변수를 뜻하는 고유 문자열을 keccak256 해시한 후, 이를 256비트 정수로 변환한다. 최종 slot number는 여기에 1을 뺀 값인데, 이렇게 string을 해시한 값을 바로 사용하지 않고 추가적인 연산을 더함으로써 더욱 강력한 충돌 방지를 보장한다. 해시값에 1을 뺀 slot number는 매우 “의도된” 값이기 때문에 실수로라도 충돌할 가능성이 거의 없기 때문이다. 또한, 해시의 프리이미지를 감춰 공격 가능성을 낮추려는 목적도 있다.
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
Solidity
복사
해시결과로 나온 admin과 implementation의 스토리지 슬롯 번호는 아래와 같다.
bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
bytes32 private ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
Solidity
복사
정해진 스트링을 해시한 것이므로 우연이라도 slot number가 겹칠 일은 없고, 순차적으로 슬롯을 쓴다고 하여도 컨트랙트가 사실상 slot number만큼의 변수를 가질 수 없으므로 어떤 경우에서라도 slot number가 겹치지 않을 것이다.
스토리지를 쓰거나 읽을 때는 이렇게 한다.
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation; // 저장
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; // 로드
Solidity
복사
만약 beacon proxy라면 implementation 슬롯은 비어있어야 하며, 그대신
bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1))의 비콘 슬롯이 사용된다. 반대로 beacon proxy가 아닌 경우 beacon 슬롯이 비어있어야 한다.
ADMIN_SLOT이나 IMPLEMENTATION_SLOT에 값이 있는 경우 블록 탐색기에서 해당 컨트랙트가 ERC-1967을 준수하는 프록시 컨트랙트로 인식하고 Proxy Contract임을 표시해준다. 이제 우리는 블록 탐색기가 어떻게 컨트랙트가 프록시인지 구별하여 라벨을 붙여줄 수 있는지 알았다!
ERC-1967에서 보장하지 않는 것
ERC-1967가 스토리지 충돌 문제를 해결해주었지만 여전히 남아있는 문제가 한 가지 더 있다. 바로 함수 선택자(function selector) 충돌이다. 만약 Proxy에도 Implementation의 함수와 같은 것이 있다면 어떻게 될까? fallback()은 언제나 마지막에 실행되므로 Implementation의 함수는 불리지도 않을 것이다. 이 문제를 해결하기 위해 고안된 것이 바로 TransparentUpgradeable 패턴과 UUPS 패턴이다. 이 프록시들에 대한 자세한 내용은 다음글에서 알아볼 예정이다.