Cảnh báo rủi ro: Đề phòng huy động vốn bất hợp pháp dưới danh nghĩa 'tiền điện tử' và 'blockchain'. — Năm cơ quan bao gồm Ủy ban Giám sát Ngân hàng và Bảo hiểm
Tìm kiếm
Đăng nhập
简中
繁中
English
日本語
한국어
ภาษาไทย
Tiếng Việt
BTC
ETH
HTX
SOL
BNB
Xem thị trường
Balancer bị hack, phân tích lỗ hổng
ExVul Security
特邀专栏作者
@exvulsec
2025-11-04 08:19
Bài viết này có khoảng 11217 từ, đọc toàn bộ bài viết mất khoảng 17 phút
Vào ngày 3 tháng 11 năm 2025, giao thức Balancer đã bị tin tặc tấn công vào nhiều chuỗi công khai, bao gồm Arbitrum và Ethereum, gây thiệt hại 120 triệu đô la tài sản. Nguyên nhân cốt lõi của cuộc tấn công bắt nguồn từ hai lỗ hổng: mất độ chính xác và thao túng các bất biến. Vấn đề then chốt trong cuộc tấn công này nằm ở logic xử lý các giao dịch nhỏ của giao thức.

Lời nói đầu

Vào ngày 3 tháng 11 năm 2025, giao thức Balancer đã bị tin tặc tấn công trên nhiều chuỗi công khai như Arbitrum và Ethereum, gây thiệt hại 120 triệu đô la tài sản. Nguyên nhân cốt lõi của cuộc tấn công bắt nguồn từ lỗ hổng kép về mất độ chính xác và thao túng bất biến.

Vấn đề then chốt trong cuộc tấn công này nằm ở logic xử lý các giao dịch nhỏ của giao thức. Khi người dùng thực hiện một giao dịch nhỏ, giao thức sẽ gọi hàm _upscaleArray , sử dụng mulDown để làm tròn giá trị xuống. Nếu số dư trong giao dịch và số tiền đầu vào đều nằm trong một giới hạn làm tròn cụ thể (ví dụ: phạm vi 8-9 wei), một lỗi độ chính xác tương đối đáng kể sẽ xảy ra.

Lỗi độ chính xác lan truyền đến phép tính giá trị bất biến D trong giao thức đã khiến giá trị D giảm bất thường. Sự thay đổi giá trị D đã trực tiếp làm giảm giá BPT (Mã thông báo nhóm cân bằng) trong giao thức Balancer. Tin tặc đã lợi dụng mức giá BPT thấp này để thực hiện giao dịch chênh lệch giá thông qua các đường dẫn giao dịch được thiết kế sẵn, cuối cùng gây ra tổn thất tài sản khổng lồ.

Liên kết khai thác lỗ hổng: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742

Liên kết chuyển nhượng tài sản: https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569

Phân tích kỹ thuật

Điểm vào tấn công

Điểm vào của cuộc tấn công là hợp đồng Balancer: Vault và hàm vào tương ứng là hàm batchSwap , gọi onSwap nội bộ để thực hiện hoán đổi mã thông báo.

 hàm onSwap(
 SwapRequest bộ nhớ swapRequest,
 cân bằng bộ nhớ uint256[],
 chỉ số uint256In,
 uint256 indexOut
 ) chỉ ghi đè bên ngoàiVault(swapRequest.poolId) trả về (uint256) {
 _beforeSwapJoinExit();

 _validateIndexes(indexIn, indexOut, _getTotalTokens());
 uint256[] bộ nhớ scalingFactors = _scalingFactors();

 trở lại
 swapRequest.kind == IVault.SwapKind.GIVEN_IN
 ? _swapGivenIn(swapRequest, số dư, indexIn, indexOut, scalingFactors)
 : _swapGivenOut(swapRequest, số dư, indexIn, indexOut, scalingFactors);
 }

Từ các tham số và hạn chế của hàm, chúng ta có thể thu được một số thông tin:

  1. Kẻ tấn công cần phải gọi hàm này thông qua Vault; chúng không thể gọi trực tiếp.
  2. Hàm này gọi _scalingFactors() bên trong để lấy các hệ số tỷ lệ cho các hoạt động tỷ lệ.
  3. Các hoạt động điều chỉnh tỷ lệ được xử lý trong _swapGivenIn hoặc _swapGivenOut .

Phân tích mô hình tấn công

Phương pháp tính giá BPT

