Back to Blog

#5 Yearn Finance 사고: 불변량 솔버의 안전하지 않은 산술 연산이 그 이름값을 하다

Code Auditing
February 11, 2026
20 min read

2025년 11월 30일, Yearn Finance의 yETH Weighted Stable Pool이 900만 달러 이상의 피해를 입는 공격을 받았습니다 [1]. 근본 원인은 불변식 솔버 _calc_supply()의 안전하지 않은 산술 연산과, 초기화 로직에 재진입을 허용한 비활성화되지 않은 부트스트랩 경로였습니다. 공식 사후 분석 보고서 [2]는 다섯 가지 항목을 근본 원인으로 제시하고 있으나, 본 글에서는 이를 두 가지 결함(위의 취약점들)과, 이 결함들이 존재할 때에만 악용 가능해지는 두 가지 아키텍처적 전제 조건으로 재분류합니다. 기존에 공개된 분석들은 단계별 공격 트랜잭션 세부 내용에 초점을 맞추고 있습니다. 고수준 요약과 트랜잭션 수준의 세부 내용 사이에는 여전히 간극이 존재합니다. 공격이 실제로 왜, 어떻게 작동했는가 하는 문제입니다. 이 글은 Foundry와 Python 시뮬레이션을 활용하여 핵심 값들이 단계별로 어떻게 변화하는지, 그리고 계산이 어디서 무너지는지를 추적함으로써 그 간극을 메웁니다.

이 분석은 주로 다음 세 가지 기여를 합니다:

  1. 취약점별 손실 분류. 두 취약점은 상호 의존적이지 않습니다. 안전하지 않은 산술 연산만으로도 약 810만 달러(전체의 90%)의 손실이 발생했으며, 부트스트랩 경로는 추가로 약 90만 달러를 가능하게 했습니다. 이를 통해 어느 취약점이 주된 원인이었는지 명확히 합니다.
  2. 근본 원인의 재분류. 공식 보고서의 다섯 가지 근본 원인은 두 가지 구현 결함(다섯 항목 중 세 항목을 통합)과, 결함과의 조합 속에서만 악용 가능해지는 두 가지 아키텍처적 전제 조건으로 이해하는 것이 더 적절합니다.
  3. 기술적 오해 수정. "두 번째 반복에서의 언더플로우가 곱 항을 0으로 만든다"는 주장은 성립하지 않습니다. 시뮬레이션 결과, 곱이 0이 되는 것은 언더플로우가 아닌 나눗셈의 내림 반올림을 통해 발생하며, 이익을 창출하는 언더플로우는 완전히 다른 단계에서 발생합니다.

이 글의 나머지 부분은 다음과 같이 구성됩니다. 0x1절에서는 yETH의 가중 안정 풀과 그 불변식 솔버에 대한 배경을 제공합니다. 0x2절에서는 두 가지 근본 원인과 그 실패 방식을 분석합니다. 0x3절에서는 세 단계로 이루어진 공격을 상세히 추적합니다. 0x4절에서는 시뮬레이션 증거를 통해 두 가지 일반적인 오해를 수정합니다. 0x5절에서는 권고사항으로 마무리합니다.

TL;DR

근본 원인: 두 가지 취약점이 악용되었으나 비대칭적인 영향을 미쳤습니다:

  1. _calc_supply()의 안전하지 않은 산술 연산 (주요, 약 810만 달러). 풀 상태로부터 yETH 공급량을 재계산하는 함수에 두 가지 산술적 실패가 존재합니다: unsafe_div()의 내림 반올림이 내부 곱 항을 0으로 만들 수 있고, unsafe_sub()의 언더플로우가 중간값을 엄청나게 큰 양의 정수로 wrap할 수 있습니다. 이 취약점만으로도 yETH 가중 스테이블스왑 풀을 고갈시키기에 충분했습니다.
  2. 비활성화되지 않은 부트스트랩 경로 (부차적, 약 90만 달러). prev_supply == 0 초기화 분기는 배포 후 영구적으로 차단되지 않았습니다. 첫 번째 취약점이 공급량을 0으로 고갈시킨 후, 이 경로에 접근이 가능해졌으며, yETH/WETH Curve 풀에서 추가 이익을 얻을 수 있었습니다.

안전하지 않은 산술 연산 취약점 내에서, 내림 반올림 실패(실패 방식 A)만이 Phase 2에서 사용되었습니다. 언더플로우 실패(실패 방식 B)는 부트스트랩 경로와 상호 의존적이며, 함께 Phase 3를 가능하게 했습니다.

공격자는 세 단계의 시퀀스를 실행했습니다:

  1. 준비: 반복적인 추가/제거 사이클을 통해 풀의 자산 분포를 왜곡하여 가상 잔액에 극단적인 불균형을 만듭니다.
  2. 공급량 조작: _calc_supply()의 내림 반올림을 이용하여 곱 항을 0으로 붕괴시킨 후, 일련의 민팅/소각 작업을 통해 총 공급량을 0으로 고갈시킵니다. 풀의 모든 LST가 인출되어 WETH로 교환되어 약 810만 달러의 손실이 발생했습니다.
  3. 이익 추출: 소액 예치로 부트스트랩 경로(prev_supply == 0)를 트리거하여 _calc_supply()의 언더플로우를 악용해 약 2.35×10⁵⁶개의 yETH를 민팅하고, 이를 yETH/WETH Curve 풀 고갈에 활용하여 약 90만 달러의 손실을 발생시킵니다.

두 가지 일반적인 오해 수정:

  • "pow_up()pow_down()의 반올림 방향 불일치로 인해 불변식이 깨진다." pow_up() 호출을 모두 pow_down()으로 교체한 Foundry 시뮬레이션을 통해 검증한 결과: 익스플로잇은 동일하게 작동합니다. 반올림 방향 불일치는 근본 원인이 아닙니다.
  • "두 번째 반복에서의 언더플로우로 인해 중간 항이 0으로 붕괴된다." Foundry와 Python 시뮬레이션 결과, 두 번째 반복에서는 언더플로우가 발생하지 않습니다. 실제 값은 약 1.91e19(주장된 약 1.94e18이 아님)로, 올바른 뺄셈의 정당한 결과입니다. 곱을 0으로 만드는 것은 이후의 나눗셈에서의 내림 반올림이며, 언더플로우가 아닙니다.

