본문 바로가기
Hack/Cryptocurrency

Vyper Language Re-Entrancy 취약점 분석 (With Curve Pool Hacking)

by Becoming a Hacker 2023. 8. 13.
반응형

기본 개념

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에 대한 보다 자세한 설명은 아래의 문서 참고해주세요.

 

Reentrancy Attack in Smart Contract

Smart Contract는 블록체인 기술에서 중요한 개념 중 하나로, 계약 체결과 실행을 자동으로 수행할 수 있는 프로그램이며, 블록체인 기술을 활용하여 제 3자의 인증 기관 없이 개인 간 계약이 이루어

hacksms.tistory.com


취약점이 발생하게 된 원인

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

 

Vyper Nonreentrancy Lock Vulnerability Technical Post-Mortem Report - HackMD

On the 30th of July, 2023, multiple Curve.Fi liquidity pools were exploited as a result of a latent vulnerability in the Vyper compiler, specifically in versions 0.2.15, 0.2.16, and 0.3.0. While bug was identified and patched by the v0.3.1 release, the imp

hackmd.io

 

GitHub - vyperlang/vyper: Pythonic Smart Contract Language for the EVM

Pythonic Smart Contract Language for the EVM. Contribute to vyperlang/vyper development by creating an account on GitHub.

github.com

 

GitHub - yearn/yearn-vaults: Yearn Vault smart contracts

Yearn Vault smart contracts. Contribute to yearn/yearn-vaults development by creating an account on GitHub.

github.com

 

댓글