Trong mô hình nhóm ổn định của Balancer, giá BPT là điểm tham chiếu quan trọng, quyết định số lượng BPT mà người dùng nhận được và số lượng tài sản nhận được trên mỗi BPT.

 Giá BPT = D / tổng cung

Trong đó D = bất biến, theo mô hình StableSwap của Curve.

Trong phép tính trao đổi nhóm:

 // StableMath._calcOutGivenIn
 hàm _calcOutGivenIn(
 Tham số khuếch đại uint256,
 cân bằng bộ nhớ uint256[],
 uint256 tokenIndexIn,
 uint256 tokenIndexOut,
 uint256 tokenAmountIn,
 bất biến uint256
 ) trả về thuần túy nội bộ (uint256) {
 /************************************************************************************************************
 // outGivenIn token x cho y - phương trình đa thức để giải //
 // ay = số tiền cần tính toán //
 // bởi = số dư token ra //
 // y = by - ay (finalBalanceOut) //
 // D = bất biến DD^(n+1) //
 // A = hệ số khuếch đại y^2 + ( S + ---------- - D) * y - ------------- = 0 //
 // n = số lượng mã thông báo (A * n^n) A * n^2n * P //
 // S = tổng số dư cuối cùng nhưng y //
 // P = tích của số dư cuối cùng nhưng y //
 *******************************************************************************************************************/

 // Số tiền ra, do đó chúng ta làm tròn xuống tổng thể.
 số dư[tokenIndexIn] = số dư[tokenIndexIn].thêm(tokenAmountIn);

 uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances(
 Tham số khuếch đại,
 số dư
 bất biến, // sử dụng D cũ
 tokenIndexOut
 );

 // Không cần sử dụng số học đã kiểm tra vì `tokenAmountIn` thực sự đã được thêm vào cùng một số dư ngay trước đó
 // gọi `_getTokenBalanceGivenInvariantAndAllOtherBalances` mà không làm thay đổi mảng balances.
 số dư[tokenIndexIn] = số dư[tokenIndexIn] - tokenAmountIn;

 trả về số dư[tokenIndexOut].sub(finalBalanceOut).sub(1);
 }

Phần đóng vai trò là chuẩn mực cho giá BPTgiá trị hằng số D ; nghĩa là, thao túng giá BPT đòi hỏi phải thao túng D. Chúng ta hãy phân tích quá trình tính toán của D:

 // StableMath._calculateBất biến
 hàm _calculateInvariant(uint256 amplificationParameter, uint256[] cân bằng bộ nhớ)
 nội bộ
 nguyên chất
 trả về (uint256)
 {
 /************************************************************************************************
 // bất biến //
 // D = bất biến D^(n+1) //
 // A = hệ số khuếch đại A n^n S + D = AD n^n + ----------- //
 // S = tổng số dư n^n P //
 // P = tích của các số dư //
 // n = số lượng mã thông báo //
 *************************************************************************************************/

 // Luôn làm tròn xuống để phù hợp với phép tính của Vyper (luôn cắt bớt).

 uint256 tổng = 0; // S trong phiên bản Curve
 uint256 numTokens = số dư.chiều dài;
 đối với (uint256 i = 0; i < numTokens; i++) {
 sum = sum.add(balances[i]); // balances là giá trị được chia tỷ lệ}
 nếu (tổng == 0) {
 trả về 0;
 }

 uint256 prevInvariant; // Dprev trong phiên bản Curve
 uint256 bất biến = tổng; // D trong phiên bản Curve
 uint256 ampTimesTotal = amplificationParameter * numTokens; // Ann trong phiên bản Curve

 // Tính toán lặp lại của D...
 // Việc tính toán D ảnh hưởng đến độ chính xác của số dư đối với (uint256 i = 0; i < 255; i++) {
 uint256 D_P = bất biến;

 đối với (uint256 j = 0; j < numTokens; j++) {
 // (D_P * bất biến) / (số dư[j] * numTokens)
 D_P = Math.divDown(Math.mul(D_P, bất biến), Math.mul(số dư[j], numTokens));
 }

 prevInvariant = bất biến;

 bất biến = Math.divDown(
 Toán.mul(
 // (ampTimesTotal * tổng) / AMP_PRECISION + D_P * numTokens
 (Math.divDown(Math.mul(ampTimesTotal, tổng), _AMP_PRECISION).add(Math.mul(D_P, numTokens))),
 bất biến
 ),
 // ((ampTimesTotal - _AMP_PRECISION) * bất biến) / _AMP_PRECISION + (numTokens + 1) * D_P
 (
 Math.divDown(Math.mul((ampTimesTotal - _AMP_PRECISION), bất biến), _AMP_PRECISION).add(
 Math.mul((numTokens + 1), D_P)
 )
 )
 );

 if (bất biến > trướcInvariant) {
 if (bất biến - prevInvariant <= 1) {
 trả về bất biến;
 }
 } else if (prevInvariant - bất biến <= 1) {
 trả về bất biến;
 }
 }

 _revert(Lỗi.STABLE_INVARIANT_DIDNT_CONVERGE);
 }

