기본 개념
EVM
단일 스레드 비동시 시스템으로, 블록체인에서의 제어 흐름은 일반적인 프로그램과는 달리 프로그램(ex: Smart Contract)이 다른 프로그램을 호출할 때마다 전체 제어 흐름이 호출된 프로그램으로 전달됩니다.
Vyper
Ehereum Virtual Machine(EVM)을 대상으로 한 Smart Contract 기반의 Pythonic한 프로그래밍 언어입니다.
Re-Entrancy Attack
제어 흐름이 호출된 Smart Contract로 넘어가는 특성을 악용한 공격으로 제어 흐름이 넘어가는 함수에 반복적으로 재진입(ex : A -> B -> A ...)하여 상태가 업데이트 되기 전에 이전 상태를 악용(자산 탈취 등)할 수 있습니다.
Re-Entrancy Attack 대응 방안
1. CEI(checks-effects-interactions) Pattern 사용
외부 Contract와 상호작용 하기 이전에 보안성 검증을 수행하고 자산 현황과 같은 상태 변경 등을 수행하는 패턴입니다.
2. Re-Entracny Guadrd
De-Fi 생태계에서는 외부 Contract의 상호 작용 결과에 의존하여 실행되는 기능이 존재하며, 이러한 상황에서는 CEI 패턴의 적용이 어려울 수 있습니다.
이러한 상황에서는 Vyper와 같은 특정 언어의 Re-Entrancy Guard 기능을 도입하여 Re-Entrancy Attack을 방지할 수 있습니다.
- 함수 호출 시 활성화 상태 확인
- 활성화 상태가 아닌 경우, 활성화 상태로 설정
- 함수 호출 완료 시 비활성화 상태로 변경
Re-Entrancy에 대한 보다 자세한 설명은 아래의 문서 참고해주세요.
취약점이 발생하게 된 원인
Vyper의 Re-Entrancy Gaurd
Vyper는 @nonreentrant Decorator로 Re-Entrancy guard를 제공하였으며, 사용 시 <KEY>를 같이 사용해야 합니다.
ex : @nonreentrant("lock")
Vyper는 사용자에게 Raw Storage에 대한 Access를 제공하지 않기 때문에 Compile 시 모든 Storage 슬롯의 파악이 필요합니다. 이에 따라 저장될 슬롯 할당 작업이 필요하며, 이때 Storage 변수와 Re-Entrancy Key의 슬롯이 서로 겹치지 않아야 합니다.
Vyper는 Re-Entrancy Lock을 위한 슬롯 작업을 최적화 하기 위해 코드 업데이트를 여러 번 진행하였습니다.
v0.1.0-beta.9(PR#1264) : OFFSET(0xffffff)을 이용하여 슬롯이 겹치지 않도록 했습니다.
v0.2.9(PR#2308) : 일반적인 Stroage 변수에 대한 모든 슬롯 작업이 완료된 후, 할당되지 않은 첫 번째 스토리지에 Re-Entrancy Key 슬롯을 할당하는 방식으로 겹치지 않도록 했습니다.
(이러한 방식으로 Byte Code Space를 절약할 수 있다고 합니다.)
v0.2.13(PR#2361) : 코드베이스가 리팩토링을 겪으면서 1개 이상의 Storage 슬롯(32Byte)을 포함할 수 있는 변수 저장 방식을 도입하였습니다.
Storage 변수에 대한 슬롯 계산 코드는 새로운 Front-End 로직으로 리팩토링되었지만, Re-Entrancy Key에 대한 슬롯 계산 코드는 Storage 변수를 전적으로 의존하고 있어 코드 로직을 그대로 유지하였습니다.
v0.2.14(PR#2379) : Front-End의 Storage 변수 할당 방식과 Codegen Passess(기존)의 Re-Entrancy Key 할당 방식의 로직 차이로 인해 PR#2308의 Offset 계산 코드에 업데이트를 수행했습니다.
counter = (
sum(v.size for v in self._globals.values() if not isinstance(v.typ, MappingType))
+ self._nonrentrant_counter
)
그러나 해당 업데이트를 통해서도 두 할당 방식의 차이로 인해 발생한 문제를 100% 해결하지 못하였고, 이 문제가 결국은 Re-Entrancy Guard의 손상으로 이어졌습니다.
최초 Re-Entrancy Guard 손상 발견 (Issue #2393)
Re-Entrancy Guard의 손상은 특정 사용자가 Yearn Vault 테스트를 수행하는 과정에서 @nonreentrant로 인해 실패한다는 사실을 최초로 발견하였습니다.
전문가의 분석 결과에 따르면, deposit과 withdraw 함수는 @nonreentrant("withdraw") 키워드를 사용하고 있었고, Storage Offset은 0x2e를 사용하고 있었습니다.
@external
@nonreentrant("withdraw")
def deposit(_amount: uint256 = MAX_UINT256, recipient: address = msg.sender) -> uint256:
@external
@nonreentrant("withdraw")
def withdraw(
maxShares: uint256 = MAX_UINT256,
recipient: address = msg.sender,
maxLoss: uint256 = 1, # 0.01% [BPS]
) -> uint256:
그런데 0x2e Storage Offset은 managementFee 변수에서도 동일하게 사용하고 있어, Storage 슬롯의 겹침 문제가 발생하는 것이 원인이었습니다.
@external
def setManagementFee(fee: uint256):
"""
@notice
Used to change the value of `managementFee`.
This may only be called by governance.
@param fee The new management fee to use.
"""
assert msg.sender == self.governance
assert fee <= MAX_BPS
self.managementFee = fee
log UpdateManagementFee(fee)
정확한 근본 원인은 위에서 언급한 내용과 같이 Front-End의 Storage 슬롯 할당 코드와 기존 코드의 Re-Entrancy Key 슬롯 할당 코드의 차이에서 발생하게 되었습니다.
기존 코드의 Re-Entrancy Key 슬롯 계산 코드
def get_nonrentrant_counter(self, key):
"""
Nonrentrant locks use a prefix with a counter to minimise deployment cost of a contract.
We're able to set the initial re-entrant counter using the sum of the sizes
of all the storage slots because all storage slots are allocated while parsing
the module-scope, and re-entrancy locks aren't allocated until later when parsing
individual function scopes. This relies on the deprecated _globals attribute
because the new way of doing things (set_data_positions) doesn't expose the
next unallocated storage location.
"""
if key in self._nonrentrant_keys:
return self._nonrentrant_keys[key]
else:
counter = (
sum(v.size for v in self._globals.values() if not isinstance(v.typ, MappingType))
+ self._nonrentrant_counter
)
self._nonrentrant_keys[key] = counter
self._nonrentrant_counter += 1
return counter
Front-End의 Storage 변수 슬록 계산 코드
available_slot = 0
for node in vyper_module.get_children(vy_ast.AnnAssign):
type_ = node.target._metadata["type"]
type_.set_position(StorageSlot(available_slot))
available_slot += math.ceil(type_.size_in_bytes / 32)
코드에서 확인할 수 있듯이, Re-Entrancy Key 슬롯 계산 시에는 MappingType에 대한 슬롯을 고려하지 않는 반면, Storage 변수 슬록 계산 시에는 MappingType도 고려하고 있어 OFFSET 계산 과정의 불일치로 인해 겹침이 발생하였습니다.
※ Mapping Type의 경우 실제 사용되지 않지만, Compiler에 의해 예약된다고 함
Vyper에서는 해당 이슈를 v0.2.15(PR#2391)를 통해 업데이트 하였습니다.
해당 업데이트에서는 Re-Entrancy 슬롯 할당 코드가 Front-End의 Storage 슬롯 할당 코드와 통합되었고
Re-Entrancy Key 슬롯이 Storage 변수 슬롯보다 앞쪽에 위치하도록 변경되었습니다.
# Allocate storage slots from 0
# note storage is word-addressable, not byte-addressable
storage_slot = 0
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is not None:
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
for node in vyper_module.get_children(vy_ast.AnnAssign):
type_ = node.target._metadata["type"]
type_.set_position(StorageSlot(storage_slot))
# CMC 2021-07-23 note that HashMaps get assigned a slot here.
# I'm not sure if it's safe to avoid allocating that slot
# for HashMaps because downstream code might use the slot
# ID as a salt.
storage_slot += math.ceil(type_.size_in_bytes / 32)
그런데 해당 업데이트에서는 치명적인 버그가 존재하였습니다.
기존 코드에서는 @nonreentrant Decorator의 Key 마다 하나의 슬롯이 존재할 수 있었으나,
if key in self._nonrentrant_keys:
# --> SAFE. only allocate one slot per key <--
return self._nonrentrant_keys[key]
변경된 코드에서는 해당 Key의 슬롯 존재 유무와는 상관 없이 새 슬롯을 할당하여 Re-Entrancyt Guard가 정상적으로 동작하지 않게 되었습니다.
# Allocate storage slots from 0
# note storage is word-addressable, not byte-addressable
storage_slot = 0
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is not None:
# --> BUG! should check nonreentrant key not already allocated <--
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
패치 코드 분석
v0.2.15에서 생긴 해당 취약점은 2021년 7월 21일 ~ 2021년 11월 30일까지 4개월 동안 탐지되지 않았고
이로 인해 v0.2.15, v0.2.16, v0.3.0 버전이 해당 취약점에 노출되었습니다.
- 취약 버전 : Vyper 0.2.15, 0.2.16, 0.3.0
- 패치 버전 : Vyper 0.3.1
해당 취약점은 아래의 조건에 부합하는 경우에만 Exploit 할 수 있었으나, 안타깝게도 일부 Curve.Fi Liquidity Pools에 부합하여 실제 Exploit 되었습니다.
1. vyper 버전이 취약한 경우 (v0.2.15, v0.2.16, v0.3.0)
2. CEI Pattern을 따르지 않아야 함 (즉, Storage 업데이트 이전에 제 3자에게 제어 흐름이 넘어가야 함)
3. @nonreentrant Decorator의 키를 동일하게 사용하는 함수가 존재하며, 해당 키를 사용하는 최초 함수의 상태에 영향을 받아야 함
v0.3.0의 코드를 통해 취약점을 다시 확인해보면, v0.2.15에서 코드가 살짝 변경되었지만, 근본 원인인 @nonreentrant Decorator의 Key와 상관 없이 슬롯을 할당하는 로직은 동일합니다.
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is not None:
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO this could have better typing but leave it untyped until
# we nail down the format better
variable_name = f"nonreentrant.{type_.nonreentrant}"
ret[variable_name] = {
"type": "nonreentrant lock",
"location": "storage",
"slot": storage_slot,
}
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
그리고 취약점 조치가 수행된 2개의 PR(PR#2439, PR#2514)을 확인해보면, 동일한 Key가 존재할 경우 해당 Key가 등록된 Slot을 등록해주었습니다. 이를 통해 동일한 Re-Entrancy Key에는 단일 Storage 슬롯이 적용될 수 있었습니다.
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is None:
continue
variable_name = f"nonreentrant.{type_.nonreentrant}"
# a nonreentrant key can appear many times in a module but it
# only takes one slot. after the first time we see it, do not
# increment the storage slot.
if variable_name in ret:
_slot = ret[variable_name]["slot"]
type_.set_reentrancy_key_position(StorageSlot(_slot))
continue
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO this could have better typing but leave it untyped until
# we nail down the format better
ret[variable_name] = {
"type": "nonreentrant lock",
"location": "storage",
"slot": storage_slot,
}
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
Reference
'Hack > Cryptocurrency' 카테고리의 다른 글
[2차 포스팅] PEPE Token의 Rug Pull 분석 (0) | 2023.08.26 |
---|---|
PEPE Token의 Rug Pull 분석 (0) | 2023.08.25 |
Truffle를 통한 Reentrancy Attack 실습해보기 (0) | 2023.07.23 |
Ethereum 로컬 개발 환경 구성하기 (with Smart Contract) (0) | 2023.07.23 |
stETH (Staked ETH) (0) | 2023.07.15 |
댓글