위험 경고: '가상화폐', '블록체인'이라는 이름으로 불법 자금 모집 위험에 주의하세요. — 은행보험감독관리위원회 등 5개 부처
검색
로그인
简中
繁中
English
日本語
한국어
ภาษาไทย
Tiếng Việt
BTC
ETH
HTX
SOL
BNB
시장 동향 보기
밸런서 해킹, 취약점 분석
ExVul Security
特邀专栏作者
@exvulsec
2025-11-04 08:19
이 기사는 약 11217자로, 전체를 읽는 데 약 17분이 소요됩니다
2025년 11월 3일, Balancer 프로토콜은 Arbitrum과 Ethereum을 포함한 여러 퍼블릭 체인에 대한 해커 공격을 받아 1억 2천만 달러의 자산 손실을 초래했습니다. 이 공격의 핵심은 정밀도 손실과 불변량 조작이라는 두 가지 취약점에서 비롯되었습니다. 이 공격의 핵심 문제는 소규모 거래를 처리하는 프로토콜의 논리에 있었습니다.

머리말

2025년 11월 3일, Balancer 프로토콜은 Arbitrum과 Ethereum 등 여러 퍼블릭 체인에서 해커의 공격을 받아 1억 2천만 달러의 자산 손실을 초래했습니다. 이 공격의 핵심은 정밀도 손실과 불변 조작이라는 이중 취약점에서 비롯되었습니다.

이 공격의 핵심 문제는 소액 거래 처리 프로토콜의 논리에 있습니다. 사용자가 소액 거래를 할 때, 프로토콜은 _upscaleArray 함수를 호출하는데, 이 함수는 mulDown 사용하여 값을 내림합니다. 거래 잔액과 입력 금액이 모두 특정 반올림 범위(예: 8~9 wei 범위) 내에 있으면 심각한 상대 정밀도 오류가 발생합니다.

프로토콜의 불변값 D 계산에 전파된 정확도 오류로 인해 D 값이 비정상적으로 감소했습니다. D 값의 변화는 Balancer 프로토콜에서 BPT(Balancer Pool Token)의 가격을 직접적으로 하락시켰습니다. 해커들은 이러한 BPT 가격 하락을 악용하여 미리 설계된 거래 경로를 통해 차익거래를 완료했고, 궁극적으로 막대한 자산 손실을 초래했습니다.

취약점 악용 링크: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742

자산 이전 링크: https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569

기술적 분석

공격 진입 지점

공격 진입 지점은 Balancer: Vault 계약이고, 해당 진입 기능은 batchSwap 함수로, 내부적으로 onSwap 호출하여 토큰 스왑을 수행합니다.

 onSwap(함수
 SwapRequest 메모리 swapRequest,
 uint256[] 메모리 잔액,
 uint256 인덱스인,
 uint256 인덱스아웃
 ) 외부 재정의 onlyVault(swapRequest.poolId)는 (uint256) {를 반환합니다.
 _beforeSwapJoinExit();

 _validateIndexes(indexIn, indexOut, _getTotalTokens());
 uint256[] 메모리 스케일링 요인 = _scalingFactors();

 반품
 swapRequest.kind == IVault.SwapKind.GIVEN_IN
 ? _swapGivenIn(swapRequest, 잔액, indexIn, indexOut, 스케일링 팩터)
 : _swapGivenOut(스왑요청, 잔액, 인덱스입력, 인덱스출력, 스케일링인자);
 }

함수 매개변수와 제한 사항에서 다음과 같은 여러 정보를 얻을 수 있습니다.

  1. 공격자는 Vault를 통해 이 함수를 호출해야 하며, 직접 호출할 수는 없습니다.
  2. 이 함수는 내부적으로 _scalingFactors() 호출하여 스케일링 작업에 필요한 스케일링 요소를 얻습니다.
  3. 확장 작업은 _swapGivenIn 또는 _swapGivenOut 에서 처리됩니다.

공격 패턴 분석

BPT 가격 계산 방법

Balancer의 안정적인 풀 모델에서 BPT 가격은 중요한 기준점이며, 이를 통해 사용자가 받는 BPT 수와 BPT당 받는 자산 수가 결정됩니다.

 BPT 가격 = D / 총공급량

여기서 D = 불변, Curve의 StableSwap 모델에서 따온 값입니다.

풀 교환 계산에서:

 // StableMath._calcOutGivenIn
 함수 _calcOutGivenIn(
 uint256 증폭 매개변수,
 uint256[] 메모리 잔액,
 uint256 토큰 인덱스인,
 uint256 토큰 인덱스 아웃,
 uint256 토큰 금액,
 uint256 불변
 ) 내부 순수 반환 (uint256) {
 /********************************************************************************************************************
 // outGivenIn 토큰 x를 y로 변환 - 풀어야 할 다항 방정식 //
 // ay = 계산할 금액 //
 // by = 잔액 토큰 출력 //
 // y = by - ay(finalBalanceOut) //
 // D = 불변 DD^(n+1) //
 // A = 증폭계수 y^2 + ( S + ---------- - D) * y - ------------- = 0 //
 // n = 토큰 수 (A * n^n) A * n^2n * P //
 // S = 최종 잔액의 합계이지만 y //
 // P = 최종 잔액의 곱이지만 y //
 ******************************************************************************************************************/

 // 금액이 너무 많으므로 전체적으로 반올림합니다.
 잔액[토큰 인덱스인] = 잔액[토큰 인덱스인].add(토큰 금액인);

 uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances(
 증폭매개변수,
 잔액
 불변, // 오래된 D 사용
 토큰 인덱스 아웃
 );

 // `tokenAmountIn`이 실제로 바로 직전에 동일한 잔액에 추가되었기 때문에 확인된 산술을 사용할 필요가 없습니다.
 // 잔액 배열을 변경하지 않는 `_getTokenBalanceGivenInvariantAndAllOtherBalances`를 호출합니다.
 잔액[토큰 인덱스 인] = 잔액[토큰 인덱스 인] - 토큰 금액 인;

 잔액[tokenIndexOut].sub(finalBalanceOut).sub(1)을 반환합니다.
 }

BPT 가격의 벤치마크 역할을 하는 부분은 상수 값 D 입니다. 즉, BPT 가격을 조작하려면 D를 조작해야 합니다. D의 계산 과정을 분석해 보겠습니다.

 // StableMath._calculateInvariant
 함수 _calculateInvariant(uint256 amplificationParameter, uint256[] 메모리 잔액)
 내부
 순수한
 (uint256)을 반환합니다.
 {
 /****************************************************************************************************
 // 불변 //
 // D = 불변 D^(n+1) //
 // A = 증폭 계수 A n^n S + D = AD n^n + ----------- //
 // S = 잔액의 합 n^n P //
 // P = 잔액의 곱 //
 // n = 토큰 수 //
 **************************************************************************************************/

 // 항상 반올림하여 Vyper의 산술 연산(항상 잘라냄)에 맞춥니다.

 uint256 sum = 0; // Curve 버전의 S
 uint256 토큰 수 = 잔액.길이;
 uint256 i = 0; i < numTokens; i++에 대해 {
 sum = sum.add(balances[i]); // balances는 스케일링된 값입니다.}
 if (합계 == 0) {
 0을 반환합니다.
 }

 uint256 prevInvariant; // Curve 버전의 Dprev
 uint256 invariant = sum; // Curve 버전의 D
 uint256 ampTimesTotal = amplificationParameter * numTokens; // Curve 버전의 Ann

 // D의 반복 계산...
 // D 계산은 잔액의 정밀도에 영향을 미칩니다. (uint256 i = 0; i < 255; i++)
 uint256 D_P = 불변;

 (uint256 j = 0; j < numTokens; j++) {
 // (D_P * 불변) / (잔액[j] * 토큰 수)
 D_P = Math.divDown(Math.mul(D_P, 불변), Math.mul(잔액[j], 토큰 수));
 }

 prevInvariant = 불변;

 불변량 = Math.divDown(
 수학.mul(
 // (ampTimesTotal * 합계) / AMP_PRECISION + D_P * 토큰 수
 (Math.divDown(Math.mul(ampTimesTotal, sum), _AMP_PRECISION).add(Math.mul(D_P, numTokens))),
 불변
 ),
 // ((ampTimesTotal - _AMP_PRECISION) * 불변) / _AMP_PRECISION + (토큰 수 + 1) * D_P
 (
 Math.divDown(Math.mul((ampTimesTotal - _AMP_PRECISION), 불변), _AMP_PRECISION).add(
 수학.mul((토큰 수 + 1), D_P)
 )
 )
 );

 if (불변 > prevInvariant) {
 if (불변 - prevInvariant <= 1) {
 불변성을 반환합니다.
 }
 } 그렇지 않으면 (prevInvariant - invariant <= 1) {
 불변성을 반환합니다.
 }
 }

 _revert(오류.STABLE_INVARIANT_DIDNT_CONVERGE);
 }