0x1 배경

이번 사건에서 두 풀이 자산 손실을 입었습니다: yETH 가중 스테이블스왑 풀 (LST를 보유하는 Yearn 풀, 약 810만 달러 손실)과 yETH/WETH Curve 풀 (Curve 스테이블스왑 풀, 약 90만 달러 손실). 핵심 취약점은 yETH 가중 스테이블스왑 풀에 있습니다. 이 절에서는 취약점과 익스플로잇을 이해하는 데 필요한 배경을 제공합니다.

0x1.1 가상 잔액과 불변식

yETH 프로토콜은 이더리움 유동 스테이킹 토큰(LST)을 위한 자동화 시장 조성자(AMM)입니다 [3]. 피해를 입은 yETH 가중 스테이블스왑 풀은 여러 LST를 단일 풀로 집계합니다: 사용자는 LST를 예치하고 풀 지분 토큰으로 yETH를 받습니다.

각 LST는 시간이 지남에 따라 보상이 누적되는 스테이킹된 ETH를 나타내므로, 기본 ETH에 대한 교환 비율이 변합니다. 통일된 회계를 위해 풀은 각 자산에 대해 가상 잔액 xix_i를 정의합니다: 온체인 잔액 × 교환 비율. 이를 통해 모든 자산이 비콘체인 ETH 단위로 정규화됩니다. 모든 가상 잔액의 합계는 σ=xi\sigma = \sum x_i로 표기됩니다.

풀에는 8개의 자산(인덱스 0–7)이 있으며, 각각 지정된 가중치 wiw_i를 가집니다:

인덱스 자산 인덱스 자산
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

풀의 상태는 가중 StableSwap 방식의 불변식에 의해 관리됩니다 [4]:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

여기서:

  • **DD**는 불변식 스케일로, 이 풀의 총 yETH 공급량과 직접적으로 같습니다. 풀이 완벽하게 균형 잡혀 있을 때, D=σD = \sigma입니다.
  • **π\pi**는 가중 곱 항으로, π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i}로 정의됩니다. 여기서 wiw_i는 자산 i의 가중치이고 vi=winv_i = w_i \cdot n입니다.
  • **Af\mathit{Af}**는 증폭 계수로, 단일 프로토콜 매개변수입니다(A×fA \times f가 아님). Afn\mathit{Af}^{\,n}은 이 계수를 nn제곱한 값을 나타내며, nn은 자산의 수(이 풀에서는 8)입니다. 이는 등가 부근의 상수합과 극단에서의 상수곱 사이의 곡선 형태를 제어합니다.

핵심 특성: DD는 닫힌 형태의 해를 가지지 않습니다. 수치적으로 풀어야 합니다. 그 솔버인 _calc_supply()가 바로 산술 취약점이 존재하는 곳입니다.

0x1.2 불변식 솔버

프로토콜은 최대 256회로 제한된 고정점 반복을 통해 DD를 재계산합니다. 이 알고리즘은 코드에서 _calc_supply()로 구현되어 있습니다(0x2.1절에서 상세 설명). 각 라운드는 세 단계를 수행합니다:

1단계: 공급량 추정치 업데이트.

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

2단계: 새로운 공급량에 맞게 곱 항 업데이트.

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

3단계: 수렴 확인.

Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon이면 DmD_{m}을 반환하고, 그렇지 않으면 1단계부터 반복합니다.

초기값 D0D_0, π0\pi_0, σ\sigma는 초기 반복에 영향을 미칩니다. 이론적으로는 최종 수렴과 무관하지만, 유한한 반복 횟수와 고정 정밀도 산술로 인해 실제로는 결과에 영향을 줍니다.

구현은 고정 정밀도 정수 연산을 사용합니다: 나눗셈은 내림 반올림하며, 뺄셈은 언더플로우를 방지하지 않습니다. 일반적인 풀 조건에서는 중간값이 안전한 범위 내에 유지됩니다. 극단적인 풀 상태에서는 그렇지 않습니다. 0x2.1절에서 이러한 실패 방식을 상세히 분석합니다.

0x1.3 세 가지 인터페이스와 불변식 솔버

프로토콜은 가중 곱 항 π\pi(vb_prod로 코드에 저장됨)를 업데이트하여 풀 상태에 영향을 미치는 세 가지 진입점을 제공합니다:

인터페이스 기능 _calc_supply() 호출 여부
add_liquidity() 임의 비율로 자산 예치
update_rates() 외부 교환 비율 업데이트
remove_liquidity() 가중치에 따라 비례적으로 자산 인출 아니오 (비례 스케일링 사용)

이 비대칭성이 중요합니다: add_liquidity()임의 비율의 예치를 허용하여(풀을 크게 왜곡할 수 있음) 반면, remove_liquidity()는 항상 비례적으로 인출합니다. 따라서 추가/제거의 반복 사이클을 통해 풀을 점점 더 불균형한 상태로 만들 수 있습니다.

비율 업데이트 메커니즘

앞서 설명했듯이, 가상 잔액(xix_i)은 LST의 교환 비율을 기반으로 계산됩니다. 따라서 비율을 업데이트하는 방법을 이해하는 것이 중요합니다.

구체적으로, add_liquidity()update_rates() 함수는 내부 함수 _update_rates()를 통해 비율을 업데이트할 수 있는 반면, remove_liquidity() 함수는 비율 동기화를 수행하지 않습니다.

  • add_liquidity()는 중요한 작업을 실행하기 전에 _update_rates()를 호출하여 자산 교환 비율이 최신 상태로 동기화되도록 합니다.
  • update_rates()는 수동 비율 업데이트를 허용합니다.

