Author: Jiujiu

Edited: 77

Background

On September 4, 2026, the well-known decentralized lending platform Notional Finance was attacked, resulting in a loss of approximately $1.73 million. The following is a detailed analysis of this attack by the SlowMist security team:

Prerequisites

In Notional Finance V1, fCash can be understood as cash debt with a maturity date. A cash receiver collects payment at maturity, while a cash payer makes payment at maturity. The protocol records both positions in the Portfolio, and then determines whether an account is healthy enough to open a position through a free collateral check.

ERC1155Trade's safeTransferFrom takes on the asset accounting role here. When the input asset type belongs to cash receiver, the contract calls Portfolios.mintfCashPair, records a liability for the payer, and records an equal claim for the receiver. What looks like an ERC1155 transfer is actually an fCash pair mint.

After the new position is written to Portfolio, free collateral must be calculated and checked. The RiskFramework contract first aggregates the payer's fCash liabilities into a signed int256, then converts the balances of each currency into ETH for aggregation. Negative values represent cash that needs to be paid, while positive values represent cash or claims owned by the account. This result determines whether the transaction is allowed to proceed.

After a position matures, Portfolio calls Escrow's portfolioSettleCash function to convert the matured fCash into a cash balance. The account then withdraws the corresponding DAI or USDC assets from Escrow through the withdrawal function.

Root cause

The core vulnerability in this attack lies in ExchangeRate._convertToETH used by the Escrow implementation contract, where a piece of code directly converts the absolute value of a signed integer into uint128.

The attacker first created two liability positions with the same payer, with amounts 1 and uint128.max respectively. After adding the two liabilities, the result is exactly 2^128. Since the payer's payment is recorded as a negative number in the risk calculation, the key parameter passed to Escrow's convertBalancesToETH is -2^128.

However, the Solidity version used by this contract is 0.6.x, so after the absolute value 2^128 is force-cast to uint128, it overflows to 0, and this liability is therefore not included in the ETH-denominated result.

Finally, the mintfCashPair function only requires the payer's free collateral to be greater than or equal to zero. Therefore, after the result overflows to 0, this check passes, and an account without sufficient assets to cover its liabilities can still open a position.

Attack step analysis

1. In the pre-transaction (0xe1589a19…d60a), the attacker first created multiple helper contracts and used the setApprovalForAl function of the ERC1155Trade contract to authorize them; in addition, they also queried the maturity dates of two CashMarkets in advance and calculated the values of the three AssetId parameters required for the subsequent operations.

2. Immediately after, the attacker called the ERC1155Trade contract's safeTransferFrom function to mint an fCash pair in the amount of 1, corresponding to a bond asset with cashGroupId 2 and a maturity timestamp of 1788480000 (8:00 AM on September 4, 2026). This calls the internal function _upsertAsset to update the liabilities and expected income of the from address (attack contract) and the to address (receiving contract 1), respectively.

After the pair mint is completed, Portfolios immediately checks the attack contract's free collateral. Because the quantity in the first mint was too small, when converted to ETH under the current exchange rate and precision, it is rounded down to zero, so the first check passes.

3. The attacker then called the ERC1155Trade contract's safeTransferFrom function again to mint an fCash pair in the amount of uint128.max. This time, the bond asset's cashGroupId was 2, but the maturity timestamp was 1796256000 (8:00 AM on December 3, 2026).

There is a detail here: the attacker minted bond assets with different maturity dates to two different addresses. This is because when updating liabilities for the from address, if the bond assets are the same, they will be added directly, which would cause a revert due to overflow during addition (the contract uses the safeMath library).

4. After the second mint updates the liabilities and expected income of the from address (attack contract) and the to address (receiving contract 2), the mintfCashPair function calls the freeCollateral function to calculate the net collateral position of the from address and checks that it must be greater than or equal to 0 to be considered healthy.

It first sums the total liabilities from the from address's two minting operations in the getRequirement function of the RiskFrameork contract:

The final result is -2^128, after which the Escrow contract's convertBalancesToETH function is called to convert this value into an ETH valuation, and convertBalancesToETH in turn calls the ExchangeRate library's _convertToETH function for calculation and processing:

Following the _convertToETH function, it can be seen that it first takes the absolute value of balance and then uses uint128 to convert 256 bits to 128 bits. However, the total liability value of the from address above is -2^128. After taking the absolute value, 2^128 will overflow to 0 when forcibly cast to uint128(). This means the protocol mistakenly believes that the free collateral position of the from address is 0 and therefore healthy, thereby passing the final check in the mintfCashPair function.

5. Afterwards, receiving contract 2 split its position again to two other helper contracts. These two safeTransferFrom calls still enter the mintfCashPair function. However, at this point the payer is receiving contract 2 from the second mint, which holds the large expected-income position obtained in the previous step. The risk calculation treats it as positive credit, thereby passing the collateral check, and the two helper contracts thus receive receiver fCash that can be converted into cash after maturity.

6. The attacker then initiated a second formal profit-taking transaction. In this transaction, they settled the corresponding claims for the helper contracts holding the two positions from the previous transaction, increasing the cash balances of the corresponding assets, and then called the Escrow contract's withdraw function to withdraw the assets and realize the profit.

图片

Summary

The key point of this attack is that the attacker first used two separate positions to make the payer's total liabilities reach 2^128, and then let Escrow convert this liability into ETH. By exploiting an overflow vulnerability in the type conversion and truncating the result to zero, the risk check was bypassed.

The SlowMist security team recommends that project teams must perform range checks before type conversion, and directly reject results exceeding type(uint128).max. At the same time, all boundaries involving signed amounts, precision scaling, and asset denominations should include edge-case tests, especially covering inputs such as uint128.max, uint128.max + 1, and negative absolute values. You may refer to or use OpenZeppelin's SafeCast library for conversion handling.