Tác giả: 99

Biên tập: 77

Bối cảnh

Vào ngày 4 tháng 9 năm 2026, nền tảng cho vay phi tập trung nổi tiếng Notional Finance đã bị tấn công, thiệt hại khoảng 1,73 triệu USD. Dưới đây là phân tích cụ thể của đội ngũ an ninh SlowMist về vụ tấn công này:

Kiến thức nền tảng

Trong Notional Finance V1, fCash có thể được hiểu là quyền đòi tiền mặt có ngày đáo hạn. cash receiver sẽ nhận tiền khi đáo hạn, còn cash payer sẽ thanh toán khi đáo hạn. Giao thức ghi nhận cả hai vị thế trong Portfolio, sau đó thông qua kiểm tra tài sản thế chấp tự do để xác định mức độ an toàn của một tài khoản khi mở vị thế.

safeTransferFrom của ERC1155Trade ở đây đảm nhiệm chức năng ghi sổ tài sản. Khi loại tài sản truyền vào thuộc cash receiver, hợp đồng sẽ gọi Portfolios.mintfCashPair, đồng thời ghi một khoản nợ cho payer và một khoản quyền đòi nợ tương đương cho receiver. Lệnh gọi này nhìn giống như chuyển ERC1155, nhưng thực chất là đúc một cặp fCash.

Sau khi vị thế mới được ghi vào Portfolio, cần tính toán và kiểm tra tài sản thế chấp tự do. Hợp đồng RiskFramework sẽ trước tiên gộp các khoản nợ fCash của payer thành một số int256 có dấu, sau đó quy đổi số dư của từng loại tiền sang ETH để tổng hợp. Số âm biểu thị lượng tiền cần phải trả, còn số dương biểu thị tài khoản đang có tiền hoặc có quyền đòi nợ. Kết quả tính toán này quyết định giao dịch có được phép thực hiện hay không.

Sau khi vị thế đáo hạn, Portfolio sẽ gọi hàm portfolioSettleCash của Escrow để biến fCash đến hạn thành số dư tiền mặt. Sau đó tài khoản sẽ rút tài sản DAI hoặc USDC tương ứng từ Escrow thông qua hàm rút tiền.

Nguyên nhân gốc rễ

Lỗi cốt lõi của cuộc tấn công lần này nằm ở ExchangeRate._convertToETH mà hợp đồng triển khai Escrow sử dụng, trong đó có một đoạn mã lấy giá trị tuyệt đối của số nguyên có dấu rồi chuyển trực tiếp sang uint128.

Kẻ tấn công trước tiên thiết lập hai vị thế nợ có cùng payer, với số tiền lần lượt là 1 và uint128.max. Sau khi cộng hai khoản nợ này, kết quả đúng bằng 2^128. Do trong tính toán rủi ro, khoản thanh toán của payer được ghi nhận là số âm, nên tham số quan trọng được truyền vào convertBalancesToETH của Escrow sẽ là -2^128.

Tuy nhiên, phiên bản Solidity mà hợp đồng này sử dụng là 0.6.x, nên giá trị tuyệt đối 2^128 sau khi bị ép kiểu thành uint128 sẽ bị tràn thành 0, và khoản nợ này vì thế không được tính vào kết quả định giá ETH.

Cuối cùng trong hàm mintfCashPair chỉ yêu cầu tài sản thế chấp tự do của payer phải lớn hơn hoặc bằng 0, vì vậy sau khi kết quả tràn thành 0 vẫn có thể vượt qua kiểm tra này; một tài khoản không có đủ tài sản để bảo đảm nợ vẫn có thể tạo vị thế.

Phân tích các bước tấn công

1. Trong giao dịch chuẩn bị trước (0xe1589a19…d60a), kẻ tấn công trước tiên tạo nhiều hợp đồng phụ trợ, và gọi hàm setApprovalForAl của hợp đồng ERC1155Trade để cấp quyền cho các hợp đồng phụ trợ; ngoài ra còn truy vấn trước ngày đáo hạn của hai CashMarket, tính sẵn giá trị của ba tham số AssetId cần truyền vào trong các bước tiếp theo.

2. Ngay sau đó, kẻ tấn công thông qua việc gọi hàm safeTransferFrom của hợp đồng ERC1155Trade đã đúc một cặp fCash với số lượng là 1, tương ứng với tài sản trái phiếu có cashGroupId là 2, và ngày đáo hạn là 1788480000 (8 giờ sáng ngày 4 tháng 9 năm 2026). Khi đó sẽ gọi hàm nội bộ _upsertAsset để cập nhật riêng nợ và thu nhập kỳ vọng cho địa chỉ from (hợp đồng tấn công) và địa chỉ to (hợp đồng nhận 1).

Sau khi đúc pair hoàn tất, Portfolios sẽ kiểm tra ngay tài sản thế chấp tự do của hợp đồng tấn công; do số lượng đúc lần đầu quá nhỏ, khi quy đổi sang ETH theo tỷ giá và độ chính xác hiện tại thì sẽ bị làm tròn thành 0, vì vậy lần kiểm tra đầu tiên có thể vượt qua.

