Tác giả: Jiu Jiu
Chỉnh sửa: 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, gây thiệt hại khoảng 1,73 triệu USD. Dưới đây là phân tích cụ thể từ nhóm an toàn SlowMist về sự kiện tấn công này:
Kiến thức nền
Trong Notional Finance V1, fCash có thể được hiểu là quyền đòi nợ tiền mặt có ngày đáo hạn. Người nhận tiền mặt sẽ nhận thanh toán vào ngày đáo hạn, còn người trả tiền mặt sẽ thanh toán vào ngày đó. Giao thức ghi nhận cả hai vị thế này vào Portfolio, sau đó đánh giá mức độ lành mạnh của một tài khoản trong việc thiết lập vị thế thông qua kiểm tra tài sản thế chấp tự do.
Trong ERC1155Trade, hàm safeTransferFrom đảm nhận chức năng ghi chép tài sản. Khi loại tài sản được truyền vào thuộc về 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 tín dụng tương đương cho receiver. Cuộc gọi này trông giống như một giao dịch ERC1155, nhưng thực chất là một lần tạo cặp fCash.
Sau khi vị thế mới được ghi vào Portfolio, cần thực hiện tính toán và kiểm tra tài sản thế chấp tự do. Hợp đồng RiskFramework sẽ tổng hợp nợ fCash của người trả thành một số nguyên int256 có dấu, sau đó chuyển đổi số dư của từng đồng tiền thành ETH để tổng hợp. Số âm đại diện cho số tiền cần thanh toán, số dương đại diện cho số tiền hoặc quyền đòi nợ mà tài khoản đang sở hữu. 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ế hết hạn, Portfolio sẽ gọi hàm portfolioSettleCash của Escrow để chuyển fCash hết hạn thành số dư tiền mặt. Tài khoản sau đó sẽ rút các tài sản DAI hoặc USDC tương ứng thông qua hàm rút tiền từ Escrow.
Nguyên nhân cốt lõi
Lỗ hổng chính trong cuộc tấn công này nằm ở hàm ExchangeRate._convertToETH được sử dụng trong hợp đồng Escrow, nơi có một đoạn mã chuyển trực tiếp giá trị tuyệt đối của số nguyên có dấu thành uint128.

Kẻ tấn công đã tạo trước hai vị thế nợ có cùng payer, với số tiền lần lượt là 1 và uint128.max. Tổng hai vị thế nợ này đúng bằng 2^128. Do thanh toán của payer trong tính toán rủi ro được ghi nhận là số âm, nên tham số quan trọng được truyền vào convertBalancesToETH của Escrow là -2^128.
Tuy nhiên, phiên bản Solidity được sử dụng trong hợp đồng này là 0.6.x, do đó giá trị tuyệt đối 2^128 khi được ép kiểu thành uint128 sẽ tràn thành 0, khoản nợ này do đó không được tính vào kết quả định giá bằng ETH.
Cuối cùng, trong hàm mintfCashPair, chỉ yêu cầu tài sản tự do của người trả lớn hơn hoặc bằng 0, do đó sau khi tràn kết quả về 0, giao dịch vẫn vượt qua kiểm tra này, cho phép một tài khoản không có đủ tài sản để bao phủ nợ vẫn có thể mở vị thế.
Phân tích các bước tấn công
1. Trong giao dịch trước (0xe1589a19…d60a), kẻ tấn công trước tiên đã tạo ra nhiều hợp đồng phụ và gọi hàm setApprovalForAll của hợp đồng ERC1155Trade để cấp quyền cho các hợp đồng phụ; đồng thời cũng tra cứu trước ngày đáo hạn của hai CashMarket, tính toán giá trị của ba tham số AssetId cần truyền vào trong các thao tác tiếp theo.

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

Sau khi đúc cặp hoàn tất, Portfolios sẽ lập tức kiểm tra tài sản thế chấp tự do của hợp đồng tấn công, và do số lượng đúc lần đầu quá nhỏ, khi chuyển đổi thành ETH theo tỷ giá và độ chính xác hiện tại sẽ được làm tròn thành zero, nên lần kiểm tra đầu tiên có thể vượt qua.
3. Sau đó, kẻ tấn công gọi lại hàm safeTransferFrom của hợp đồng ERC1155Trade để tạo ra một cặp fCash với số lượng là uint128.max, lần này cashGroupId của tài sản trái phiếu là 2, nhưng thời gian đáo hạn là 1796256000 (8:00 ngày 3 tháng 12 năm 2026).
Có một chi tiết nhỏ: kẻ tấn công đã đúc các tài sản trái phiếu với ngày đáo hạn khác nhau vào hai địa chỉ khác nhau. Điều này là vì khi cập nhật nợ cho địa chỉ từ, nếu tài sản trái phiếu giống nhau thì hệ thống sẽ cộng dồn trực tiếp, dẫn đến việc bị revert khi xử lý phép cộng do tràn (hợp đồng sử dụng thư viện SafeMath).

4. Sau khi cập nhật nợ và thu nhập dự kiến cho địa chỉ from (hợp đồng tấn công) và địa chỉ to (hợp đồng nhận 2), hàm mintfCashPair sẽ gọi hàm freeCollateral để tính toán vị thế tài sản đảm bảo ròng của địa chỉ from và kiểm tra xem nó có lớn hơn hoặc bằng 0 không để đảm bảo tính lành mạnh.

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

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 đổi giá trị này sang đơn vị ETH, và hàm convertBalancesToETH lại gọi hàm _convertToETH của thư viện ExchangeRate để thực hiện tính toán:


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

5. Sau đó, hợp đồng 2 nhận hợp đồng và chia vị thế của nó cho hai hợp đồng phụ khác, hai hàm safeTransferFrom này vẫn sẽ đi vào hàm mintfCashPair. Tuy nhiên, do lúc này payer là hợp đồng 2 nhận trong lần đúc thứ hai, hợp đồng này đang nắm giữ vị thế thu nhập kỳ vọng lớn từ bước trước, việc tính toán rủi ro coi nó là khoản nợ có lợi nên đã vượt qua kiểm tra tài sản đảm bảo, do đó hai hợp đồng phụ nhận được receiver fCash có thể đổi thành tiền mặt sau khi đáo hạn.

6. Sau đó, kẻ tấn công thực hiện một giao dịch lợi nhuận chính thức thứ hai, trong đó thanh toán các khoản nợ tương ứng cho hai vị thế giữ của giao dịch trước đó, tăng số dư tiền mặt của các tài sản tương ứng, sau đó gọi hàm withdraw của hợp đồng Escrow để rút tài sản và chốt lời.
Summary
Điểm then chốt của sự cố tấn công này là kẻ tấn công trước tiên sử dụng hai vị thế riêng biệt để tổng nợ của payer đạt đến 2^128, sau đó khiến Escrow chuyển đổi khoản nợ này thành ETH. Bằng cách khai thác lỗ hổng tràn trong chuyển đổi kiểu dữ liệu, kết quả bị cắt cụt thành không, khiến các kiểm tra rủi ro bỏ qua và cho phép giao dịch.
Đội ngũ an toàn SlowMist khuyến nghị các dự án phải thực hiện kiểm tra phạm vi trước khi thực hiện chuyển đổi kiểu dữ liệu, từ chối trực tiếp các kết quả vượt quá type(uint128).max. Đồng thời, tất cả các giới hạn liên quan đến số tiền có dấu, tỷ lệ độ chính xác và mệnh giá tài sản đều nên được kiểm tra với các giá trị cực hạn, đặc biệt bao gồm 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ý chuyển đổi.