_update_rates() 함수는 컨트랙트 내에 기록된 교환 비율이 외부 비율과 일치하는지 확인합니다. 불일치가 감지되면 가상 잔액을 재계산하고 불변식을 업데이트하는 과정을 트리거합니다. 그렇지 않으면 업데이트 과정을 건너뜁니다.

각 인터페이스의 π 처리 방식

불변식에 영향을 미치는 방식에 따라 이 세 함수는 두 가지 범주로 분류할 수 있습니다. 구체적으로, add_liquidity()update_rates()는 가상 잔액의 비비례적 변화를 허용하므로 공급량 DD와 곱 π\pi의 반복적 재계산이 필요합니다. 반면 remove_liquidity()는 유동성을 비례적으로 인출하므로 반복 계산이 필요하지 않습니다.

처음부터 곱을 계산하는 기본 공식은 다음과 같습니다:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

여기서 DD는 공급량, wiw_i는 자산 ii의 가중치, xix_i는 가상 잔액(코드에서 vb[i]로 저장됨), nn은 자산 수입니다. 이 형태는 0x1.1절의 정의와 대수적으로 동치이며, DnD^n이 곱 안으로 분배됩니다.

  1. **add_liquidity()**는 두 가지 경로를 가집니다(코드는 0x2.2절에서 확인):
  • 부트스트랩 경로 (prev_supply == 0일 때): 수식 (4)를 사용하여 vb_prod를 처음부터 계산합니다. 배포 후에도 이 경로가 접근 가능한 상태로 남아 있는 것이 0x2.2절에서 논의하는 상태 관리 취약점입니다.
  • 일반 경로 (prev_supply > 0일 때): 계산 과정은 두 단계로 나뉩니다:
    • a) 이전 가상 잔액과 새 가상 잔액의 비율을 기반으로 점진적 업데이트를 사용합니다:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      여기서 xix_ixix_i'는 예치 전후의 가상 잔액입니다.

    • b) 이 추정치를 입력으로 _calc_supply()를 호출하여 불변식 DD와 정확한 π\pi 값을 재계산함으로써 정밀한 값을 반복적으로 보정합니다.

  1. **update_rates()**는 교환 비율이 변경될 때 트리거되어 해당 자산의 가상 잔액이 업데이트됩니다. 이후 계산 흐름은 add_liquidity()의 일반 경로를 따릅니다. 즉, 불변식이 반복적으로 재계산됩니다. 또한 새로 계산된 공급량을 기반으로 컨트랙트는 유동성 공급량이 업데이트된 가상 잔액 상태와 일치하도록 yETH를 민팅하거나 소각합니다.

  2. **remove_liquidity()**는 각 가상 잔액을 비례적으로 감소시킨 후 항상 수식 (4)를 사용하여 vb_prod를 처음부터 계산합니다.


0x2 근본 원인 분석

두 가지 취약점이 서로 다른 역할과 영향으로 악용되었습니다. 주요 근본 원인은 불변식 솔버 _calc_supply()의 계산 결함으로, 두 가지 실패 방식이 있었습니다: (A) 내림 반올림이 곱 항을 0으로 만들어 불변식을 상수합 모델로 퇴화시키고 과도한 LP 민팅(공급량 인플레이션)으로 이어질 수 있으며, (B) 언더플로우 조건이 공급량을 과도하게 부풀릴 수 있습니다. 실패 방식 A만이 Phase 2(약 810만 달러)에서 사용되었습니다. 실패 방식 B는 부차적 취약점에 상호 의존적이었습니다.

부차적 근본 원인은 상태 관리 결함이었습니다: 풀의 초기화 분기가 접근 가능한 상태로 유지되었습니다. Phase 2에서 공급량이 0으로 고갈된 후, 실패 방식 B와 부트스트랩 경로가 결합하여 추가로 약 90만 달러의 손실(Phase 3)을 가능하게 했습니다.

0x2.1 _calc_supply()의 안전하지 않은 산술 연산 (주요)

그림 2는 _calc_supply() 구현을 0x1.2절의 수학적 절차에 매핑하고, 아래에서 분석하는 두 가지 산술 실패 지점을 표시합니다:

코드 변수는 수학적 항에 다음과 같이 매핑됩니다:

코드 변수 수학적 역할
s 현재 공급량 추정치 DmD_m
r 곱 항 πm\pi_m
sp 다음 공급량 추정치 Dm+1D_{m+1}
l 분자 상수: Afnσ\mathit{Af}^{\,n} \cdot \sigma
d 분모 상수: Afn1\mathit{Af}^{\,n} - 1

핵심 표현식은 다음과 같습니다:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # 1단계: D[m+1]
r  = unsafe_div(unsafe_mul(r, sp), s)                 # 2단계: π 업데이트 (자산별)

이 함수 내에는 서로 다른 줄을 대상으로 하고 서로 다른 효과를 생성하는 두 가지 산술 실패 방식이 존재합니다. 두 가지 모두 풀이 극단적인 상태에 있어야 트리거됩니다.

일반적인 조건에서 반복은 올바르게 동작합니다: l - s * r은 적당한 양수 값이고, 반복은 몇 라운드 안에 수렴합니다.

1. 실패 방식 A: 내림 반올림으로 인한 곱의 제로화

2단계에서 곱은 자산별로 다음과 같이 업데이트됩니다:

r = unsafe_div(unsafe_mul(r, sp), s)   # r = r * sp / s

unsafe_div()는 정수 나눗셈을 수행하므로 항상 내림 반올림합니다. 풀이 심각하게 불균형하고 sps보다 훨씬 작을 때(조작된 대규모 예치 후에 발생하듯이), 분자 r * sp가 분모 s보다 작아질 수 있습니다. 정수 나눗셈은 그때 **r = 0**을 반환합니다.

