Root Cause
Notional Finance's free-collateral check contained an unsafe signed-to-unsigned downcast. When computing net collateral, the protocol cast an int256 liability value to uint128. For an attacker-controlled value of -2^128, this downcast silently produces 0, causing the health check to report the position as perfectly solvent despite an enormous fabricated debt.
// Vulnerable pattern (simplified) in free-collateral calculation:
function _getNetCollateral(address account) internal view returns (int256) {
int256 netCashBalance = _sumPositionsAndLiabilities(account);
// BUG: int256(-2^128) cast to uint128 wraps to 0
// A massive negative liability reads as zero debt
return int256(uint128(netCashBalance));
}
// Safe alternative using OpenZeppelin SafeCast:
// import "@openzeppelin/contracts/utils/math/SafeCast.sol";
// int128 safeValue = SafeCast.toInt128(netCashBalance); // reverts on overflow
The attacker triggered this by calling mintfCashPair() twice with crafted arguments that caused the internal liability accumulator to underflow to exactly -2^128.
Attack Steps
| Step | Action | Detail |
|---|---|---|
| 1 | Fund attacker EOA | Funded from 0xC954...De69 |
| 2 | First mintfCashPair() call | Creates initial paired fCash asset + liability |
| 3 | Second mintfCashPair() call | Crafted amounts cause int256 liability to underflow to -2^128 |
| 4 | Collateral check bypassed | uint128(-2^128) == 0; position reports as zero net debt |
| 5 | Borrow/drain DAI | 69,242 DAI drained from escrow contract |
| 6 | Drain USDC | 1,658,423 USDC drained from escrow contract |
| 7 | Swap to ETH | DAI + USDC swapped for 689.2 ETH |
| 8 | Obfuscate | Proceeds routed through Tornado Cash |
Setup tx: 11:58 PM UTC September 3, 2026
Drain confirmed: 12:01 AM UTC September 4, 2026
Attacker address: 0xDaCC...Ce38
Impact
- Total loss: ~$1,730,000
- Assets: 69,242 DAI + 1,658,423 USDC → 689.2 ETH
- Chain: Ethereum mainnet
- Protocol: Notional Finance escrow contract
- Date: September 3–4, 2026
Lessons for Auditors
- Never downcast signed integers to unsigned types in financial arithmetic.
int256 → uint128or similar conversions silently discard sign information for large negative values, turning debt into apparent collateral. - Use
SafeCast(OpenZeppelin) or equivalent everywhere.SafeCast.toInt128(x)reverts on overflow; the raw cast does not. - Fuzz collateral calculations with extreme boundary values. A fuzz suite targeting
mintfCashPair()with inputs near±2^127and±2^128would surface this class of bug before deployment. - The
mintfCashPair()function should enforce that aggregate liability positions remain within safe int128/uint128 ranges before updating state. - Invariant testing: assert that the sum of all fCash liabilities across the protocol is always
>= 0after any state-changing call. A negative aggregate is a red flag for overflow exploitation.