밸런서 해킹, 취약점 분석
- 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
- 关键要素:
- 小额交易缩放函数向下舍入致精度损失。
- 不变值D计算依赖缩放后余额,误差放大。
- 缺少不变值变化验证与批处理误差累积。
- 市场影响:引发DeFi协议安全机制与审计标准反思。
- 时效性标注:中期影响
머리말
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(스왑요청, 잔액, 인덱스입력, 인덱스출력, 스케일링인자);
}
함수 매개변수와 제한 사항에서 다음과 같은 여러 정보를 얻을 수 있습니다.
- 공격자는 Vault를 통해 이 함수를 호출해야 하며, 직접 호출할 수는 없습니다.
- 이 함수는 내부적으로
_scalingFactors()호출하여 스케일링 작업에 필요한 스케일링 요소를 얻습니다. - 확장 작업은
_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을 사용하여 단일 트랜잭션 내에서 여러 스왑을 수행했습니다.
- 첫 번째 교환: BPT → cbETH (잔액 조정)
- 두 번째 스왑: wstETH(8) → cbETH(정밀도 손실 발생)
- 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 에서는 다음과 같습니다.
- 첫 번째 교환 후 잔액이 업데이트됩니다(하지만 정확도가 떨어지기 때문에 업데이트된 값이 부정확할 수 있습니다).
- 두 번째 스왑은 첫 번째 스왑의 결과를 기반으로 계산을 계속합니다.
- 누적된 정밀도 손실은 결국 불변값 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. 일괄 스왑에서 누적된 정밀도 손실 : 동일한 일괄 스왑에서 여러 스왑으로 인한 정밀도 손실이 누적되어 결국 엄청난 재정적 손실로 확대됩니다.
정밀도 손실과 검증 부족이라는 두 가지 문제가 공격자의 경계 조건에 대한 신중한 설계와 결합되어 이러한 손실이 발생했습니다.