r이 0이 되면 이후 모든 반복에서 0으로 유지됩니다. 곱 항 π\pi는 영구적으로 붕괴됩니다.

이 실패가 pow_up()pow_down() 간의 반올림 방향 불일치에서 비롯된다는 일반적인 오귀인이 있습니다. 0x4절에서 이것이 올바르지 않다는 증거를 제시합니다.

2. 실패 방식 B: 언더플로우로 인한 공급량 인플레이션

1단계에서 새로운 공급량 추정치는 다음과 같이 계산됩니다:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # sp = (l - s*r) / d

뺄셈 l - s*r은 수식 2의 AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m입니다. 일반적인 조건에서 이는 양수입니다. 그러나 풀이 공급량 0의 퇴화 상태에 도달하면, add_liquidity()의 초기화 분기(0x2.2절에서 상세 설명)가 곱 항을 처음부터 재계산하고, 상대적 크기가 역전될 수 있습니다.

구체적으로, 공급량이 0인 풀에서 소액으로 add_liquidity()가 호출되면, 초기화 분기는 _calc_vb_prod_sum()을 호출하여 수식 (4)(0x1.3절)를 사용해 새로운 값을 계산합니다(0x1.3절). 아주 작은 예치금으로, vb_sum은 극히 작지만(예: 16), 거의 0에 가까운 잔액으로 나누고 높은 거듭제곱으로 올리면 곱이 불균형하게 큰 값(예: ~9.13e20)으로 증폭됩니다. s * rl을 초과하면 뺄셈은 수학적으로 음수 결과를 산출합니다.

unsafe_sub()확인되지 않은 uint256 산술로 뺄셈을 수행하므로, 음수 결과는 엄청나게 큰 양의 정수(22562^{256}에 가까운 값)로 wrap됩니다. 이 wrap된 값은 나눗셈과 후속 반복을 통해 전파되어 터무니없이 큰 공급량 추정치를 만들어내고, 프로토콜은 이를 실제 yETH 토큰으로 민팅합니다.

일반적인 주장은 이러한 언더플로우가 특정 공급량 조작 단계의 두 번째 반복에서 발생한다고 말합니다. 0x4절에서 이 주장이 올바르지 않음을 보여줍니다: 공급량을 부풀리는 실제 언더플로우는 완전히 다른 맥락(공격의 Phase 3)에서 발생합니다.

3. 이러한 실패들이 공격을 가능하게 하는 방식

이 두 가지 실패 방식은 서로 다른 단계에서 서로 다른 이익 기여를 하며 작동합니다:

  • 실패 방식 A (Phase 2, 약 810만 달러): 공격자가 심각하게 불균형한 풀에 예치하면 곱 항이 0이 되어 _calc_supply()가 인플레이션된 공급량을 반환합니다. 프로토콜은 공격자에게 yETH를 과도하게 민팅합니다. 이 실패 방식만으로, 부트스트랩 경로의 관여 없이, 공격자가 yETH 가중 스테이블스왑 풀의 LST 자산을 고갈시킬 수 있었습니다.

  • 실패 방식 B (Phase 3, 약 90만 달러): 공급량이 0으로 고갈된 후, 부트스트랩 경로가 소액 예치로부터 큰 곱 항을 재계산하여 뺄셈에서 언더플로우가 발생합니다. 프로토콜은 천문학적인 양의 yETH를 민팅하고, 공격자는 이를 별도의 yETH/WETH Curve 풀을 고갈시키는 데 사용합니다.

의존성은 단방향입니다: 실패 방식 A는 독립적으로 악용 가능하여 손실의 90%를 유발했으며, 실패 방식 B는 먼저 공급량을 0으로 만들기 위해 실패 방식 A가 필요합니다.

0x2.2 비활성화되지 않은 부트스트랩 경로 (부차적)

add_liquidity() 함수는 풀의 초기 예치를 위한 분기를 포함합니다:

로직은 다음과 같이 추상화할 수 있습니다:

if prev_supply == 0:
    # 부트스트랩 경로 — vb_prod와 vb_sum을 처음부터 계산
    vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
    supply = vb_sum
else:
    # 일반 경로 — 저장된 vb_prod 사용, 점진적 확인 수행
    ...

# 두 분기 모두 이후에 호출됨, prev_supply == 0을 플래그로 사용
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)

prev_supply == 0일 때, 함수는 저장된 상태를 우회하고 _calc_vb_prod_sum()을 통해 수식 (4)(0x1.3절)를 사용하여 vb_prodvb_sum을 처음부터 재계산합니다. 이 부트스트랩 분기는 풀 초기화 시 일회성 사용을 위해 의도되었으나, 첫 번째 예치 후에 영구적으로 차단되지 않았습니다.

총 공급량이 0으로 유도될 수 있다면(소각과 인출의 조합을 통해), 이 분기에 다시 접근 가능해집니다. 이 경로에 재진입하는 공격자는 솔버에 전달되는 초기 조건을 제어할 수 있으며, 일반적인 풀 작동 중에는 발생하지 않을 매개변수 하에서 위에서 설명한 산술 실패를 잠재적으로 트리거할 수 있습니다.

이것은 알려진 취약점 패턴입니다. 2023년 8월 Balancer V2 사건에서도 유사하게 공급량을 0으로 만들어 내부 비율을 재설정하고, 공격자가 인위적으로 유리한 매개변수에서 초기화 로직에 재진입할 수 있게 했습니다 [6]. 배포된 풀이 초기 상태로 다시 되돌아갈 수 있는지, 그리고 그 상태에서 어떤 불변식이 유지되는지는 프로토콜 설계자가 명시적으로 다루어야 하는 질문입니다.


0x3 공격 분석

익스플로잇은 공격 트랜잭션 [5]의 조율된 시퀀스를 통해 전개되며, 세 단계로 구성됩니다. 각 단계는 이전 단계에서 형성된 상태를 기반으로 합니다.

0x3.1 Phase 1: 풀 왜곡 (준비)

