Audius 사건과 Ethereum의 스토리지 충돌 리서치
개요: 본 글은 작년에 일어난 Audius 해킹 사건의 간략한 설명과 더불어 동일한 사건이 발생할 가능성이 있는 Ethereum에서 배포된 모든 Contract를 조사한 프로젝트 내용을 담고 있습니다.
What is Audius?
Audius는 누구나 음악을 등록, 청취할 수 있으며 토큰 리워드를 통해 탈중앙화 생태계를 구성하고 있는 음악 스트리밍 플랫폼입니다. Audius를 통해 음원을 판매하는 아티스트들은 기존 음원 유통 플랫폼 대비 음원 수익화 과정을 단축시키고 수익률을 극대화시킬 수 있습니다.
Audius의 생태계는 음원을 판매하여 그간 중간자들이 취했던 이익을 아티스트들이 얻고 팬들과 수익을 나눌 수 있도록 고안되었으며, 현재 음원 유통 시장이 가지고 있는 이익 배분의 문제점을 개선하는 것이 목적입니다.
Audius의 대시보드에 따르면, 2023년 03월 9일 기준 620만 명 이상의 MAU(월간 사용자 수)를 확보하고 있으며, $AUDIO 토큰의 유통 계획을 바탕으로 23년 03월 기준 10억 개 가량 중 약 30% 가량의 토큰이 스테이킹 되어 있습니다.
Audius 해킹 사건
2022년 7월 24일, Audius의 거버넌스, 스테이킹, 위임 Contract의 버그로 인해 1,800만 달러의 가치를 지니는 $AUDIO 토큰을 공격자 지갑으로 이전시키는 거버넌스 제안이 통과됩니다.
공격자는 거버넌스 Contract에 보관중이던 자금을 탈취하기 위해 Contract에 존재하던 버그를 악용하여 유통량 공급의 변화 없이도 잘못된 위임(erroneous delegation)을 실행할 수 있었고, 이를 통해 가짜로 위임된 10T 개의 $AUDIO 토큰을 거버넌스 제안을 통과시키는 데에 사용합니다.
제안이 통과되어 공격자는 성공적으로 1,800만 달러 상당의 $AUDIO 토큰을 자신의 지갑으로 이전합니다.
이 사건은 Solidity 언어의 스토리지 충돌 버그에 의해 발생했습니다. 이후, ChainLight는 Ethereum에 다른 종류의 스토리지 충돌이 있는지 확인하는 프로젝트를 진행했습니다. 프로젝트를 수행하기 위해 BigQuery, 로컬 Geth 노드 및 EVM 레벨의 정적 분석 기술을 활용했습니다.
스토리지 충돌의 발생과 해결점
Solidity에서의 스토리지 충돌이란,
EVM에서 일어나는 스토리지 충돌 버그는 같은 스토리지 슬롯을 공유하는 두 Contract 내 함수가 서로 다른 스토리지 레이아웃을 사용하여 발생합니다.
EVM에서 스토리지 충돌을 피하는 것은 Smart Contract의 정확성과 보안성을 보장하기 위해 중요합니다. 스토리지 충돌은 예기치 않은 동작을 일으키거나 자금 손실 또는 기타 부정적인 결과를 초래할 수 있습니다.
EVM 레벨에서의 정적 분석이란,
EVM 레벨에서의 정적 분석이란, Ethereum의 Smart Contract를 분석하는 과정을 말합니다. EVM bytecode를 분석하여, Contract가 Ethereum에서 실행될 때 발생할 수 있는 잠재적인 오류, 보안 취약점 또는 기타 문제를 감지하는 것을 의미합니다.
EVM 레벨에서의 정적 분석은 Smart Contract를 블록체인에 배포하기 전에 정확성과 보안성을 보장하기 위해 중요합니다. 개발 과정에서 잠재적인 문제를 조기에 감지함으로써, 개발자는 높은 위험성을 가진 버그나 보안 취약점의 위험을 줄일 수 있습니다.
위의 개념에서 언급했듯이 아래의 경우에서 스토리지 충돌 버그가 발생합니다. 예를 들어, Proxy-Implementation 패턴에서 아래와 같은 코드를 사용하는 경우 _implementation
과 _owner
의 위치가 서로 겹치게 됩니다.
contract Proxy {
address _implementation;
function fallback() { _implementation.delegatecall(msg.data); }
}
contract Implementation {
address _owner;
mapping(address -> uint256) _balances;
uint256 _supply;
...
}
위의 코드에서 스토리지는 다음과 같이 할당됩니다. (참조: OpenZeppelin)
|Proxy |Implementation |
|--------------------------|-------------------------|
|address _implementation |address _owner | <= [!]
|... |mapping _balances |
| |uint256 _supply |
| |... |
이와 같은 스토리지 레이아웃을 가진 Implementation에서 _owner
를 지정하는 순간 _implementation
변수도 바뀌게 되어 Proxy와의 연결이 끊기게 됩니다.
스토리지 충돌 문제가 두드러지는 대표적인 사례로, Implementation Contract가 OpenZeppelin의 v4.5.0 이전 버전의 Initializable Contract를 사용하는 경우가 있습니다.
contract Initializable {
bool _initialized, _initializing;
modifier initializer {}
}
contract MyContract is Initializable {
function init(uint256 param1, ...) initializer { ... }
}
Solidity 컴파일러는 스토리지에 저장되는 변수를 차례대로 0, 1, … 번째 슬롯에 할당합니다. 각 슬롯은 256 bit인데, 공간을 절약하기 위해 256 bit보다 작은 변수들은 하나의 슬롯에 같이 할당됩니다.
따라서 위의 코드에 있는 _initialized
및 _initializing
변수는 첫 슬롯의 2바이트를 차지하며, 이들 변수도 Proxy와 _implementation
변수와 겹치는 문제가 발생합니다. 따라서 Proxy와 MyContract가 연결된 직후 _initializing
변수가 차지하는 공간에 이미 0이 아닌 값이 저장되어 있으므로, initializer
modifier를 항상 통과하게 됩니다.
따라서 Proxy와 Implementation이 연결된 직후 _initializing
변수가 차지하는 공간에 이미 0이 아닌 값이 저장되어 있으므로, initializer
modifier를 항상 통과하게 됩니다.
이러한 패턴의 취약점은 Audius 등 여러 Ethereum 프로젝트에서 발견되었고, 다량의 TVL(Total Value Locked) 유출로 이어지기도 했습니다. 따라서 스토리지 충돌이 발생하지 않도록 스토리지 변수들의 레이아웃을 각별히 신경써야 합니다.
이러한 취약점을 막기 위한 최선의 방법은, Proxy의 _implementation
변수와 같이 비즈니스 로직과 분리될 수 있는 스토리지 변수들은 일반적으로 사용되지 않는 슬롯에 위치시키는 것입니다. delegatecall 대상인 Contract와 충돌할 일이 없는 슬롯에 Proxy의 데이터를 저장하면 스토리지 충돌 취약점을 원천 차단할 수 있습니다. (예시: keccak256("my_id")-1
번째 슬롯 사용)
비록 Solidity 0.8.x 버전에서는 스토리지 변수의 슬롯 위치를 임의로 지정하는 것을 지원하지 않아 assembly를 직접 사용해야 하지만, OpenZeppelin의 EIP-1967 Proxy 구현을 사용하면 이를 직접 작성하는 수고로움을 덜 수 있습니다.
또한 Slither와 같은 정적 분석 도구에서는 Upgradability Checks 관련 도구들을 제공하고 있습니다. 첫 버전의 Contract, Proxy, 그리고 업그레이드 할 버전의 Contract를 지정하면 Proxy 사용 및 업그레이드 시 스토리지 충돌 등의 위험성이 없는지 알려주는 도구입니다.
실험: 온 체인 스캐닝
ChainLight는 이런 버그들이 Ethereum 상에 더 존재하는지 찾기 위해
체인 전체에 있는 Contract들을 검사했습니다. 실험은 아래의 과정으로 이루어졌습니다.
DELEGATECALL가 호출되는 Contract 쌍 찾기
각 Contract의 스토리지 레이아웃 분석
스토리지 충돌 감지 및 보고
DELEGATECALL가 호출되는 Contract 쌍 찾기
전체 체인을 어떻게 스캔해야 할까요? 먼저 어떤 Contract를 검사할 지 알아야 합니다. ChainLight는 체인에서 여태 일어난 DELEGATECALL을 찾아 Proxy — Implementation 쌍을 조사하는 접근 방식을 채택했습니다.
Google의 BigQuery를 활용하면 Ethereum과 관련된 데이터를 SQL 쿼리를 사용해 조회할 수 있습니다. BigQuery를 통해 이 때까지¹ 일어난 모든 DELEGATECALL transaction 목록을 가져왔더니, 6.94 GB 크기의 주소 쌍을 얻을 수 있었습니다.²
SELECT from_address, to_address FROM `bigquery-public-data.crypto_ethereum.traces`
WHERE call_type IN ('delegatecall', 'callcode')
GROUP BY from_address, to_address
BigQuery에서 1 GB가 넘는 결과를 다운로드받기 위해서는 해당 결과를 Google Cloud Storage로 내보내야 합니다. 결제 수단을 설정해야 하지만 다운로드 후 결과를 지우면 실제로 청구되는 요금은 1,000원 미만입니다.
1: 2022년 8월 1일
2: Google BigQuery는 2018년부터 Ethereum 내 데이터를 SQL과 비슷한 BigQuery로 조회할 수 있는 공개 데이터셋을 제공하고 있습니다. 데이터셋에는 각 주소별 잔고, 토큰 거래 기록 등 여러 유용한 정보들이 테이블 별로 있으며, 이번 실험에서는 각 트랜잭션의 internal tx들이 있는 traces 테이블을 활용했습니다.
단, BigQuery는 스토리지 읽기 작업 기준으로 매월 1 TB까지만 무료이니 주의하세요! 쿼리 실행 후 잠시 기다리면 실행 없이도 우측 상단에서 예상 처리량을 볼 수 있습니다.
각 Contract의 스토리지 레이아웃 분석
Ethereum에는 Solidity를 사용하지 않는 프로젝트도 있으므로, 배포된 모든 Contract를 검사하기 위해 EVM bytecode를 대상으로 분석을 진행했습니다.
Proxy와 Implementation 쌍에 대해, 각각의 Contract에서 사용되는 모든 변수의 스토리지 슬롯을 구하기 위해 EVM bytecode에 대해 간단한 에뮬레이션 기반 분석을 수행했습니다.
etk-dasm을 수정하여 basic block 목록을 얻은 다음 각 블록을 가상으로 실행하여 Contract 내부의 모든 스토리지 로딩 명령어를 수집했습니다. 이는 나이브한 방법으로 false negative를 초래할 가능성이 다분했으나, 일반적으로 여기에서 다루는 고정 슬롯 스토리지 로드를 얻는 데에는 충분했습니다.
가장 간단한 경우는 이렇습니다.
PUSH 0x00 # slot
SLOAD # load storage slot
# This equals to: contract MyContract { uint256 a; }
위의 opcode는 스토리지 슬롯 0x0에서 256 bit 정수를 로드합니다. 또한 더 작은 데이터 타입을 구현하기 위해 Solidity는 로딩한 값에 나눗셈을 한 뒤 bitwise AND 연산을 수행합니다.
PUSH (1 << 8) - 1 # least; 8 bits
PUSH (1 << 224) # most; 32 bits (256 - 32)
PUSH 0x00 # slot
SLOAD # load storage slotDIV # extracts the most significant 32 bits
AND # extracts the least significant 8 bits
# This equals to: contract { uint248 a; uint8 b; }
따라서 에뮬레이터에서는 SLOAD opcode 뿐만 아니라, DIV 및 AND 연산을 처리하여 SLOAD에서 불러온 값이 어떻게 나눠지는지 기록해야 합니다. 또한 Solidity는 ²²²⁴와 같이 큰 상수를 처리할 때 이를 몇 가지 산술연산으로 분할하여 코드 크기를 최적화합니다. 아래는 Solidity의 최적화 예시입니다.
PUSH (1 << 224) - 1: 29 bytes
->
PUSH 1 : 2
PUSH 1 : + 2
PUSH 224 : + 2
SHR : + 1
SUB : + 1 = 6 bytes
위 다섯 개의 opcode로부터 하나의 정수를 얻기 위해 기본적인 Constant Folding을 구현했으며, 이는 각 opcode를 시뮬레이션시 좌, 우항이 전부 상수인 경우 식 자체가 아닌 연산 결과값을 반환하는 방식으로 구현되었습니다.
분석 결과 모든 스토리지 변수의 바이트 단위 레이아웃을 얻을 수 있습니다.³
Proxy:
Slot 0: [1 x 20] (address: uint160)
Implementation:
Slot 0: [1 x 1, 2 x 1] (uint8, uint8)
3: mapping 변수는 슬롯 공간을 차지하지만 실제로 해당 영역에 값을 저장하지는 않기 때문에 이 방식으로는 감지하기 어렵습니다.
스토리지 충돌 감지 및 보고
(1) DELEGATECALL 쌍과 (2) 위의 분석 결과로부터 (3) 서로의 스토리지 레이아웃을 비교하여, 결과적으로 Contract 간 충돌되는 스토리지 변수가 있는지 확인할 수 있었습니다.
Conclusion
실험 결과, 180개의 취약한 주소 쌍을 발견할 수 있었습니다. 취약한 주소 쌍의 종류는 크게 3가지로 분류할 수 있었습니다. 대부분의 경우, 저희가 실험을 하기 전에 이미 같은 실험을 한 사람이 있거나, 각 프로젝트에 적절한 제보가 이루어졌음을 알 수 있었습니다.
과거에는 취약했으나, 현재는 취약점의 영향이 없는 Contract. Contract가 삭제(SELFDESTRUCT)되거나 다른 Contract로 대체된 경우(Audius, xToken Terminal)입니다.
스토리지 충돌이 발생하지만 추가 코드로 인해 영향이 없는 Contract (FLASH token)
취약점이 존재하나, 실제로는 사용되지 않는 Contract
Web3 및 디파이(DeFi) 프로젝트에서 Upgradable Contract를 사용하면, 본래 불변성이라는 특징이 있는 블록체인에서도 취약점을 패치하거나 프로토콜을 개선하는 작업이 가능해집니다. 하지만 스토리지 충돌과 같은 취약점의 위험이 도사리고 있어 Audius 사건처럼 오히려 역효과가 발생하는 경우 또한 빈번했습니다. 따라서 OpenZeppelin 코드와 같은 검증된 구현체를 사용하고 감사를 실시하여 스토리지 충돌로 인한 취약성이 발생하지 않도록 해야 합니다.
참고 자료
https://blog.audius.co/article/audius-governance-takeover-post-mortem-7-23-22
https://dashboard.audius.org/#/services/user/0xbdbB5945f252bc3466A319CDcC3EE8056bf2e569
https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#unstructured-storage-proxies
✨ We are ChainLight!
ChainLight 팀은 풍부한 실전 경험과 깊은 기술 이해를 바탕으로 새롭고 효과적인 블록체인 보안 기술을 연구합니다. 연구 결과를 바탕으로 Web3 생태계의 각종 보안 위험 요소와 취약점을 사전 파악하여 제거하는 혁신적인 보안 감사 서비스를 제공합니다. 보안 감사 이후에도 온체인 데이터 모니터링 및 취약점 탐지 자동화 서비스를 이용한 지속적인 디지털 자산 위험 관리 솔루션을 제공합니다.
ChainLight 팀은 사용자들이 탈중앙화 서비스를 안전하게 활용할 수 있도록 Web3 생태계 위협으로부터의 보호에 힘쓰고 있습니다.
ChainLight의 더 다양한 정보를 보고 싶으시다면? 👉 Twitter 계정도 방문해 주세요.
🌐 Website: chainlight.io | 📩 TG: @chainlight | 📧 chainlight@theori.io
Originally published at https://blog.theori.io on May 18, 2023.