Root Cause
Notional Finance V1's free-collateral calculation performed an unchecked narrowing cast from a signed integer to uint128 in Solidity 0.6.x — a compiler version that does not provide automatic overflow protection. If an account's absolute debt equalled exactly 2¹²⁸, the cast silently truncated to zero, making the protocol treat a massively under-collateralised account as fully collateralised.
// Solidity ^0.6.x — no SafeCast, no checked arithmetic
function getFreeCollateral(address account) internal view returns (int256) {
int256 netDebt = _getNetDebt(account); // may be large negative value
// VULNERABILITY: abs(-2^128) == 2^128; uint128(2^128) overflows to 0
uint128 debtMagnitude = uint128(netDebt.abs());
int256 collateralValue = _getCollateral(account);
// With debtMagnitude == 0, this always returns collateralValue >= 0
return collateralValue - int256(debtMagnitude);
}
// Collateral check passes even with 2^128 real debt outstanding
require(getFreeCollateral(account) >= 0, "Undercollateralized");
Attack Steps
| # | Step |
|---|---|
| 1 | Attacker opens fCash debt position of size 1 in payer account |
| 2 | Attacker opens second fCash debt of size uint128.max in the same payer account |
| 3 | Combined absolute debt = 2¹²⁸; uint128(2^128) truncates to 0 |
| 4 | Free-collateral check passes (collateral − 0 ≥ 0) despite massive real debt |
| 5 | Attacker transfers the unbacked collateral position to a second account |
| 6 | Second account borrows against the phantom collateral, draining the escrow |
| 7 | ~$1.73 M (≈ 1.66 M USDC + 69 K DAI) swapped to ~689 ETH and laundered via Tornado Cash |
Impact
- ~$1.73 M drained from the legacy V1 Escrow on Ethereum (Sept 3–4, 2026)
- Protocol team paused the V1 contract immediately after detection, protecting ~$60 K remaining
- V2 was not affected; the bug exists only in the legacy V1 codebase
- SlowMist analysis dubbed the attack "The Vanishing Debt"
Lessons for Auditors
- Use
SafeCastor Solidity ≥0.8 for all type-narrowing casts.uint128(x)in 0.6.x is a silent truncation; replace withSafeCast.toUint128(x)which reverts on overflow. - Fuzz collateral calculations with boundary values. Inputs of
uint128.max,int128.min, and multiples of 2¹²⁸ would surface this class of bug in minutes. - Audit dormant legacy contracts that still hold live funds. V1 was superseded but never drained; $1.7 M in unreviewed legacy code is a standing invitation to attackers.
- Verify arithmetic invariants across coordinated positions. A property test asserting that the sum of all fCash pair debts equals net zero for any combination of positions would have caught the overflow path.