목표: 자산 전체에 걸쳐 가상 잔액에 극단적인 불균형을 만듭니다.

아래 그림은 이 단계의 트랜잭션 추적을 보여줍니다(공간 제약으로 플래시론 단계는 생략됨):

공격자는 먼저 Balancer와 Aave에서 플래시론을 통해 대량의 LST 자산을 빌립니다. 구체적으로 5,500e18 wstETH, 3,100e18 WETH, 1,800e18 rETH, 2,000e18 ETHx, 200e18 cbETH입니다.

다음으로, 공격자는 yETH/WETH Curve 풀에서 약 800e18 WETH를 약 416e18 yETH로 교환하고, 획득한 yETH로 풀에서 유동성을 제거합니다.

핵심 조작은 0x1절(배경)에서 설명한 인터페이스 비대칭성을 활용합니다: add_liquidity()임의 비율의 예치를 허용하는 반면, remove_liquidity()는 풀 가중치에 따라 비례적으로 자산을 인출합니다(위 그림의 빨간 사각형으로 강조). 선택된 자산만 예치하고 모든 자산은 비례적으로 인출하면서 추가 → 제거 작업을 반복 순환함으로써, 공격자는 풀을 점진적으로 심각하게 불균형한 상태로 만듭니다:

자산 가중치 이전 이후 변화
0 (sfrxETH) 20% 628,097,482,908,289,585,170 684,908,495,923,316,419,717 +9.04%
1 (wstETH) 20% 376,569,216,105,249,117,091 684,906,088,027,654,432,883 +81.88%
2 (ETHx) 10% 187,473,530,249,048,974,586 410,441,661,092,336,995,160 +118.93%
3 (cbETH) 10% 267,387,722,745,796,900,349 3,532,430,695,689,175,233 -98.68%
4 (rETH) 10% 201,828,029,369,446,137,136 410,441,659,865,060,509,563 +103.36%
5 (apxETH) 25% 753,792,636,209,697,936,333 549,134,446,963,315,842,411 -27.15%
6 (WOETH) 2.5% 49,640,000,870,620,479,267 655,788,758,768,556,847 -98.68%
7 (mETH) 2.5% 47,667,894,211,903,277,629 629,735,467,970,876,930 -98.68%

자산 3(cbETH), 6(WOETH), 7(mETH)은 98% 이상 고갈되었습니다. 이 불균형은 직접적으로 이익을 추출하지 않습니다. 다음 단계를 위한 수치적 전제 조건을 만듭니다.

0x3.2 Phase 2: 공급량 0으로 붕괴 (약 810만 달러)

목표: 불변식 곱을 0으로 만들고, yETH 공급량을 0으로 고갈시킵니다. 이 단계는 주요 취약점(안전하지 않은 산술 연산)만을 악용하며 총 손실의 약 90%를 유발했습니다.

이 단계는 세 번 실행되는 반복적인 5단계 사이클을 사용합니다:

  1. add_liquidity()를 통해 곱을 오염시킵니다;
  2. add_liquidity()를 통해 수정을 위한 전제 조건을 설정합니다;
  3. yETH 0으로 remove_liquidity()를 통해 곱을 재설정합니다;
  4. update_rates()를 통해 공급량을 수정합니다;
  5. remove_liquidity()를 통해 자산을 인출합니다.

아래 그림은 트랜잭션 추적을 보여주며, 5단계 사이클의 세 번의 반복이 명확하게 보입니다:

1. add_liquidity()를 통해 곱 오염

공격자는 고가중치 자산(인덱스 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH)을 대량 예치하며, 각각 현재 가상 잔액의 약 세 배에 해당합니다.

add_liquidity()는 수식 (5)(0x1.3절)의 점진적 업데이트를 통해 새로운 곱 항을 추정합니다. 고가중치 자산에 대해 xixix_i' \gg x_i이므로, 비율 (xi/xi)(x_i / x_i')은 모두 1보다 훨씬 작은 분수이며 높은 거듭제곱으로 올립니다. 이로 인해 πnew\pi_{\text{new}}가 약 42e18에서 약 0.00353e18로 떨어지며, 거의 0에 가까운 추정 곱이 됩니다.

이 작은 곱이 _calc_supply()에 입력됩니다. 반복에서 곱 업데이트 r = r * sp / s는 0x2절(근본 원인 분석)에서 설명한 내림 반올림 조건에 부딪힙니다: 분자가 분모보다 작아지고 정수 나눗셈이 r0으로 내립니다. 함수는 0인 곱과 인플레이션된 공급량(~vb_sum)을 반환하여 프로토콜이 yETH를 과도하게 민팅하게 합니다.

2. add_liquidity()를 통해 수정을 위한 전제 조건 설정

공격자는 자산 인덱스 3(cbETH, 고갈된 저가중치 자산)에 대해 단일 면 유동성을 추가하여 자산의 현재 풀 잔액의 약 6.5배를 예치합니다. 이는 소수의 yETH만 받지만, 3단계에서 제품을 0이 아닌 값으로 복원한 후에도 4단계의 반복이 여전히 극단적인 불균형으로 인한 격렬한 진동 때문에 0인 곱을 생성할 수 있습니다.

이 단계 없이는 3단계에서 0이 아닌 곱을 복원하더라도 4단계의 수정이 여전히 실패합니다. 우리의 Foundry 시뮬레이션이 이를 확인합니다: 2단계를 건너뛰면 4단계의 수정이 실패합니다.

3. yETH 0으로 remove_liquidity()를 통해 곱 재설정

공격자는 amount 0으로 remove_liquidity()를 호출합니다. 토큰은 인출되지 않지만, 함수는 수식 (4)(0x1.3절)를 사용하여 현재 풀 상태로부터 vb_prod를 재계산합니다. 가상 잔액이 0이 아니므로 0이 아닌 곱(~9.09e19)이 생성되어 오염된 0 값을 덮어씁니다.

