2023년 7월 30일, 일련의 익스플로잇이 여러 Curve 풀을 대상으로 발생하여 수백만 달러에 달하는 손실을 초래했습니다. 이는 전형적인 재진입 공격이지만, 재진입 방어 기능의 부재를 야기하는 컴파일러 버그에서 비롯된 비전형적인 근본 원인을 가지고 있습니다. 구체적으로, 스마트 컨트랙트 내 서로 다른 함수들에 대한 재진입 잠금이 서로 다른 스토리지 슬롯에 할당되는 오류가 있었습니다. 그 결과, Vyper 버전 0.2.15, 0.2.16, 0.3.0을 사용하여 컴파일된 스마트 컨트랙트가 취약점에 노출되었습니다.
배경
Curve는 스마트 컨트랙트 개발에 Solidity 대신 Vyper를 사용하기 때문에, 관련 취약점을 이해하는 데 도움이 되도록 Vyper 언어에 대한 간략한 소개를 제공합니다.
Vyper는 이더리움의 공동 창립자인 Vitalik Buterin이 만든 Python 기반 프로그래밍 언어입니다. 공식 문서에서 소개하는 바와 같이, Vyper는 이더리움 가상 머신(EVM)을 대상으로 하는 컨트랙트 지향, 도메인 특화, 파이써닉(pythonic) 프로그래밍 언어입니다. 그 목표에는 단순성, '파이써닉함', 보안성, 감사 가능성이 포함됩니다.
Vyper는 잘 알려진 Solidity에 이어 이더리움 및 EVM 호환 체인에서 두 번째로 널리 사용되는 프로그래밍 언어가 되었습니다. Curve는 Vyper 언어의 가장 큰 채택자 중 하나로, 대부분의 컨트랙트가 Vyper로 작성되어 있습니다. Curve와 관련되거나 Curve에서 포크된 많은 프로젝트들도 Curve 시스템과의 더 나은 코드 재사용 및 상호 운용성을 보장하기 위해 Vyper를 사용합니다.
아래는 Curve 풀(이번 사건에서 공격받은 pETH/ETH 풀)의 코드 세그먼트입니다. 문법이 Python과 매우 유사하지만, Vyper와 Python 사이에는 주목할 만한 차이점이 있습니다:
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
컨트랙트 내 함수의 모든 매개변수와 반환값에는 적절한 타입 어노테이션이 있어야 합니다. 이 언어는 클래스나 상속을 지원하지 않으며, 'self' 키워드는 오직 컨트랙트 자체를 참조하고 상태 변수에 접근하는 데만 사용됩니다. 내장 데코레이터(예: @external 및 @nonreentrant)는 함수의 속성을 나타내는 데 활용되며, 사용자 정의 데코레이터는 지원되지 않습니다.
취약점 분석
이 취약점은 재진입 방어 기능의 부재를 초래하는 컴파일러 버그에서 비롯됩니다.
재진입 공격은 블록체인 생태계에서 가장 일반적인 공격 유형 중 하나입니다. 구체적으로, 컨트랙트 로직의 실행이 외부 호출을 시작할 때 발생하며, 일부 외부 호출이 원래 컨트랙트를 재귀적으로 다시 호출할 수 있습니다. 이는 함수 실행 중 컨트랙트의 중간 상태를 다른 컨트랙트에 위험하게 노출시켜 잠재적인 취약점을 야기할 수 있습니다. 이에 대응하기 위해 재진입 가드 또는 잠금을 사용하여 단일 트랜잭션 실행 중에 컨트랙트가 재진입될 수 없도록 보장합니다.
앞서 언급한 코드 세그먼트에서 @nonreentrant('lock') 어노테이션은 remove_liquidity 함수가 lock이라는 재진입 잠금으로 보호되어야 함을 나타냅니다. 추가적인 이해를 위해, OpenZeppelin ReentrancyGuard 컨트랙트와 그 nonReentrant 수정자와 비교해볼 수 있습니다. Vyper에서의 주요 차이점은 재진입 잠금이 외부 라이브러리가 아닌 언어 자체의 내장 기능으로 제공된다는 것입니다. 이는 재진입 잠금의 구현에 대한 심층적인 조사가 이루어지기 전까지는 만족스러워 보였습니다. Pull Request #2391(2021년 7월 23일 병합)에서 도입된 코드 세그먼트가 set_storage_slots 함수를 사용하여 Vyper 소스 코드의 AST(추상 구문 트리)를 기반으로 스토리지 슬롯을 할당한다는 것이 밝혀졌습니다.
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
Parse module-level Vyper AST to calculate the layout of storage variables.
"""
# 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
그러나 핵심적인 오류는 각 재진입 잠금마다 스토리지 슬롯이 1씩 증가한다는 사실에 있습니다. 결과적으로, 서로 다른 함수들이 재진입 잠금에 대해 고유한 스토리지 슬롯을 할당받게 됩니다. 이 구현을 평가하기 위해 아래 컨트랙트를 컴파일합니다:
# Compile command: vyper -f bytecode,bytecode_runtime,ir test_contract.vy
from vyper.interfaces import ERC20
test_addr: address
@external
def __init__(_addr: address):
self.test_addr = _addr
@external
@nonreentrant('lock')
def test_funcA():
pass
@external
@nonreentrant('lock')
def test_funcB():
pass
Vyper 컴파일러 버전 0.2.16+commit.59e1bdd를 사용하면, 코드 컴파일 중 생성된 Vyper IR(중간 표현)의 일부가 다음과 같이 나열됩니다:
[if,
[eq, _func_sig, 2354224227 <test_funcA()>],
[seq,
[assert, [iszero, [sload, 0]]],
[sstore, 0, 1],
pass,
# Line 13
pass,
# Line 12
[sstore, 0, 0],
stop]],
# Line 17
[if,
[eq, _func_sig, 741100118 <test_funcB()>],
[seq,
[assert, [iszero, [sload, 1]]],
[sstore, 1, 1],
4-5번 줄과 16-17번 줄의 IR에 집중해 보면, 생성된 코드가 재진입 잠금을 확인하고 스토리지에 잠금 상태를 저장하는 것을 알 수 있습니다. 그러나 서로 다른 함수들이 비재진입 잠금에 대해 서로 다른 슬롯을 사용한다는 것이 관찰되었습니다: test_funcA는 슬롯 0을 사용하고, test_funcB는 슬롯 1을 사용합니다. 이는 컨트랙트가 서로 다른 함수를 통해 재진입될 수 있으므로 재진입 잠금이 효과가 없음을 나타냅니다.
공격 분석
Curve에 대한 맥락을 제공합니다. Curve 풀은 사용자가 add_liquidity 및 remove_liquidity 함수를 통해 유동성을 공급하고 인출할 수 있도록 합니다. 유동성을 추가할 때, 추가할 금액은 총 공급량의 비율, 즉 추가되는 유동성과 기존 유동성의 비율에 의해 결정됩니다. 반면, remove_liquidity는 제출된 LP(유동성 공급자) 토큰과 현재 총 공급량의 비율을 기반으로 인출할 토큰 수를 계산한 후 LP 토큰을 소각합니다.
또한, Curve는 네이티브 토큰을 처리하는 풀을 지원하며, 사용자에게 네이티브 토큰을 반환하기 위해 저수준 호출(Vyper의 raw_call 함수)을 사용합니다. 아래 코드 세그먼트에서 remove_liquidity 함수는 먼저 LP 토큰 수량과 총 공급량을 기반으로 제거할 토큰을 계산하고 전송한 다음, 이후에 총 공급량이 감소합니다.
정상적인 상황에서는, 재진입 잠금이 raw 호출 중 중간 상태의 노출을 방지해야 하므로 이는 안전합니다. 그러나 재진입 잠금이 효과가 없을 때—결국 악용된 결함—공격이 가능해집니다. 효과 없는 재진입 잠금은 저수준 호출 중 중간 상태(인출될 토큰이 전송되었지만 총 공급량이 아직 감소하지 않은 상태)가 취약해져 컨트랙트로의 잠재적 재진입을 허용한다는 것을 의미합니다.
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
else:
response: Bytes[32] = raw_call(
self.coins[1],
concat(
method_id("transfer(address,uint256)"),
convert(_receiver, bytes32),
convert(value, bytes32),
),
max_outsize=32,
)
if len(response) > 0:
assert convert(response, bool)
total_supply -= _burn_amount
self.balanceOf[msg.sender] -= _burn_amount
self.totalSupply = total_supply
log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)
return amounts
위의 27번 줄에서 표시된 바와 같이 총 공급량이 감소하지 않았기 때문에 이 재진입이 악용될 수 있습니다. 이 시점에서 컨트랙트에 재진입하여 add_liquidity를 호출하면, 유동성 공급이 잘못된 총 공급량(실제보다 높은)을 기반으로 계산되어 과도한 수의 LP 토큰이 발행되고 풀에 손실이 발생합니다. 이번 사건의 대부분의 공격은 이 취약점을 악용했습니다. 다음 논의에서는 Curve pETH-ETH 풀을 대상으로 한 가장 큰 공격 트랜잭션 중 하나인 0xa84aa065ce를 살펴봅니다.

공격 추적은 매우 명확합니다.
- 공격자는 Balancer에서 플래시 론을 빌린 다음 40,000 ETH를 Curve pETH/ETH 풀에 유동성으로 공급하여 32,431.41 pETH-ETH LP 토큰을 받았습니다.
- 공격자는 32,431.41 pETH/ETH 풀 LP 토큰을 소각하여 풀에서 3,740 pETH와 34,316 ETH를 제거했습니다.
- 유동성 제거 중 풀 컨트랙트에 재진입이 발생했습니다. 폴백 함수 내에서 공격자는 Curve pETH/ETH 풀에 40,000 ETH를 추가로 유동성으로 공급하여 82,182개의 LP 토큰을 추가로 발행했습니다. 이 과정에서 사용된 총 공급량 수치는 유동성 제거 이전의 것으로 잘못된 값이었으며, 그 결과 가능해야 했던 것보다 더 많은 수의 LP 토큰이 발행되었습니다.
- 이후 공격자는 10,272.84개의 Curve LP 토큰을 소각하여 1,184.73 pETH와 47,506.53 ETH를 인출했습니다. 요약하면, 공격자는 추가 LP 토큰을 발행하고 이 추가 LP 토큰을 사용하여 풀을 고갈시킴으로써 이익을 얻었습니다.
요약
이 취약점은 소스 코드가 아닌 컴파일러에서 비롯되었습니다. 이는 컴파일러 버그가 블록체인 생태계에서 상당한 재정적 손실을 초래한 첫 번째 사례입니다.
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
컴파일러는 중요한 인프라 구성 요소를 형성하므로, 블록체인 기술의 무결성과 기능성을 위해 컴파일러의 보안은 매우 중요합니다. 컴파일러 관련 문제는 즉시 명확하지 않을 수 있지만, 광범위하고 심각한 결과를 초래할 수 있습니다. 컴파일러 보안을 위해서는 취약점을 발견하고 해결하기 위한 종합적인 감사와 강력한 버그 바운티 프로그램을 포함하는 엄격한 평가가 필요합니다. 컴파일러 버그의 본질적인 미묘함은 그 탐지와 완화를 복잡하게 만듭니다. 이러한 복잡성은 DeFi 프로토콜을 효과적으로 보호하기 위한 필수적인 자동화 방어를 제공하는 BlockSec의 BlockSec Phalcon과 같은 정교한 공격 탐지 및 방지 메커니즘의 중요성을 부각시킵니다.
이 시리즈의 다른 글 읽기:
- 도입부: 2023년 Top Ten "놀라운" 보안 사건
- #1: Flashbots Relay의 취약점을 악용한 MEV 봇 수확
- #2: Euler Finance 사건: 2023년 최대 해킹
- #3: KyberSwap 사건: 극도로 미묘한 계산으로 반올림 오류를 능숙하게 악용
- #5: Platypus Finance: 행운으로 세 번의 공격에서 살아남다
- #6: Hundred Finance 사건: 취약한 포크된 프로토콜에서 정밀도 관련 익스플로잇의 물결을 촉발
- #7: ParaSpace 사건: 업계 최대 위기 공격을 저지하기 위한 시간과의 싸움
- #8: SushiSwap 사건: 서투른 구조 시도가 일련의 모방 공격으로 이어지다
- #9: MEV 봇 0xd61492: 독창적인 익스플로잇에서 포식자에서 피식자로
- #10: ThirdWeb 사건: 신뢰할 수 있는 모듈 간의 비호환성이 취약점을 노출