Trong đoạn mã trên, phép tính D phụ thuộc vào mảng scaled balances . Điều này có nghĩa là cần một thao tác để thay đổi độ chính xác của các số dư này, dẫn đến lỗi trong phép tính D.

Nguyên nhân gốc rễ của việc mất độ chính xác

 // BaseGeneralPool._swapGivenIn
 hàm _swapGivenIn(
 SwapRequest bộ nhớ swapRequest,
 cân bằng bộ nhớ uint256[],
 chỉ số uint256In,
 uint256 indexOut,
 uint256[] Hệ số mở rộng bộ nhớ
 ) trả về ảo nội bộ (uint256) {
 // Phí được trừ đi trước khi chia tỷ lệ để giảm độ phức tạp của việc phân tích hướng làm tròn.
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);

 _upscaleArray(balances, scalingFactors); // Khóa: Nâng cấp số dư swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(swapRequest, số dư, indexIn, indexOut);

 // Các mã thông báo amountOut đang thoát khỏi Pool, do đó chúng tôi làm tròn xuống.
 trả về _downscaleDown(amountOut, scalingFactors[indexOut]);
 }

Hoạt động mở rộng:

 // ScalingHelpers.sol
hàm _upscaleArray(uint256[] lượng bộ nhớ, uint256[] memory scalingFactors) pure {
 uint256 chiều dài = số lượng.chiều dài;
 InputHelpers.ensureInputLengthMatch(chiều dài, scalingFactors.length);

 đối với (uint256 i = 0; i < chiều dài; ++i) {
 amount[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]); // Làm tròn xuống}
}