4. update_rates()를 통해 공급량 수정

공격자는 자산 인덱스 6(WOETH) 또는 7(mETH)에 대해 update_rates()를 호출합니다. 마지막 업데이트 이후 교환 비율이 변경되었다면, 함수는 복원된(0이 아닌) 곱으로 _calc_supply()를 트리거합니다. 이번에는 반복이 올바르게 수렴하여 현재 인플레이션된 값보다 훨씬 낮은 공급량 값을 생성합니다. 그 차이는 yETH 스테이킹 컨트랙트에서 소각됩니다. 공식 사후 분석 보고서 [2]에 따르면, 이는 프로토콜 소유 유동성(POL)을 구성하며, 이 소각은 공격자의 보유량이 아닌 프로토콜의 포지션을 줄입니다. 이 비대칭성이 핵심입니다: 각 사이클은 공격자의 yETH 잔액이 그대로 유지되는 동안 총 공급량을 줄입니다.

비율 불일치 자체는 이익의 원천이 아닙니다. 이는 순전히 트리거 메커니즘으로 작동합니다. 세 가지 풀 인터페이스 중 add_liquidity()update_rates()만이 _calc_supply()를 호출하며, remove_liquidity()는 비례 스케일링을 사용하고 그렇지 않습니다. 3단계에서 0이 아닌 곱이 복원된 후, 공격자는 추가 자산을 예치하지 않고 _calc_supply()를 트리거해야 합니다. 오래된 비율로 update_rates()를 호출하면 정확히 이것을 달성합니다: 비율 변경이 공격자에게 비용 없이 공급량 재계산을 트리거합니다.

이것은 공격의 미묘한 측면을 설명합니다: 준비 단계(Phase 1) 동안 공격자는 의도적으로 WOETH와 mETH에 대한 유동성 추가를 피했습니다. 해당 비율이 add_liquidity() 중에 업데이트되었다면 비율 불일치가 없었을 것이고, 이 단계의 update_rates()_calc_supply()를 트리거하지 않았을 것입니다.

5. remove_liquidity()를 통해 자산 인출

각 사이클이 끝날 때 공격자는 remove_liquidity()를 통해 자산을 인출합니다.

이익 추출 방식

이익 메커니즘은 다음과 같이 작동합니다: 1단계에서 공격자는 LST를 예치하고 과도하게 민팅된 yETH를 받습니다(오염된 곱으로 인해). 4단계에서 공급량이 수정될 때, 초과 yETH는 공격자가 아닌 POL(스테이킹 컨트랙트)에서 소각됩니다. 5단계에서 공격자는 yETH 보유량에 비례하여 LST를 인출합니다. POL이 소각을 흡수하고 공격자의 yETH 잔액이 그대로 유지되기 때문에, 공격자는 예치한 것보다 더 많은 LST를 인출하게 됩니다. 세 사이클에 걸쳐 추출된 이 차이가 약 810만 달러입니다.

리베이스의 목적

추적(첫 번째와 두 번째 사이클 사이)은 OETHVaultProxy.rebase() 호출도 보여주며, 이는 OETH 리베이스를 트리거합니다: WOETH 컨트랙트가 보유한 OETH 잔액이 증가하여 WOETH의 실효 교환 비율이 상승합니다. 이 "저장된" 비율 불일치가 두 번째 사이클의 4단계를 다시 가능하게 합니다: update_rates()가 결국 호출될 때 불일치를 감지하고 _calc_supply()를 트리거합니다.

0으로 고갈

이 5단계 사이클을 세 번 반복한 후, 공격자는 풀의 총 공급량을 자신이 보유한 yETH 양보다 적게 줄였습니다. 나머지 공급량으로 최종 remove_liquidity() 호출을 하면 ZERO로 고갈됩니다.

풀은 이제 공급량 0, 곱 0, vb_sum 0을 가집니다. 이 퇴화 상태는 사전 예치가 있는 풀이 초기화되지 않은 상태로 돌아가지 않을 것이라는 암묵적인 설계 가정을 위반합니다.

0x3.3 Phase 3: 추가 이익을 위한 공급량 0 악용 (약 90만 달러)

목표: 퇴화 풀 상태에서 엄청난 양의 yETH를 민팅한 후 실제 자산과 교환합니다. 이 단계는 부차적 취약점(비활성화되지 않은 부트스트랩 경로)과 실패 방식 B(언더플로우)의 상호 의존적 조합을 악용하며, 함께 총 손실의 약 10%를 기여합니다.

1. 언더플로우를 통한 민팅

총 공급량이 0인 상태에서, 공격자는 소액([1, 1, 1, 1, 1, 1, 1, 9] 잔액)으로 add_liquidity()를 호출합니다.

prev_supply == 0이므로, 코드는 0x2절(근본 원인 분석)에서 설명한 부트스트랩 경로에 진입합니다: 저장된 상태를 우회하고 _calc_vb_prod_sum()을 통해 vb_prodvb_sum을 처음부터 재계산한 후, 이를 _calc_supply()에 전달합니다. 이것이 두 번째 취약점의 작동입니다: 공격자가 풀을 초기화되지 않은 상태로 되돌려, 솔버에 공급되는 초기 조건을 제어합니다.

모든 가상 잔액이 소액 수준일 때(교환 비율이 1e18에 가까움), 계산된 값은 다음과 같습니다:

  • vb_sum = 16
  • vb_prod ≈ 9.13e20
  • _supply = vb_sum = 16

_calc_supply() 내에서 변수들은 다음과 같이 초기화됩니다:

  • l = _amplification * _vb_sum ≈ 4.5e20 × 16 ≈ 7.2e21
  • d = _amplification - PRECISION4.49e20
  • s = _supply = 16
  • r = _vb_prod9.13e20

이제 뺄셈 l - s * r:

7.2×102116×9.13×1020=7.2×10211.46×10227.4×10217.2 \times 10^{21} - 16 \times 9.13 \times 10^{20} = 7.2 \times 10^{21} - 1.46 \times 10^{22} \approx -7.4 \times 10^{21}

이것은 음수입니다. 확인되지 않은 uint256 산술에서 unsafe_sub는 이를 약 22567.4×10212^{256} - 7.4 \times 10^{21}로 wrap합니다. 이는 천문학적으로 큰 값입니다. d(~4.49e20)로 나누면 결과 공급량 추정치는 약 2.35e56이 되며, 프로토콜은 이 전체 양을 공격자에게 민팅합니다. 이 언더플로우는 Phase 2에서 총 공급량이 0으로 고갈되었기 때문에만 가능합니다. 비퇴화 풀 상태에서는 l > s * r이 성립하여 뺄셈이 안전합니다.

2. 실제 자산으로 교환

공격자는 과도하게 민팅된 yETH의 일부를 yETH–WETH Curve 풀에서 약 1,097e18 WETH로 교환하여 WETH 준비금을 고갈시킵니다. Phase 1에서 지출된 800e18 WETH를 고려하면 순이익은 약 $0.9M입니다.

Phase 2에서 추출된 약 810만 달러의 LST 자산과 합산하면, 공격자는 플래시론 상환 후 총 약 900만 달러의 순이익을 올렸습니다.

자금 출처 및 목적지 주소를 포함한 상세한 자금 흐름 분석은 다른 공개된 분석(예: [2])에서 다루어졌으며, 이 글의 범위 밖입니다.


0x4 오해 수정

이 사건에 대한 대부분의 공개된 분석들은 공격자가 전제 조건을 어떻게 설정했는지를 충분히 설명하지 않고 산술적 증상에 초점을 맞춥니다. 두 가지 구체적인 주장은 수정이 필요합니다.

0x4.1 주장: "pow_up()pow_down() 간의 반올림 방향 불일치가 불변식을 오염시킨다"

일반적인 해석은 근본 원인을 일부 코드 경로에서 pow_up()을 사용하고 다른 경로에서 pow_down()을 사용하는 것에 귀인하며, 방향적 불일치가 악용 가능한 불일치를 도입한다고 주장합니다.

우리는 이것을 직접 테스트했습니다: 컨트랙트를 수정하여 pow_down()을 균일하게 사용하도록(모든 pow_up() 호출을 교체) 하고 Foundry에서 전체 공격 시뮬레이션을 다시 실행했습니다. 익스플로잇은 동일하게 성공했습니다. 곱은 여전히 0으로 붕괴되고, 공급량은 여전히 고갈되며, 언더플로우는 여전히 인플레이션된 민팅을 생성합니다.

0 곱 상태를 가능하게 하는 반올림은 반복 루프 내의 r = unsafe_div(unsafe_mul(r, sp), s)내림 나눗셈이며, 초기 곱 값을 추정하는 데 사용되는 거듭제곱 함수의 반올림 방향이 아닙니다.

0x4.2 주장: "두 번째 반복에서의 언더플로우가 중간 항을 0으로 붕괴시킨다"

널리 인용되는 설명에 따르면 _calc_supply()의 두 번째 반복 동안 unsafe_sub의 언더플로우가 sp ≈ 1.94e18을 생성하고, 이것이 r을 0으로 내림 반올림을 유발한다고 합니다.

우리는 Foundry(온체인 재현)와 Python(수학적 검증) 모두를 사용하여 정확한 중간값을 재현했습니다. Foundry 시뮬레이션은 반복별로 _calc_supply()를 추적합니다:

======= _calc_supply 반복 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← 초기 공급량
  r = 3538247433646816               ← 초기 곱 (매우 작음)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← 새 공급량이 약 4배 급증
  new r ≈ 4.49e22                    ← 곱이 극적으로 증가

======= _calc_supply 반복 1 =======
  s = 10926206313726454855296        ← 이전 sp에서
  r = 44892226765713223838396        ← 이전 내부 루프에서

  sp = 19113493328251743069          ← ≈ 1.91e19, 정당하게 작음
  new r = 0                          ← 0으로 내림 반올림!

핵심 관찰: 반복 1에서 sp는 약 1.91e19로 평가됩니다. 이것은 정당하게 작은 양수 값이며, 언더플로우 아티팩트가 아닙니다. 뺄셈 l - s*r은 이 반복에서 증폭 가중 합계 l과 공급량-곱 항 s*r이 크기가 비슷하기 때문에 작은 양수 결과를 만들어냅니다.

곱을 0으로 만드는 것은 다음에 일어나는 일입니다: 내부 루프는 r = r * sp / s를 계산하며, 여기서 sp(~1.91e19)는 s(~1.09e22)보다 훨씬 작습니다. 분자 r * sp가 분모 s보다 작아지고, 정수 나눗셈이 결과를 0으로 내립니다.

우리는 임의 정밀도 정수로 동일한 값을 계산하여 뺄셈이 언더플로우하지 않음을 확인하며 Python에서 독립적으로 이를 검증했습니다:

곱은 나눗셈의 내림 반올림을 통해 0이 되며, 뺄셈의 언더플로우를 통해서가 아닙니다. 공급량을 인플레이션시키는 unsafe_sub 언더플로우는 완전히 다른 맥락에서 발생합니다: 공급량이 0으로 고갈된 풀에 소액 유동성이 추가되는 공격의 Phase 3입니다.


0x5 결론

yETH 익스플로잇은 비대칭적인 영향을 미치는 두 가지 취약점을 포함했습니다. _calc_supply()안전하지 않은 산술 연산이 주요 근본 원인이었습니다: 내림 반올림 실패(실패 방식 A)는 Phase 2만으로도 독립적으로 약 810만 달러의 손실을 가능하게 했습니다. 비활성화되지 않은 부트스트랩 경로는 부차적 취약점이었습니다. 언더플로우 실패(실패 방식 B)와 결합하여 Phase 3에서 추가 약 90만 달러의 손실을 가능하게 했지만, Phase 2가 이미 공급량을 0으로 고갈시킨 후에만 가능했습니다. 이 손실 분류는 Phase 2와 Phase 3의 이익을 분리하지 않는 다른 공개된 보고서와 현재 분석을 구별합니다.