3. Sau đó, kẻ tấn công lại gọi hàm safeTransferFrom của hợp đồng ERC1155Trade để đúc một cặp fCash với số lượng là uint128.max; lần này, tài sản trái phiếu tương ứng có cashGroupId là 2, nhưng dấu thời gian ngày đáo hạn lại là 1796256000 (8 giờ sáng ngày 3 tháng 12 năm 2026).

Có một chi tiết ở đây: kẻ tấn công đã đúc cho hai địa chỉ khác nhau các tài sản trái phiếu có ngày đáo hạn khác nhau. Lý do là khi cập nhật nợ cho địa chỉ from, nếu tài sản trái phiếu là giống nhau thì hợp đồng sẽ cộng dồn trực tiếp, dẫn đến lỗi tràn khi xử lý phép cộng và bị revert (hợp đồng dùng thư viện safeMath).

4. Sau khi hoàn tất lần đúc thứ hai và cập nhật nợ cùng thu nhập kỳ vọng cho địa chỉ from (hợp đồng tấn công) và to (hợp đồng nhận 2), hàm mintfCashPair sẽ gọi hàm freeCollateral để tính vị thế tài sản thế chấp ròng của from và kiểm tra rằng giá trị này phải lớn hơn hoặc bằng 0 thì mới được coi là an toàn.

Trong đó, trước tiên trong hàm getRequirement của hợp đồng RiskFrameork sẽ cộng tổng khoản nợ của hai lần đúc đối với địa chỉ from:

Kết quả cuối cùng là -2^128, sau đó sẽ gọi hàm convertBalancesToETH của hợp đồng Escrow để chuyển giá trị này sang định giá ETH; tiếp theo hàm convertBalancesToETH lại gọi hàm _convertToETH của thư viện ExchangeRate để thực hiện tính toán:

Tiếp tục theo dõi trong hàm _convertToETH, có thể thấy hàm này trước tiên lấy giá trị tuyệt đối của balance rồi mới gọi uint128 để chuyển đổi từ 256 bit sang 128 bit; trong khi đó tổng giá trị nợ của from ở trên là -2^128, giá trị tuyệt đối sau khi lấy là 2^128, và khi ép kiểu sang uint128() sẽ dẫn đến vượt quá giới hạn và tràn thành 0. Điều này có nghĩa là giao thức đã hiểu nhầm vị thế tài sản thế chấp tự do của địa chỉ from là 0 nhưng vẫn an toàn, từ đó vượt qua được bước kiểm tra cuối cùng trong hàm mintfCashPair.

5. Sau đó hợp đồng nhận 2 lại tách vị thế của mình cho thêm hai hợp đồng phụ trợ khác; hai lệnh safeTransferFrom này vẫn sẽ đi vào hàm mintfCashPair. Tuy nhiên, vì lúc này payer là hợp đồng nhận 2 của lần đúc thứ hai, và nó đang nắm giữ vị thế thu nhập kỳ vọng rất lớn có được ở bước trước, nên phép tính rủi ro sẽ xem đó là quyền đòi nợ dương và do vậy vượt qua kiểm tra tài sản thế chấp; hai hợp đồng phụ trợ nhờ đó nhận được receiver fCash có thể quy đổi thành tiền mặt sau khi đến hạn.

6. Sau đó kẻ tấn công khởi động giao dịch kiếm lợi nhuận chính thức thứ hai; trong giao dịch này, các hợp đồng phụ trợ của hai vị thế đang nắm giữ từ giao dịch trước sẽ lần lượt thanh toán quyền đòi nợ tương ứng, làm tăng số dư tiền mặt của tài sản tương ứng, rồi sau đó gọi hàm withdraw của hợp đồng Escrow để rút tài sản và thu lợi.

图片

Tóm tắt

Điểm mấu chốt của vụ tấn công này là kẻ tấn công trước tiên lợi dụng hai vị thế riêng biệt để cộng tổng nợ của payer lên thành 2^128, sau đó để Escrow quy đổi khoản nợ này sang ETH. Do lỗi tràn trong ép kiểu mà kết quả bị cắt cụt về 0, kiểm tra rủi ro vì thế đã cho phép đi qua.

Đội ngũ an ninh SlowMist khuyến nghị dự án phải thực hiện kiểm tra phạm vi trước khi ép kiểu; nếu kết quả vượt quá type(uint128).max thì phải từ chối ngay. Đồng thời, tất cả các biên liên quan đến số có dấu, mở rộng độ chính xác và mệnh giá tài sản đều cần bổ sung kiểm thử giá trị cực trị, đặc biệt phải bao phủ các đầu vào như uint128.max, uint128.max + 1 và giá trị tuyệt đối của số âm. Có thể tham khảo hoặc sử dụng thư viện SafeCast của OpenZeppelin để xử lý ép kiểu.