The Balancer V2 Exploit
TL;DR
- 날짜: 2025년 11월 3일
- 피해액: $128.64M (6개 체인)
- 대상: Balancer V2 Composable Stable Pools
- 근본 원인:
_upscale()함수가 항상mulDown(내림)만 사용하는 반면_downscale()은divUp/divDown(올림/내림)을 상황에 따라 사용 → 라운딩 방향 비대칭으로 인해 불변량(Invariant) D가 과소 계산됨 → BPT 가격 왜곡 → 배치 스왑으로 차익 추출 - 공격 키워드: 정밀도 손실(Precision Loss), 불변량 조작(Invariant Manipulation), 배치 스왑(Batch Swap)
1. 배경: Balancer V2와 Composable Stable Pool
1.1 Balancer란
Balancer는 Ethereum 기반 AMM(Automated Market Maker) DEX로, 사용자가 다양한 가중치의 토큰 풀을 생성하고 거래할 수 있게 한다. V2 아키텍처에서는 모든 자산이 하나의 Vault 컨트랙트에 보관되고, 각 Pool 컨트랙트가 가격 결정 로직만 담당한다.
1.2 Composable Stable Pool
Curve의 StableSwap 알고리즘을 기반으로, 비슷한 가치를 가진 자산(예: wstETH/rETH/cbETH)의 효율적 스왑을 지원한다. 핵심 특징:
- BPT(Balancer Pool Token): 유동성 공급 시 발행되는 LP 토큰. 풀 자체 내에서 스왑 가능
- 불변량 D: 풀의 "가상 총 가치"를 나타내는 수학적 상수. StableSwap 공식으로 계산
- BPT 가격 ≈ D / totalSupply: D가 작아지면 BPT 가격이 하락
1.3 Scaling Factor
토큰마다 소수점 자릿수가 다르다 (예: USDC는 6, WETH는 18). Balancer는 모든 계산을 18자리 정밀도로 통일하기 위해 scaling factor를 사용한다:
내부 계산 시: amount × scalingFactor (upscale — 정밀도 확대)
결과 반환 시: amount ÷ scalingFactor (downscale — 정밀도 축소)2. 근본 원인 (Root Cause): 라운딩 방향 비대칭
2.1 핵심 원칙: "라운딩은 항상 프로토콜에 유리하게"
DeFi 프로토콜에서 정수 연산의 라운딩(반올림/내림) 방향은 항상 프로토콜(풀)에 유리하게 설정되어야 한다:
사용자가 토큰을 받을 때 (amountOut): 내림(roundDown) → 사용자가 조금 덜 받음
사용자가 토큰을 낼 때 (amountIn): 올림(roundUp) → 사용자가 조금 더 냄이 원칙이 지켜지면, 아무리 많은 스왑을 반복해도 풀의 자산이 유출되지 않는다.
2.2 취약 코드: _upscale()의 단방향 라운딩
// FixedPoint 라이브러리
function mulDown(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 product = a * b;
return product / ONE; // 항상 내림
}
function divUp(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) return 0;
return (a * ONE - 1) / b + 1; // 항상 올림
}
function divDown(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * ONE) / b; // 항상 내림
}
// BaseGeneralPool.sol — 취약한 함수
function _swapGivenOut(
SwapRequest memory swapRequest,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut,
uint256[] memory scalingFactors
) internal returns (uint256) {
// Step 1: 잔액을 18자리로 upscale
_upscaleArray(balances, scalingFactors);
// Step 2: swapRequest.amount도 upscale — 여기서 mulDown(내림)만 사용
swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexOut]);
// Step 3: 불변량 기반으로 amountIn 계산
uint256 amountIn = _onSwapGivenOut(swapRequest, balances, indexIn, indexOut);
// Step 4: amountIn을 downscale — 여기서는 divUp(올림) 사용
amountIn = _downscaleUp(amountIn, scalingFactors[indexIn]);
return _addSwapFeeAmount(amountIn);
}
// _upscale: 항상 mulDown (내림) — 문제의 핵심
function _upscale(uint256 amount, uint256 scalingFactor)
internal pure returns (uint256)
{
return FixedPoint.mulDown(amount, scalingFactor);
}
// _downscaleUp: divUp (올림) ← 이건 올바름
// _downscaleDown: divDown (내림) ← 이것도 올바름
2.3 문제의 본질
| 연산 | 함수 | 라운딩 방향 | 정상 동작 |
|---|---|---|---|
| upscale | _upscale() |
항상 내림 (mulDown) | 상황에 따라 올림/내림이 필요 |
| downscale | _downscaleUp() |
올림 (divUp) | 정상 |
| downscale | _downscaleDown() |
내림 (divDown) | 정상 |
downscale은 올림/내림 두 가지 변형이 있는데, upscale은 내림 하나만 존재한다.
swapGivenOut에서 swapRequest.amount(사용자가 받을 양)을 upscale할 때 내림이 적용되므로:
실제 사용자가 가져가는 양: 8.918 wei (원본)
upscale 후 불변량 계산에 사용되는 양: 8 wei (내림)
→ 풀은 사용자가 8만 가져간다고 "착각"
→ amountIn(사용자가 내야 할 양)을 과소 계산
→ 프로토콜이 손해, 사용자가 이득정상이라면 swapGivenOut의 amount(사용자에게 나가는 양)은 올림되어야 한다. 사용자가 받는 양을 올림하면, 풀이 계산하는 amountIn도 더 커져서 프로토콜이 보호된다.
3. 공격 메커니즘
3.1 왜 평소에는 문제가 안 되는가
일반적인 스왑에서 이 라운딩 오차는 1 wei 미만이다. $0.000000000000000001 수준의 차이는 무시할 수 있다.
하지만 공격자가 의도적으로 잔액을 극소량(8~9 wei)까지 낮추면, 상대적 오차가 극대화된다:
잔액이 1,000,000 wei일 때: 1 wei 오차 = 0.0001% (무시 가능)
잔액이 9 wei일 때: 1 wei 오차 = 11.1% (치명적)3.2 공격 3단계 사이클 (65회 반복)
공격자는 하나의 batchSwap 트랜잭션 내에서 다음 3단계를 65회 연속 반복했다:
[1단계: 조정 (Adjustment)]
대량의 BPT → 토큰 스왑으로 특정 토큰의 잔액을 극소량(~9 wei)으로 감소
├── Composable Pool의 특성: BPT 자체가 풀 내에서 스왑 가능
└── 사전 보유 불필요: Vault가 임시 적자(deficit) 상태 허용
[2단계: 트리거 (Trigger)]
극소 잔액 상태에서 소량 스왑(amount = 8) 실행
├── _upscale(8) → mulDown → 내림으로 8.918이 8로 절삭
├── 불변량 D 계산에서 Δy가 과소 평가
├── D 값이 실제보다 작아짐
└── BPT 가격(= D/totalSupply)이 인위적으로 하락
[3단계: 추출 (Extraction)]
하락한 BPT 가격으로 BPT를 저렴하게 매입(민팅)
└── 이후 정상 가격으로 환매 → 차익 실현[정밀도 손실 누적 과정]
반복 1: BPT 가격 ≈ 정상
반복 10: BPT 가격 약간 하락 (누적 오차 증가)
반복 30: BPT 가격 유의미하게 왜곡
반복 65: BPT 가격 대폭 왜곡 → 대량 차익 추출 가능
증거: InternalBalanceChanged 이벤트에서 수수료가
0.414 osETH → 0.000000000000000003 osETH로 급감
(65회에 걸쳐 잔액이 고갈되는 과정)3.3 2단계 실행 전략 (탐지 우회)
공격자는 익스플로잇과 수익 실현을 별도 트랜잭션으로 분리했다:
[트랜잭션 1: 익스플로잇] — 표면상 수익 없음
└── batchSwap으로 65회 사이클 실행
└── BPT 가격 왜곡 + BPT 축적
└── 단독으로 보면 "손해 보는 거래"처럼 보임
[트랜잭션 2: 수익 실현] — 별도 시점에 실행
└── 축적된 BPT를 정상 가격으로 환매
└── 순이익 실현
이유: 실시간 탐지 시스템이 "단일 트랜잭션 내 비정상 수익"을 감시하므로,
수익을 분리하면 탐지 확률이 크게 감소3.4 공격자 인프라
Deployer: 0x506D1f9EFe24f0d47853aDca907EB8d89AE03207
Exploit Contract: 0x54B53503c0e2173Df29f8da735fBd45Ee8aBa30d
Recipient: 0xAa760D53541d8390074c61DEFeaba314675b8e3f공격은 컨트랙트 배포(constructor) 내에서 자동 실행되어, 단일 트랜잭션으로 전체 사이클이 완료되었다.
4. 피해 규모 및 영향
4.1 체인별 피해
| 체인 | 피해액 | 주요 탈취 자산 | 비고 |
|---|---|---|---|
| Ethereum | ~$99M | 6,587 WETH + 6,851 osETH + 4,260 wstETH | 최대 피해 |
| Arbitrum | ~$12M | — | — |
| Base | ~$5M | — | — |
| Polygon | ~$0.1M | — | 검증자 트랜잭션 검열로 동결 |
| Sonic (Beets 포크) | ~$3.4M | — | Balancer 포크 프로젝트 |
| Optimism (Beethoven 포크) | ~$0.28M | — | Balancer 포크 프로젝트 |
| 합계 | ~$128.64M | 6개 체인, 30분 내 |
4.2 주요 Ethereum 트랜잭션
Exploit TX: 0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742
Withdrawal TX: 0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569
Arbitrum TX: 0x7da32ebc615d0f29a24cacf9d18254bea3a2c730084c690ee40238b1d8b557734.3 회수 현황
| 출처 | 회수액 |
|---|---|
| 화이트햇 (Polygon) | $2.68M |
| 화이트햇 (Ethereum) | $0.96M |
| 화이트햇 (Base) | $0.16M |
| 화이트햇 (Arbitrum) | $0.05M |
| StakeWise (osETH/osGNO) | $19.7M |
| 총 회수 | ~$23.5M (피해액의 ~18%) |
5. 포크 프로젝트 연쇄 피해
Balancer V2는 오픈소스이므로 다수의 포크 프로젝트가 동일한 코드를 사용하고 있었다. 취약점이 공개되자 카피캣 공격자들이 포크 프로젝트를 연쇄적으로 공격했다.
[연쇄 피해 타임라인]
09:45 UTC — Ethereum Balancer V2 최초 공격
↓ (수분 내)
Arbitrum, Base, Optimism 동시 공격
↓
Sonic의 Beets (Balancer 포크) 공격 — $3.4M
Optimism의 Beethoven X (포크) 공격 — $0.28M
↓
Polygon 검증자, 공격 트랜잭션 검열 → $0.1M 동결
총 소요 시간: ~30분프로토콜을 일시 정지할 수 없었던 것이 피해를 확대시킨 핵심 요인이다. Balancer V2에는 풀 운영을 즉시 중단하는 비상 정지(emergency pause) 메커니즘이 부재했거나, 실행이 지연되었다.
6. 왜 감사에서 발견되지 않았는가
Balancer V2는 다수의 유명 감사 기관으로부터 감사를 받았음에도 이 취약점이 발견되지 않았다. Certora의 사후 분석에 따르면:
6.1 누락된 검증 속성
| 속성 | 설명 | 검증 여부 |
|---|---|---|
| Roundtrip Swap Invariance | A→B→A 왕복 스왑 시 사용자가 이득을 볼 수 없어야 함 | 미검증 |
| BPT Share Value Invariant | 어떤 연산 후에도 BPT 1개당 가치가 감소하지 않아야 함 | 미검증 |
Certora는 V3 검증 시 swappingBackAndForth라는 규칙을 명시적으로 추가하여 "왕복 스왑으로 이득을 볼 수 없음"을 증명했다. 이 속성이 V2 감사 시에 포함되었다면 발견되었을 것이다.
6.2 근본적 어려움
- 수학적 정밀도 오차는 기능 테스트(unit test)로는 잡히지 않음 — 정상 범위에서는 오차가 무시할 수 있는 수준
- 극단값(8~9 wei 잔액)에서만 문제가 드러남 — 퍼징(fuzzing)의 입력 공간이 좁음
- 여러 연산의 복합 효과 — 단일 함수는 정상이지만 65회 반복 시 오차가 누적
7. Balancer V3의 수정 사항
| V2 문제점 | V3 수정 |
|---|---|
_upscale이 mulDown만 사용 |
모든 연산에 명시적 라운딩 방향 강제 |
| Pool이 직접 scaling 수행 | Vault가 scaling 전담 — 일관성 보장 |
| Composable Pool에서 BPT 스왑 가능 | ERC4626 Buffer로 대체 — 공격 표면 제거 |
| 토큰별 다른 소수점 | 모든 풀 연산을 18자리 정밀도로 통일 |
| 비상 정지 부재/지연 | 개선된 거버넌스 비상 정지 메커니즘 |
8. PoC 개념 (Proof of Concept)
주의: 실제 메인넷에서의 실행은 불법입니다. 교육 목적의 개념 설명입니다.
8.1 공격 재현 개념 (Foundry 테스트 기반)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;
import "forge-std/Test.sol";
interface IVault {
enum SwapKind { GIVEN_IN, GIVEN_OUT }
struct BatchSwapStep {
bytes32 poolId;
uint256 assetInIndex;
uint256 assetOutIndex;
uint256 amount;
bytes userData;
}
struct FundManagement {
address sender;
bool fromInternalBalance;
address payable recipient;
bool toInternalBalance;
}
function batchSwap(
SwapKind kind,
BatchSwapStep[] memory swaps,
address[] memory assets,
FundManagement memory funds,
int256[] memory limits,
uint256 deadline
) external returns (int256[] memory assetDeltas);
}
contract BalancerExploitPoC is Test {
// Ethereum mainnet addresses
IVault constant vault = IVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
// Pool: wstETH/rETH/cbETH Composable Stable Pool
bytes32 constant poolId = /* target pool ID */;
function testExploitConcept() public {
// Fork Ethereum mainnet at block before exploit
// vm.createSelectFork("mainnet", BLOCK_BEFORE_EXPLOIT);
// Step 1: 풀의 BPT 잔액과 토큰 잔액 확인
// Step 2: batchSwap 구성 — 65회 반복 사이클
IVault.BatchSwapStep[] memory swaps = new IVault.BatchSwapStep[](65 * 3);
for (uint i = 0; i < 65; i++) {
uint base = i * 3;
// Phase 1: BPT → Token (잔액을 극소량으로 감소)
swaps[base] = IVault.BatchSwapStep({
poolId: poolId,
assetInIndex: 0, // BPT
assetOutIndex: 1, // target token
amount: /* calculated amount to push balance to ~9 wei */,
userData: ""
});
// Phase 2: Token A → Token B (라운딩 오차 트리거)
swaps[base + 1] = IVault.BatchSwapStep({
poolId: poolId,
assetInIndex: 1,
assetOutIndex: 2,
amount: 8, // 핵심: 극소량으로 mulDown 라운딩 오차 최대화
userData: ""
});
// Phase 3: Token → BPT (할인된 가격으로 BPT 매입)
swaps[base + 2] = IVault.BatchSwapStep({
poolId: poolId,
assetInIndex: 2,
assetOutIndex: 0, // BPT
amount: /* calculated */,
userData: ""
});
}
// Execute batch swap
// vault.batchSwap(...)
// Step 3: 별도 트랜잭션에서 BPT → underlying 환매로 수익 실현
}
}
8.2 핵심 수치 예시 (BlockSec 분석 기반)
[2단계 트리거의 구체적 수치]
사용자가 cbETH 8 wei를 받으려 함 (swapGivenOut)
scaling factor (cbETH, 18 decimals): 1e18
_upscale(8, 1e18):
= mulDown(8, 1e18)
= (8 * 1e18) / 1e18
= 8 ← 이 경우 오차 없음
하지만 scaling factor가 정확히 1e18이 아닌 토큰에서:
_upscale(8, 1.1e18):
= mulDown(8, 1.1e18)
= (8 * 1.1e18) / 1e18
= 8.8 → 내림 → 8 ← 0.918 손실 (11% 오차)
이 8이 불변량 계산에 입력됨:
실제 나간 양: 8.918
풀이 인식한 양: 8
→ amountIn이 실제 필요한 것보다 작게 계산됨
→ 불변량 D가 과소 평가됨
→ BPT 가격 왜곡9. 교훈 및 권고
9.1 DeFi 개발자를 위한 교훈
| 교훈 | 구체적 조치 |
|---|---|
| 라운딩 방향은 항상 프로토콜 유리하게 | upscale/downscale 모두 양방향(Up/Down) 변형을 제공하고, 컨텍스트에 따라 올바른 방향 선택 |
| 모든 scaling 함수에 대칭성 검증 | upscale에 mulDown만 있다면 mulUp도 구현하여 swapGivenOut에서 사용 |
| 극단값 퍼징(Fuzzing) | 잔액 1~10 wei 범위에서의 동작을 자동 테스트. 반복 스왑 시나리오 포함 |
| 왕복 불변성(Roundtrip Invariance) 검증 | A→B→A 스왑 시 사용자 이득 불가를 형식 검증(Formal Verification)으로 증명 |
| 비상 정지 메커니즘 필수 | 익스플로잇 발생 시 즉시 풀 동결 가능한 메커니즘 사전 구현 |
9.2 감사자를 위한 교훈
| 교훈 | 구체적 조치 |
|---|---|
| 라운딩 방향 체계적 감사 | 모든 산술 연산에서 라운딩 방향이 "누구에게 유리한가"를 명시적으로 검토 |
| Composable Pool 특수성 | BPT가 풀 내에서 스왑 가능한 설계는 공격 표면을 극대화 — 추가 주의 |
| 형식 검증 속성 확장 | 단순 overflow/underflow 외에 경제적 불변성(economic invariant) 검증 포함 |
9.3 프로토콜 운영자를 위한 교훈
| 교훈 | 구체적 조치 |
|---|---|
| 멀티체인 배포 = 멀티체인 위험 | 한 체인에서 익스플로잇 발견 시 모든 체인 동시 정지 가능한 체계 필요 |
| 포크 프로젝트 알림 체계 | 원본 프로토콜의 취약점이 모든 포크에 전파 — 신속한 알림 네트워크 구축 |
| 탐지 우회 인지 | 2단계 분리(익스플로잇 + 인출)로 실시간 탐지를 우회하는 패턴 대비 |
10. 요약
Balancer V2 익스플로잇은 "1 wei의 반올림 오차"가 어떻게 $128M의 피해로 확대될 수 있는지를 보여준 사건이다.
근본 원인은 단순하다: _upscale() 함수가 mulDown만 지원하여, swapGivenOut에서 사용자가 받는 양이 내림 처리됨으로써 풀이 손해를 보는 방향으로 라운딩이 적용되었다. 이 오차는 정상 거래에서는 무시할 수 있었지만, 공격자가 잔액을 극소량으로 유도한 후 65회 반복 스왑으로 누적시키면서 BPT 가격을 대폭 왜곡할 수 있었다.
이 사건이 남긴 가장 중요한 메시지:
DeFi에서 "작은 수학적 오차"는 존재하지 않는다. 공격자는 그 오차를 증폭시킬 방법을 반드시 찾아낸다.
참고 자료
- BlockSec - In-Depth Analysis: The Balancer V2 Exploit
- BlockSec - Rounding Inconsistency Breaks the Invariant
- Certora - Balancer Exploit Explained: What Went Wrong and Why V3 Is Safe
- Check Point Research - How an Attacker Drained $128M from Balancer
- Halborn - Explained: The Balancer Hack (November 2025)
- Trail of Bits - Balancer Hack Analysis and Guidance
- OpenZeppelin - Understanding the Balancer V2 Exploit