공식 사후 분석 보고서 [2]는 다섯 가지 근본 원인을 식별합니다. 우리는 이를 두 가지 결함(공식 #1과 #5를 통합하는 안전하지 않은 산술 연산; #4로서 비활성화되지 않은 부트스트랩 경로)과 두 가지 아키텍처적 전제 조건(#2 비대칭적 Π 처리; #3 POL 가능 공급량 0 상태)으로 재분류합니다. 구분: 결함은 설계 의도를 위반하는 구현 버그이고(솔버는 0인 곱을 생성하거나 언더플로우해서는 안 됨), 전제 조건은 의도대로 기능하지만 결함과 결합될 때 악용 가능한 공격 표면을 만드는 설계 선택입니다.

권고사항

  • 불변식 솔버의 확인된 산술. 가스 효율성을 희생하더라도 언더플로우/오버플로우 시 명시적인 revert와 함께 safe_divsafe_sub를 사용합니다. 솔버는 최대 256번 반복 실행되며, 가스 오버헤드는 보안 위험에 비해 무시할 수 있습니다.
  • 중간값의 경계 검사. 곱 항이 반복 사이에 합리적인 범위 내에 유지되는지 검증합니다. 0으로 떨어지는 곱이나 반복 사이에 크기 단위로 증가하는 공급량 추정치는 퇴화 상태를 신호합니다.
  • 불균형 제한. 자산의 가상 잔액과 목표 가중치 비례 잔액 간의 최대 편차를 적용합니다. 이는 Phase 1이 전제 조건을 만드는 것을 방지했을 것입니다.
  • 불변식 단조성 검사. _calc_supply()가 반환된 후, 새로운 공급량이 변화 방향과 일치하는지 검증합니다(유동성 추가는 공급량을 감소시켜서는 안 되고, 비율 업데이트는 10배 변화를 생성해서는 안 됨 등).
  • 초기화 경로 영구 비활성화. 풀의 첫 번째 예치 후, prev_supply == 0 부트스트랩 분기가 재진입될 수 없도록 차단합니다. 이는 Phase 3를 완전히 방지했을 것입니다.
  • 공급량 0 상태 방지. 프로토콜 수준의 소각(POL 또는 스테이킹 컨트랙트에서)이 풀이 0이 아닌 잔액을 보유하는 동안 총 공급량을 0으로 줄일 수 없도록 합니다. 최소 공급량 바닥은 부트스트랩 재진입을 가능하게 하는 퇴화 상태로의 전환을 차단할 것입니다.
  • 실시간 이상 감지. 비정상적인 상태 전환(곱 항이 0으로 떨어지는 것, 공급량이 크기 단위로 변하는 것, 또는 짧은 시간 내에 반복적인 추가/제거 사이클)을 모니터링하고 손실이 누적되기 전에 경보 또는 회로 차단기를 트리거합니다.

참조

  1. Yearn Finance 사건 공지
  2. Yearn Security 사후 분석
  3. yETH 문서
  4. yETH 백서: 불변식 도출
  5. BlockSec Explorer의 공격 트랜잭션
  6. BlockSec: Balancer 부스티드 풀 사건 분석 (2023년 8월)

BlockSec 소개

BlockSec은 풀스택 블록체인 보안 및 암호화폐 컴플라이언스 제공업체입니다. 고객이 코드 감사(스마트 컨트랙트, 블록체인 및 지갑 포함)를 수행하고, 실시간으로 공격을 차단하고, 사건을 분석하고, 불법 자금을 추적하고, 프로토콜 및 플랫폼의 전체 수명주기에 걸쳐 AML/CFT 의무를 충족할 수 있도록 돕는 제품과 서비스를 구축합니다.

BlockSec은 권위 있는 학술 회의에서 여러 블록체인 보안 논문을 발표했으며, DeFi 애플리케이션의 여러 제로데이 공격을 보고했고, 2,000만 달러 이상을 구출하기 위한 여러 해킹을 차단했으며, 수십억 달러의 암호화폐를 보호했습니다.

Sign up for the latest updates
~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리
Security Insights

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 22~28일 발생한 주요 사건 2건을 다루며, 이더리움과 카르다노에서 약 410만 달러의 피해가 확인됐습니다. Taiko 브릿지 공격은 노출된 SGX 서명 키와 디버그 엔클레이브를 거부하지 못한 증명 정책 결함을 이용해 악성 증명자를 등록하고 L2 상태 증명을 위조했습니다. SecondFi 지갑은 Ed25519 논스 도출 시 비밀 입력이 제거되는 결함으로 공개 트랜잭션 데이터만으로 개인 키 복구가 가능했습니다.

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리
Security Insights

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리

이 주간 블록체인 보안 보고서는 2026년 6월 15일~21일을 다루며, 이더리움과 BNB 체인에서 3건의 주요 사고가 발생해 약 $18.3M의 손실이 발생했습니다. jaredFromSubway 사건은 MEV 봇이 차익거래를 위해 신뢰할 수 없는 제3자 컨트랙트에 자산을 승인한 역방향 승인 공격으로, 가짜 래퍼 토큰과 스왑 풀을 이용해 약 $15M 손실이 발생했습니다. Aztec은 이스케이프 해치 ZK 회로의 제약 누락으로 공격자가 가짜 머클 트리로 온체인 검증을 통과했습니다.

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

BlockSec가 Web3 Companion을 오픈소스로 공개했습니다. 이 보안 중심의 에이전트 지갑은 자체 AI 에이전트를 신뢰하지 않는 방식으로 설계되었으며, 키 격리, 강력한 정책, Passkey를 활용해 온체인 자산을 보호합니다.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit