Documentation & Blog

The Balancer V2 Exploit

KWAKBUMJUN 2026. 4. 6. 01:23

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(사용자가 내야 할 양)을 과소 계산
→ 프로토콜이 손해, 사용자가 이득

정상이라면 swapGivenOutamount(사용자에게 나가는 양)은 올림되어야 한다. 사용자가 받는 양을 올림하면, 풀이 계산하는 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:   0x7da32ebc615d0f29a24cacf9d18254bea3a2c730084c690ee40238b1d8b55773

4.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 수정
_upscalemulDown만 사용 모든 연산에 명시적 라운딩 방향 강제
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에서 "작은 수학적 오차"는 존재하지 않는다. 공격자는 그 오차를 증폭시킬 방법을 반드시 찾아낸다.


참고 자료