위 코드에서 D의 계산은 스케일된 잔액 배열에 따라 달라집니다 . 즉, 이러한 잔액의 정밀도를 변경하는 작업이 필요하며, 이로 인해 D 계산에 오류가 발생합니다.

정확도 손실의 근본 원인

 // BaseGeneralPool._swapGivenIn
 함수 _swapGivenIn(
 SwapRequest 메모리 swapRequest,
 uint256[] 메모리 잔액,
 uint256 인덱스인,
 uint256 인덱스아웃,
 uint256[] 메모리 확장 요인
 ) 내부 가상 반환 (uint256) {
 // 크기 조정 전에 수수료를 빼서 반올림 방향 분석의 복잡성을 줄입니다.
 스왑요청.금액 = _subtractSwapFeeAmount(스왑요청.금액);

 _upscaleArray(balances, scalingFactors); // 키: 잔액 상향 조정 swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(스왑 요청, 잔액, 인덱스 입력, 인덱스 출력);

 // amountOut 토큰이 풀에서 나가므로 내림합니다.
 _downscaleDown(amountOut, scalingFactors[indexOut])을 반환합니다.
 }

스케일링 작업:

 // 스케일링헬퍼.sol
함수 _upscaleArray(uint256[] 메모리 양, uint256[] 메모리 스케일링 요인) pure {
 uint256 길이 = 양.길이;
 InputHelpers.ensureInputLengthMatch(길이, 스케일링 팩터.길이);

 uint256 i = 0; i < length; ++i)에 대해 {
 amounts[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]); // 내림}
}

// FixedPoint.mulDown
 함수 mulDown(uint256 a, uint256 b) 내부 순수 반환 (uint256) {
 uint256 곱 = a * b;
 _require(a == 0 || 제품 / a == b, Errors.MUL_OVERFLOW);

 제품 반환 / ONE; // 반올림: 직접 잘라내기}

위에 표시된 것처럼 _upscaleArray 사용할 때 잔액이 매우 작은 경우(예: 8-9 wei) mulDown 을 반올림하면 정밀도가 크게 저하됩니다.

공격 프로세스 세부 정보

1단계: 반올림 경계에 맞게 조정

 공격자: BPT → cbETH
목표: cbETH 잔액을 반올림 경계(예: 9로 끝남)에 맞게 조정합니다.

초기 상태를 가정해 보자.
 cbETH 잔액(원래): ...00000000009 wei(마지막 숫자는 9)

2단계: 정밀성 손실 유발(핵심 취약성)

 공격자: wstETH(8 wei) → cbETH

스케일링 전:
 cbETH 잔액: ...000000000009 wei 
 wstETH 입력: 8 wei

_upscaleArray를 실행합니다.
 // cbETH 스케일링: 9 * 1e18 / 1e18 = 9
 // 하지만 실제 값이 9.5라면 반올림으로 인해 9가 됩니다.
 스케일링된_cbETH = 바닥(9.5) = 9
 
 정확도 손실: 0.5 / 9.5 = 5.3% 상대 오차 계산 교환:
 입력(wstETH): 8 wei(스케일링됨)
 잔액(cbETH): 9(잘못된 값, 9.5여야 함)
 
 cbETH가 저평가되어 있기 때문에 계산된 새 잔액도 저평가되어 D 계산에 오류가 발생합니다.
 D_원본 = f(9.5, ...)
 D_new = f(9, ...) < D_original

3단계: BPT 가격 하락으로 수익 창출

 공격자: 기본 자산 → BPT

이때:
 D_new = D_original - ΔD
 BPT 가격 = D_new / totalSupply < D_original / totalSupply
 
공격자는 더 적은 기반 자산으로 동일한 수의 BPT를 획득합니다.
또는 동일한 기초 자산을 더 많은 BPT로 교환합니다.

위의 공격자는 Batch Swap을 사용하여 단일 트랜잭션 내에서 여러 스왑을 수행했습니다.

  1. 첫 번째 교환: BPT → cbETH (잔액 조정)
  2. 두 번째 스왑: wstETH(8) → cbETH(정밀도 손실 발생)
  3. 3차 거래소: 기초자산 → BPT(수익)

이러한 스왑은 모두 동일한 배치 스왑 트랜잭션 내에 있으며 동일한 잔액 상태를 공유 하지만 _upscaleArray 각 스왑의 잔액 배열을 수정하기 위해 호출됩니다.

콜백 메커니즘이 부족합니다

주요 프로세스는 Vault에서 시작되는데, 이로 인해 정밀도 손실이 누적되는 이유는 무엇일까요? 답은 balances 배열의 전달 메커니즘 에 있습니다.

 // Vault가 onSwap을 호출할 때의 논리 함수: _processGeneralPoolSwapRequest(IPoolSwapStructs.SwapRequest 메모리 요청, IGeneralPool 풀)
 사적인
 (uint256 amountCalculated)를 반환합니다.
 {
 bytes32 토큰 잔액;
 bytes32 토큰아웃잔액;

 // 토큰 인덱스에 접근하기 전에 존재 여부를 확인하지 않습니다. 왜냐하면 바로 그 뒤에 수동으로 접근하기 때문입니다.
 EnumerableMap.IERC20ToBytes32Map 저장 풀 잔액 = _generalPoolsBalances[request.poolId];
 uint256 indexIn = poolBalances.unchecked_indexOf(요청.토큰인식);
 uint256 indexOut = poolBalances.unchecked_indexOf(요청.토큰아웃);

 if (indexIn == 0 || indexOut == 0) {
 // 풀 자체가 등록되지 않았기 때문에 토큰이 등록되지 않을 수 있습니다. 이를 확인하여 다음을 제공합니다.
 // 더 정확한 되돌리기 이유.
 _ensureRegisteredPool(request.poolId);
 _revert(오류.토큰이 등록되지 않음);
 }

 // EnumerableMap은 0 인덱스를 센티널 값으로 사용하기 위해 인덱스 *플러스 1*을 저장합니다. 이는 유효하기 때문입니다.
 우리는 이것을 취소할 수 있습니다.
 인덱스인 -= 1;
 인덱스아웃 -= 1;

 uint256 토큰 금액 = 풀 잔액.길이();
 uint256[] 메모리 currentBalances = new uint256[](tokenAmount);

 요청.마지막 변경 블록 = 0;
 uint256 i = 0; i < tokenAmount; i++)에 대해 {
 // 반복은 `tokenAmount`로 제한되고 토큰이 등록되거나 등록 해제되지 않으므로
 // `i`가 유효한 토큰 인덱스이고 `unchecked_valueAt`를 사용하여 저장소 읽기를 저장할 수 있다는 것을 알고 있습니다.
 bytes32 잔액 = poolBalances.unchecked_valueAt(i);

 currentBalances[i] = balance.total(); // 저장소에서 읽기 request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());

 if (i == indexIn) {
 tokenInBalance = 잔액;
 } 그렇지 않으면 if (i == indexOut) {
 토큰아웃잔액 = 잔액;
 }
 }
 
 // 스왑 수행 // 스왑 요청 콜백을 수행하고 스왑 후 '토큰 입력' 및 '토큰 출력'에 대한 새 잔액을 계산합니다.
 계산된 금액 = pool.onSwap(요청, 현재 잔액, indexIn, indexOut);
 (uint256 금액 입력, uint256 금액 출력) = _getAmounts(요청 종류, 요청 금액, 계산된 금액);
 토큰 잔액 = 토큰 잔액.현금 증가(금액);
 토큰아웃잔액 = 토큰아웃잔액.decreaseCash(금액아웃);

 // 저장소 업데이트 // 현재 또는 인덱스를 검색한 시점 사이에 토큰이 등록되거나 등록 해제되지 않았기 때문에
 // '토큰 입력'과 '토큰 출력'에서 `unchecked_setAt`를 사용하면 저장소 읽기를 저장할 수 있습니다.
 풀잔액.unchecked_setAt(인덱스인, 토큰인잔액);
 풀잔액.unchecked_setAt(indexOut, tokenOutBalance);
 }

위의 코드를 분석하면 Vault는 onSwap 호출될 때마다 새로운 currentBalances 배열을 생성하지만 Batch Swap 에서는 다음과 같습니다.

  1. 첫 번째 교환 후 잔액이 업데이트됩니다(하지만 정확도가 떨어지기 때문에 업데이트된 값이 부정확할 수 있습니다).
  2. 두 번째 스왑은 첫 번째 스왑의 결과를 기반으로 계산을 계속합니다.
  3. 누적된 정밀도 손실은 결국 불변값 D의 상당한 감소로 이어진다.

주요 이슈:

 // BaseGeneralPool._swapGivenIn
 함수 _swapGivenIn(
 SwapRequest 메모리 swapRequest,
 uint256[] 메모리 잔액,
 uint256 인덱스인,
 uint256 인덱스아웃,
 uint256[] 메모리 확장 요인
 ) 내부 가상 반환 (uint256) {
 // 크기 조정 전에 수수료를 빼서 반올림 방향 분석의 복잡성을 줄입니다.
 스왑요청.금액 = _subtractSwapFeeAmount(스왑요청.금액);

 _upscaleArray(balances, scalingFactors); // 배열을 제자리에서 수정합니다. swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(스왑 요청, 잔액, 인덱스 입력, 인덱스 출력);

 // amountOut 토큰이 풀에서 나가므로 내림합니다.
 _downscaleDown(amountOut, scalingFactors[indexOut])을 반환합니다.
 }
// Vault는 매번 새로운 배열을 전달하지만:
// 1. 잔액이 매우 작은 경우(8-9 wei), 스케일링 중 정밀도 손실이 상당합니다. // 2. 일괄 스왑에서는 후속 스왑이 이미 정밀도를 잃은 잔액을 기반으로 계산을 계속합니다. // 3. 불변값 D의 변화가 합리적인 범위 내에 있는지는 검증되지 않았습니다.

요약하다

Balancer의 공격 이유는 다음과 같이 요약할 수 있습니다.

1. 스케일링 함수는 반올림을 사용합니다 . _upscaleArray 스케일링에 mulDown 사용하는데, 이는 잔액이 매우 작을 때(예: 8-9 wei) 상대적 정밀도가 크게 저하됩니다.

2. 불변값 계산은 정밀도에 민감합니다 . 불변값 D의 계산은 스케일링된 잔액 배열에 따라 달라지며, 정밀도 손실은 D의 계산에 직접 전달되어 D를 더 작게 만듭니다.

3. 불변값 변화 검증 부족 : 교환 과정에서 불변값 D의 변화가 합리적인 범위 내에 있는지 검증하지 않아 공격자가 정밀도 손실을 반복적으로 악용하여 BPT의 가격을 낮출 수 있었습니다.

4. 일괄 스왑에서 누적된 정밀도 손실 : 동일한 일괄 스왑에서 여러 스왑으로 인한 정밀도 손실이 누적되어 결국 엄청난 재정적 손실로 확대됩니다.

정밀도 손실과 검증 부족이라는 두 가지 문제가 공격자의 경계 조건에 대한 신중한 설계와 결합되어 이러한 손실이 발생했습니다.

Balancer
Odaily 공식 커뮤니티에 가입하세요
AI 요약
맨 위로
  • 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
  • 关键要素:
    1. 小额交易缩放函数向下舍入致精度损失。
    2. 不变值D计算依赖缩放后余额,误差放大。
    3. 缺少不变值变化验证与批处理误差累积。
  • 市场影响:引发DeFi协议安全机制与审计标准反思。
  • 时效性标注:中期影响
Odaily 플래닛 데일리 앱 다운로드
일부 사람들이 먼저 Web3.0을 이해하게 하자
IOS
Android