// FixedPoint.mulDown
 hàm mulDown(uint256 a, uint256 b) trả về nội bộ thuần túy (uint256) {
 tích uint256 = a * b;
 _require(a == 0 || sản phẩm / a == b, Lỗi.MUL_OVERFLOW);

 trả về sản phẩm / MỘT; // Làm tròn xuống: cắt trực tiếp}

Như đã trình bày ở trên, khi sử dụng _upscaleArray , nếu số dư rất nhỏ (ví dụ: 8-9 wei), việc làm tròn xuống của mulDown sẽ dẫn đến mất độ chính xác đáng kể.

Chi tiết quá trình tấn công

Giai đoạn 1: Điều chỉnh theo ranh giới làm tròn

 Kẻ tấn công: BPT → cbETH
Mục tiêu: Điều chỉnh số dư cbETH theo ranh giới làm tròn (ví dụ: kết thúc bằng 9).

Giả sử trạng thái ban đầu:
 Số dư cbETH (Gốc): ...00000000009 wei (chữ số cuối là 9)

Giai đoạn 2: Kích hoạt mất độ chính xác (Lỗ hổng cốt lõi)

 Kẻ tấn công: wstETH (8 wei) → cbETH

Trước khi mở rộng quy mô:
 Số dư cbETH: ...000000000009 wei 
 wstETH đầu vào: 8 wei

Thực thi _upscaleArray:
 // tỷ lệ cbETH: 9 * 1e18 / 1e18 = 9
 // Nhưng nếu giá trị thực tế là 9,5 thì nó sẽ trở thành 9 do làm tròn xuống.
 scaled_cbETH = sàn (9,5) = 9
 
 Độ chính xác mất: 0,5 / 9,5 = 5,3% tính toán lỗi tương đối trao đổi:
 Đầu vào (wstETH): 8 wei (đã chia tỷ lệ)
 Số dư (cbETH): 9 (Không đúng, phải là 9,5)
 
 Do cbETH bị định giá thấp nên số dư mới được tính toán cũng sẽ bị định giá thấp, dẫn đến lỗi trong phép tính D.
 D_original = f(9,5, ...)
 D_mới = f(9, ...) < D_gốc

Giai đoạn 3: Hưởng lợi từ giá BPT giảm

 Kẻ tấn công: Tài sản cơ sở → BPT

vào thời điểm này:
 D_mới = D_gốc - ΔD
 Giá BPT = D_new / totalSupply < D_original / totalSupply
 
Kẻ tấn công có thể thu được cùng số lượng BPT với ít tài sản cơ bản hơn.
Hoặc trao đổi cùng một tài sản cơ bản để lấy nhiều BPT hơn

Kẻ tấn công ở trên đã sử dụng Batch Swap để thực hiện nhiều lần hoán đổi trong một giao dịch duy nhất:

  1. Giao dịch đầu tiên: BPT → cbETH (điều chỉnh số dư)
  2. Hoán đổi thứ hai: wstETH (8) → cbETH (kích hoạt mất độ chính xác)
  3. Lần trao đổi thứ ba: Tài sản cơ sở → BPT (lợi nhuận)

Tất cả các giao dịch hoán đổi này đều nằm trong cùng một giao dịch hoán đổi hàng loạt và chia sẻ cùng trạng thái số dư , nhưng _upscaleArray được gọi để sửa đổi mảng số dư cho mỗi giao dịch hoán đổi.

Việc thiếu cơ chế gọi lại

Quá trình chính được khởi động bởi Vault, vậy điều này dẫn đến sự tích tụ độ chính xác như thế nào? Câu trả lời nằm ở cơ chế truyền của mảng cân bằng .

 // Hàm logic khi Vault gọi onSwap: _processGeneralPoolSwapRequest(IPoolSwapStructs.SwapRequest memory request, IGeneralPool pool)
 riêng tư
 trả về (uint256 amountCalculated)
 {
 byte32 tokenInBalance;
 bytes32 tokenOutBalance;

 // Chúng ta truy cập cả hai chỉ mục mã thông báo mà không kiểm tra sự tồn tại, vì chúng ta sẽ thực hiện thủ công ngay sau đó.
 EnumerableMap.IERC20ToBytes32Map lưu trữ poolBalances = _generalPoolsBalances[request.poolId];
 uint256 indexIn = poolBalances.unchecked_indexOf(request.tokenIn);
 uint256 indexOut = poolBalances.unchecked_indexOf(request.tokenOut);

 nếu (indexIn == 0 || indexOut == 0) {
 // Các mã thông báo có thể chưa được đăng ký vì bản thân Nhóm chưa được đăng ký. Chúng tôi kiểm tra điều này để cung cấp
 // lý do hoàn nguyên chính xác hơn.
 _ensureRegisteredPool(request.poolId);
 _revert(Lỗi.MÃ_KHÔNG_ĐĂNG_KÝ);
 }

 // EnumerableMap lưu trữ các chỉ số *cộng với một* để sử dụng chỉ số không làm giá trị canh gác - vì chúng hợp lệ,
 Chúng ta có thể hoàn tác việc này.
 chỉ sốTrong -= 1;
 indexOut -= 1;

 uint256 tokenAmount = poolBalances.length();
 uint256[] bộ nhớ currentBalances = new uint256[](tokenAmount);

 yêu cầu.lastChangeBlock = 0;
 đối với (uint256 i = 0; i < tokenAmount; i++) {
 // Vì phép lặp bị giới hạn bởi `tokenAmount` và không có mã thông báo nào được đăng ký hoặc hủy đăng ký ở đây, chúng tôi
 // biết rằng `i` là chỉ mục mã thông báo hợp lệ và có thể sử dụng `unchecked_valueAt` để lưu các lần đọc bộ nhớ.
 số dư bytes32 = poolBalances.unchecked_valueAt(i);

 currentBalances[i] = balance.total(); // Đọc từ bộ nhớ request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());

 nếu (i == indexIn) {
 tokenInBalance = số dư;
 } else if (i == indexOut) {
 tokenOutBalance = số dư;
 }
 }
 
 // Thực hiện hoán đổi // Thực hiện lệnh gọi lại yêu cầu hoán đổi và tính toán số dư mới cho 'token vào' và 'token ra' sau khi hoán đổi
 amountCalculated = pool.onSwap(yêu cầu, số dư hiện tại, indexIn, indexOut);
 (uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
 tokenInBalance = tokenInBalance.increaseCash(số tiền trong);
 tokenOutBalance = tokenOutBalance.decreaseCash(số tiền ra);

 // Cập nhật lưu trữ // Vì không có mã thông báo nào được đăng ký hoặc hủy đăng ký giữa thời điểm hiện tại hoặc khi chúng tôi truy xuất các chỉ mục cho
 // 'token in' và 'token out', chúng ta có thể sử dụng `unchecked_setAt` để lưu các lần đọc bộ nhớ.
 poolBalances.unchecked_setAt(indexIn, tokenInBalance);
 poolBalances.unchecked_setAt(indexOut, tokenOutBalance);
 }

Phân tích mã ở trên, mặc dù Vault tạo một mảng currentBalances mới mỗi khi onSwap được gọi, trong Batch Swap :

  1. Sau lần trao đổi đầu tiên, số dư sẽ được cập nhật (nhưng giá trị cập nhật có thể không chính xác do mất độ chính xác).
  2. Lần hoán đổi thứ hai tiếp tục tính toán dựa trên kết quả của lần hoán đổi đầu tiên.
  3. Sự mất độ chính xác tích lũy cuối cùng sẽ dẫn đến sự giảm đáng kể giá trị bất biến D.

Các vấn đề chính:

 // BaseGeneralPool._swapGivenIn
 hàm _swapGivenIn(
 SwapRequest bộ nhớ swapRequest,
 cân bằng bộ nhớ uint256[],
 chỉ số uint256In,
 uint256 indexOut,
 uint256[] Hệ số mở rộng bộ nhớ
 ) trả về ảo nội bộ (uint256) {
 // Phí được trừ đi trước khi chia tỷ lệ để giảm độ phức tạp của việc phân tích hướng làm tròn.
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);

 _upscaleArray(balances, scalingFactors); // Sửa đổi mảng tại chỗ. swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(swapRequest, số dư, indexIn, indexOut);

 // Các mã thông báo amountOut đang thoát khỏi Pool, do đó chúng tôi làm tròn xuống.
 trả về _downscaleDown(amountOut, scalingFactors[indexOut]);
 }
// Mặc dù Vault truyền vào một mảng mới mỗi lần, nhưng:
// 1. Nếu số dư rất nhỏ (8-9 wei), thì độ chính xác bị mất trong quá trình điều chỉnh tỷ lệ là đáng kể. // 2. Trong Hoán đổi hàng loạt, các lần hoán đổi tiếp theo sẽ tiếp tục tính toán dựa trên số dư đã mất độ chính xác. // 3. Chưa xác minh được liệu sự thay đổi trong giá trị bất biến D có nằm trong phạm vi hợp lý hay không.

Tóm tắt

Nguyên nhân dẫn đến cuộc tấn công của Balancer có thể được tóm tắt như sau:

1. Hàm chia tỷ lệ sử dụng làm tròn xuống : _upscaleArray sử dụng mulDown để chia tỷ lệ, điều này sẽ làm mất đáng kể độ chính xác tương đối khi số dư rất nhỏ (chẳng hạn như 8-9 wei).

2. Tính toán giá trị bất biến nhạy cảm với độ chính xác : Việc tính toán giá trị bất biến D phụ thuộc vào mảng cân bằng được chia tỷ lệ và độ chính xác bị mất sẽ được chuyển trực tiếp vào phép tính D, làm cho D nhỏ hơn.

3. Thiếu xác minh những thay đổi trong các giá trị bất biến : Trong quá trình trao đổi, không xác minh được liệu những thay đổi trong giá trị bất biến D có nằm trong phạm vi hợp lý hay không, điều này cho phép kẻ tấn công liên tục khai thác việc mất độ chính xác để hạ giá BPT.

4. Tổn thất độ chính xác tích lũy trong hoán đổi hàng loạt : Trong cùng một hoán đổi hàng loạt, tổn thất độ chính xác từ nhiều lần hoán đổi sẽ tích lũy và cuối cùng khuếch đại thành tổn thất tài chính khổng lồ.

Hai vấn đề này—mất độ chính xác và thiếu xác thực—kết hợp với việc kẻ tấn công thiết kế điều kiện biên cẩn thận đã dẫn đến mất mát này.

Balancer
Chào mừng tham gia cộng đồng chính thức của Odaily
Nhóm đăng ký
https://t.me/Odaily_News
Tài khoản chính thức
https://twitter.com/OdailyChina
Tóm tắt AI
Trở về đầu trang
  • 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
  • 关键要素:
    1. 小额交易缩放函数向下舍入致精度损失。
    2. 不变值D计算依赖缩放后余额,误差放大。
    3. 缺少不变值变化验证与批处理误差累积。
  • 市场影响:引发DeFi协议安全机制与审计标准反思。
  • 时效性标注:中期影响
Tải ứng dụng Odaily Nhật Báo Hành Tinh
Hãy để một số người hiểu Web3.0 trước
IOS
Android