Back to Blog

~$9.4M Lost: Injective, Aquifer Exploits | BlockSec Weekly

Code Auditing
September 11, 2026
27 min read
Key Insights
  • Four incidents this week caused approximately $9.4M in losses across Injective, Solana, Ethereum, and Flow EVM.

  • The Injective, Aquifer and Notional Finance exploits needed no price manipulation and no flash loan. Each one simply made the protocol's own accounting produce a number that was never true: on Injective, an insurance fund whose identifier collided with a market's settled a fabricated 12,744 USDC shortfall with an unchecked balance in a different token worth a fraction of a cent, and on Notional Finance a debt of exactly -2^128 was valued at zero.

  • In three of the four cases the protocol had already written the correct check, just not on the path that mattered. Aquifer shipped a Token Program allowlist that its swap entry point never called, Ankr FLOW guarded one staking entry point with a pause modifier and left its sibling open, and Notional Finance used a checked cast for one conversion in a function while the conversion above it stayed a raw cast.

During the past week (2026/08/31 - 2026/09/06), we observed 4 security incidents with a total estimated loss of approximately $9.4M.

Date Incident Type Estimated Loss
2026/08/31 Ankr FLOW Flawed State Validation ~$410K
2026/08/31 Aquifer Flawed Input Validation ~$2.47M
2026/08/31 Injective Missing Denomination Validation ~$4.8M
2026/09/03 Notional Finance Unsafe Cast ~$1.73M

Best Security Auditor for Web3

Validate design, code, and business logic before launch

Weekly Highlight: Injective

This incident is the highlight of the week for the complexity of its attack chain and the size of the loss: two separate defects had to line up before a single settlement would pay out. Both sat in the chain's own exchange logic rather than in an application contract. It shows how an identifier assembled from concatenated fields without separators can silently merge two objects that were never meant to be related, and how much that merger costs when the settlement path never checks that a fund holds the denomination of the market it backs.

On 2026/08/31, the binary options logic inside Injective's exchange module was exploited for approximately $4.8M in USDC. Injective is a layer-1 chain that builds an order book exchange into the chain itself, so the affected code is part of the node software rather than a contract someone deployed. An insurance fund holding INJ, Injective's native token, ended up bound to a binary options market quoted in USDC because the two identifiers collided, and the settlement path that draws on an insurance fund never compared the two. The attacker traded against their own subaccounts inside such a market to manufacture a shortfall, which the protocol then covered with an INJ balance worth a fraction of a cent. Every position was refunded in full and the attacker withdrew far more than they had deposited.

Background

Injective's exchange module lists binary options markets, which are fully collateralized bets on a yes-or-no outcome. Anyone can list one by paying a listing fee, choosing the oracle that will resolve it and the timestamps at which it expires and settles. The oracle is named as a provider plus a symbol: becoming a provider takes a governance vote, while the symbol is any string the lister supplies. A trader first deposits quote tokens such as USDC into a subaccount, then takes a side by placing an order. A BUY bets that the event happens and locks P * Q as margin; a SELL bets that it does not and locks (1 - P) * Q, where Q is the quantity in contracts and P is the entry price in the range [0, 1]. The side betting on the more likely outcome therefore posts the larger margin. The EndBlocker, the hook the chain runs at the end of every block, matches a BUY and a SELL at the same price into a LONG and a SHORT position. Because the two locks always sum to exactly Q, a freshly matched book is fully funded by construction.

At expiration the oracle publishes a settlement price S in [0, 1] and each position is paid margin ± (S - entry) * Q out of the market pool. Payments between participants are strictly zero-sum, and each payout is floored at zero, so a position can never go negative and never needs liquidation. A position can also be closed early by placing an opposite order with margin = 0, which releases its margin plus its realized profit, paid out of the margin that the incoming opener locks. Each position's margin stays at what it locked on entry, so after an early close the margins on the books need no longer add up to the pool.

A market whose symbol no provider publishes reaches settlement with no price at all. The module has a fallback for that case: settlement falls into a refund path implemented by getBinaryOptionsSocializedLossDataWithRefundFlag(), which unwinds the market instead of resolving it. Every position is simply refunded its margin, because the path closes each one at its own entry price, so no position books a profit or a loss, as long as the book still holds what the positions claim. The refunds are funded by the market balance and, for any shortfall, by the insurance fund associated with that market.

A market and an insurance fund are separate objects, each created by its own message and each carrying a denomination: a market is quoted in one token, and a fund holds the token it was created with. They are paired by identity: a fund backs the market whose ID equals its own. Both IDs are keccak256 digests of identity fields. The exchange module itself is a single omnibus bank account whose per-market and per-fund balances are raw integer bookkeeping.

Vulnerability Analysis

The buggy component is the binary options handling in the exchange module of injective-core, remediated in commit b994d6b6 [1]. Two chained defects allow a fund holding one denomination to back a market quoted in another.

Defect 1: identifiers are derived from an unseparated concatenation. NewBinaryOptionsMarketID(), which computes a market's ID when that market is launched, and CreateInsuranceFund(), which computes the ID of the market a new fund is meant to back (for expiry = BinaryOptionsExpiryFlag = -2), both derive that ID from the same expression:

return crypto.Keccak256Hash([]byte((BINARY_OPTIONS_MARKET_ID_PREFIX +
    oracleType.String() + ticker + quoteDenom + oracleSymbol + oracleProvider)))

There are no field separators and no length prefixes, so the boundaries between fields leave no trace in the hashed bytes. CreateInsuranceFund() maps an insurance fund's own fields onto these slots, with oracle_base occupying the oracleSymbol slot and oracle_quote the oracleProvider slot. A fund tuple and a market tuple can therefore produce byte-identical preimages while splitting those bytes across fields differently, and the two objects then share one ID. Registration accepts that shared ID as the link between them, so a fund can become the insurance fund of a market whose quoteDenom it does not hold.

Defect 2: payouts are never checked against the market's denomination. PayDeficitFromInsuranceFund() moves raw coins out of the fund in the denomination the fund holds, then credits the same raw integer to the market balance, without ever comparing insuranceFund.DepositDenom against the market's quote denom. Since the module's bookkeeping is plain integers, one raw unit of INJ and one raw unit of USDC are indistinguishable on this path, even though the same integer stands for values that differ by a factor on the order of 10^11. The remediation commit adds the check this path was missing, on both the inflow and the outflow side, so a fund can only back markets quoted in the denomination it holds:

The same commit also hard-disables binary options trading and settlement on Injective mainnet, removing the refund path as an attack surface.

Attack Analysis

All steps were executed by a single wallet through three of its own subaccounts (...037c, ...037d and ...037e), with Q = 15,930.

The colliding pair was constructed by shifting where the field boundaries fall while keeping the concatenated bytes identical. The fund's ticker and quoteDenom (X and inj) spell the market's ticker Xinj, and the fund's oracle_base (a contract address and an oracle symbol glued together) spells the market's quoteDenom followed by its oracleSymbol:

Slot in the concatenation Fund (MsgCreateInsuranceFund) Market (MsgInstantBinaryOptionsMarketLaunch)
prefix -BINARY-OPTIONS-MARKET- -BINARY-OPTIONS-MARKET-
oracleType.String() Provider Provider
ticker X Xinj
quoteDenom inj erc20:0xa00C...235a
oracleSymbol erc20:0xa00C...235aNO_PRICE_FOR_REFUND...297, filled from the fund's oracle_base with both parts packed into that one field NO_PRICE_FOR_REFUND...297
oracleProvider Frontrunner, filled from the fund's oracle_quote Frontrunner

Both resolve to 0x9793a39f82993cdedb0c82c614102d5aba2451f29709dc9a6f7df191020b4efc. The oracle named NO_PRICE_FOR_REFUND was configured never to publish a price, which forces settlement onto the refund path.

The following analysis is based on the transaction 0x6ae9cb...51dcf8.

  • Step 1: In one atomic transaction at block 181024772, the attacker created the colliding INJ-denominated insurance fund and USDC-denominated binary options market, seeded the fund with 12,744,000,000 raw INJ (worth about $0.000000063), and deposited 30,267.02 USDC across the three subaccounts. The dust deposit was sized so that its raw integer matches the deficit the attacker planned to manufacture. Each subaccount received exactly the margin it would later need:
Subaccount Deposit (USDC) Margin it funds
037d 1,593.001593 BUY of 15,930 at 0.10, locking 1,593 (Step 2)
037c 14,337.001593 SELL of 15,930 at 0.10, locking 14,337 (Step 2)
037e 14,337.014337 BUY of 15,930 at 0.90, locking 14,337 (Step 3)
Total 30,267.017523 -
  • Step 2: In the same transaction, 037d placed a BUY of 15,930 at 0.10 and 037c placed a SELL of 15,930 at 0.10. The EndBlocker matched them into a LONG for 037d carrying 1,593 of margin and a SHORT for 037c carrying 14,337. Total margin on the books is 1.0Q = 15,930, exactly what the market pool holds, so the book is indistinguishable from any normal fully collateralized market.

  • Step 3: Two blocks and 1.1 seconds later, in transaction 0x012c17...2af694 at block 181024774, 037d closed its long at 0.90 with margin = 0 and received 1,593 + (0.90 - 0.10) * 15,930 = 14,337. That payout came out of the margin locked by the incoming opener 037e, which placed a BUY of 15,930 at 0.90 and locked 14,337. The realized profit of 0.8Q = 12,744 now sits in 037d's available balance outside the market pool, while both remaining position margins stay on the books: liabilities read 1.8Q = 28,674 against a pool that still holds 1.0Q.

  • Step 4: Settlement is driven by the market's own clock, not by a transaction. At the start of every block the module picks up any market whose settlement timestamp has passed and settles it as a whole rather than position by position. This one fired 18 seconds after market creation, with both positions still on the books: 037c's SHORT and 037e's LONG, 14,337 of margin each. The oracle stayed silent, so the refund path computed liabilities of 1.8Q = 28,674 against idealized assets of 1.0Q = 15,930 and reported a deficit of 0.8Q = 12,744. That deficit exists only inside the refund path's accounting. Under a single price S, each side's payout depends only on S and not on where it entered: the short takes (1 - S) * Q and the long S * Q, summing to exactly the Q in the pool. The refund path pays on each side's own entry price instead, so the entries do not cancel: the short was paid (1 - 0.10) * Q and the long 0.90 * Q, 14,337 each. The short collected its full margin as if the price had never moved off 0.10, the same 0.8Q the attacker had already withdrawn in Step 3.

  • Step 5: PayDeficitFromInsuranceFund() cleared the deficit by moving 12,744,000,000 raw INJ out of the colliding fund and crediting 12,744 USDC to the market pool. With the deficit reported as covered, the socialized-loss haircut across the remaining positions was skipped and every position was refunded its full margin. The deficit itself costs nobody anything: it is met from the market's insurance fund or, failing that, by the haircut. What made this one profitable is that the fund bound to the market held INJ dust rather than USDC.

  • Step 6: In transaction 0xcb33ad...152eff at block 181024803, the attacker withdrew 43,010,985,663 raw USDC, or 43,010.99 USDC, against the 30,267.02 USDC deposited. The net gain is 12,743.97 USDC, roughly 21 seconds from the first transaction to the last.

The cycle above is one representative round. The attacker repeated it across 299 short-lived binary options markets created over a 19-hour window, each tied to an oracle configured never to post a price and each with expiration and settlement timestamps seconds apart [2]. The net gains from those rounds add up to the approximately $4.8M lost in the incident.

Conclusion

This incident combines an identifier collision with a missing denomination check on the payout path. A fund is meant to back the market whose identity it matches. But an ID built by joining identity fields end to end no longer records where one field stops and the next begins, so two different sets of fields can yield the same ID, and the pairing binds a fund to a market it does not actually match. Nothing downstream catches the mismatch, because the path that draws on an insurance fund to cover a market deficit compares amounts and never denominations. The attacker used the two defects together to manufacture a phantom deficit inside a market they controlled, settle it with a fund balance worth a fraction of a cent, and walk away with a full refund of margins that the pool never held.

More generally, an identifier that carries meaning should be derived from a structure-preserving encoding, with explicit separators or length prefixes on every variable-length field, so that two distinct field tuples cannot map to the same digest.

Get Started with Phalcon Explorer

Dive into Transactions to Act Wisely

Try now for free

More Incidents This Week

Ankr FLOW

On 2026/08/31, Ankr's liquid staking service on Flow EVM was exploited. Ankr issues two different tokens against staked FLOW, the network's native token, and each is minted through its own entry point. One of those paths had been switched off, but a second way into it skipped the check that enforced the pause, and the conversion rate on that path had gone stale in the meantime. The attacker minted through it far more cheaply than the same tokens could be redeemed for, then cycled the gap through Ankr's redemption buffer, a Uniswap V3 pool, and the MORE Markets lending protocol. About 15.5M WFLOW (wrapped FLOW), worth roughly $410K at the time, was drained from the MORE Markets reserve, and the attacker realized about $246K after slippage [3].

Background

Ankr FLOW is a liquid staking service on Flow EVM. FlowStakingPool forwards FLOW to Cadence for validator staking and represents the resulting positions through two tokens: the non-rebasing certificate token ankrFLOW, and the rebasing bearing token aFLOWEVMb, which is itself backed by ankrFLOW.

The two tokens have separate entry points. The certificate path runs through stakeCerts() and unstakeCerts() into _stakeCerts() and _unstakeCertsFor(); the bearing path runs through stakeBonds() and unstakeBonds() into _stakeBonds() and _unstakeBondsFor(). Minting on the bearing path has a second external entry point, stakeBondsWithCode(), which takes a partner code for Ankr's referral program and then calls the same internal _stakeBonds(). Each path reads its own entry from the InternetBondRatioFeed, the contract that publishes the conversion ratio between FLOW and each token.

FlowStakingPool also holds a FLOW buffer for immediate redemptions. Beyond the pool, ankrFLOW traded in a Uniswap V3 ankrFLOW/WFLOW pool and was accepted as collateral on MORE Markets, an Aave V3 style lending protocol. MORE Markets caps borrowing at a loan-to-value (LTV) ratio set per asset and offers e-mode (efficiency mode) categories: groups of assets expected to move in price together, for which a higher LTV applies once a borrower enables the category.

Vulnerability Analysis

The buggy contract is FlowStakingPool (0xfe81...287a), which mints the certificate token ankrFLOW (0x1b97...14bdb) and the bearing token aFLOWEVMb (0xd6fd...f8d4a) against ratios read from the InternetBondRatioFeed (0x3201...de38f).

Two defects overlap. First, stakeBondsWithCode() reaches _stakeBonds() without the bondStakingUnpaused modifier that stakeBonds() enforces, so the bearing token path stayed reachable after it was disabled. Second, across 71 weekly ratio update batches between April 29, 2025 and August 27, 2026, only the active ankrFLOW entry was refreshed, leaving the aFLOWEVMb entry at 1.0.

The two ratios therefore priced the same underlying stake differently:

Direction Functions Ratio Conversion
Mint _stakeCerts() / stakeCerts() 0.833437 1 FLOW to 0.833437 ankrFLOW
Mint _stakeBonds() / stakeBondsWithCode() 1.0 1 FLOW to 1 aFLOWEVMb
Redeem _unstakeCertsFor() / unstakeCerts() 0.833437 1 ankrFLOW to ~1.19985 FLOW
Redeem _unstakeBondsFor() / unstakeBonds() 1.0 1 aFLOWEVMb to 1 FLOW

Because aFLOWEVMb is backed by ankrFLOW, minting through the bearing path produced one backed ankrFLOW per FLOW deposited, while the certificate path produced 0.833437. Redemption through the certificate path still paid ~1.19985 FLOW per ankrFLOW.

Attack Analysis

The following analysis is based on the transaction 0x2b2e6e...3f66c9.

  • Step 1: The attacker bootstrapped capital with one round trip between the two paths. They flash loaned 5,000 ankrFLOW from the Uniswap V3 ankrFLOW/WFLOW pool, redeemed it through unstakeCerts() for ~5,999.25 FLOW, deposited 5,000.50 FLOW through stakeBondsWithCode() to mint 5,000.50 aFLOWEVMb backed by the same amount of ankrFLOW, and called unlockShares() to release that ankrFLOW to repay the loan and its 0.50 ankrFLOW premium. About 998.75 FLOW remained as working capital.

  • Step 2: The attacker repeated a Uniswap V3 swap 50 times. Each cycle swapped ankrFLOW for WFLOW with a price limit, minted the ankrFLOW owed to the pool inside the swap callback by routing FLOW through stakeBondsWithCode() and unlockShares(), then unwrapped the WFLOW output for the next cycle. Across the 50 cycles the pool received ~38,634,755.38 ankrFLOW and paid out ~46,265,167.78 WFLOW, raising the attacker's balance from ~998.75 FLOW to ~7,631,411.14 FLOW.

  • Step 3: The attacker converted ~38,601.95 FLOW through the bearing path and redeemed the resulting ankrFLOW through unstakeCerts(), draining the ~46,316.57 FLOW redemption buffer that FlowStakingPool still held and adding ~7,714.62 FLOW.

  • Step 4: The attacker turned to MORE Markets. They enabled e-mode category 1 ("Wrapped native tokens"), which treated ankrFLOW and WFLOW as correlated FLOW assets and raised the ankrFLOW LTV from 78.5% to 97%. They deposited ~7,639,125.76 FLOW through stakeBondsWithCode(), unlocked the matching ankrFLOW, supplied it as collateral, and borrowed ~5,668,483.10 WFLOW. Unwrapping that borrow and sending it back through the same path (a single round of loop borrowing) added an equal amount of ankrFLOW, bringing collateral to ~13,307,608.86 ankrFLOW and supporting a second borrow of ~9,819,641.05 WFLOW, for a total debt of ~15,488,124.15 WFLOW, about 97% of the collateral value at the ~1.19985 WFLOW oracle ratio.

Step 4's collateral returned more than it cost to mint: one FLOW minted one ankrFLOW on the bearing path, the oracle valued that ankrFLOW at ~1.19985 WFLOW, and e-mode allowed borrowing 97% against it, so the ~13.31M ankrFLOW supplied supported ~15.49M WFLOW of debt, ~1.16 WFLOW for every FLOW put in. The attacker recycled only once because the second borrow left the WFLOW reserve empty.

That ~15.49M WFLOW is gross borrowing, and it is the 15.5M WFLOW reported as drained from the MORE Markets reserve. About 5.67M WFLOW of it was unwrapped and recycled into additional collateral rather than kept as liquid proceeds; once the second borrow of ~9,819,641.05 WFLOW was unwrapped, the attacker held ~9,819,641.05 FLOW as the cash-out. Attributed by where the value came from, ~7,630,412.39 FLOW of that cash-out came from the Uniswap V3 pool, ~8,713.37 FLOW from FlowStakingPool, and ~2,180,515.29 FLOW from MORE Markets.

Conclusion

A state check that guards one entry point but not its sibling is the root cause here: a mint path that had been disabled stayed callable, and its ratio went unrefreshed across sixteen months of weekly updates. Every FLOW routed through it therefore produced ankrFLOW at a price the certificate path would never have offered, and the attacker cycled that mismatch through a Uniswap V3 pool, the staking pool's redemption buffer, and a lending market, converting cheap ankrFLOW into WFLOW liquidity at each stop.

Pause guards must be enforced at every entry point that reaches the disabled logic, not only at the one that is expected to be called, and a ratio feed serving a dormant path should either be kept current or made to revert. Lending protocols should also avoid granting a high e-mode LTV to a liquid staking token whose minting price is set independently of its oracle price; monitoring mint cost, redemption value, and oracle price together would surface that divergence.


Aquifer

On 2026/08/31, Aquifer, a proprietary market maker AMM on Solana, was exploited for approximately $2.47M across 212 successful swaps, spread over USDC, USDT, HYPE, cbBTC, CASH and thirteen other tokens [4]. Each swap settles as two token transfers, one in each direction, and Aquifer let the caller choose which program would carry out each of them without ever checking that choice. The attacker named a program of their own for the transfer that should have paid Aquifer, and it reported success while moving nothing; the transfer going the other way ran through the genuine Token Program and delivered real assets out of Aquifer's vaults.

Background

Aquifer is a Prop AMM, meaning a professional market maker supplies its own inventory and maintains bid and ask quotes, rather than pricing swaps along a constant product curve as Uniswap V2 style pools do. Aquifer derives a price from its quote and risk state, then settles the trade between the user's Token Accounts and its own vaults.

On Solana, a Token Program is an executable program that implements operations such as transfer, mint and burn. Tokenkeg is the original SPL Token Program and Token-2022 is its extensible successor; each one manages many different tokens rather than a single asset. A Mint Account identifies one token type and stores its supply, decimals and authorities, while a Token Account stores one holder's balance for one Mint; both are owned by the Token Program that manages them. Aquifer's vaults are Token Accounts controlled by Aquifer PDAs.

A trader trades by invoking Aquifer's swap instruction and passing in the accounts it will touch, including, for each of the two transfers, the Token Program that should execute it. Aquifer moves those tokens through cross-program invocation (CPI): it builds a transfer instruction and hands it to the program named by Instruction.program_id. Transfer-shaped instruction data moves real SPL tokens only when the correct Token Program executes it.

Vulnerability Analysis

The buggy program is Aquifer (AQU1FR...Tz45). No published source matches its deployed bytecode, so the analysis below is recovered from the program's disassembly: fn_ names label internal functions by their code offsets, and no name used here, swap included, comes from the developers. The program contains an allowlist function, fn_49740(), that accepts only Tokenkeg and Token-2022, but nothing on the swap path calls it. When handling a swap, the program builds both transfer instructions through fn_45f20() and fn_46bf8() using a hardcoded Tokenkeg constant, which necessarily satisfies their internal check, and then overwrites each instruction's program_id with the Token Program supplied by the caller before invoking it:

// Semantic reconstruction of the code the program runs for a swap; construct_transfer()
// stands for fn_45f20() and fn_46bf8(), and the allowlist fn_49740() is never reached.
let checked_program = TOKENKEG_ID;

let mut output_instruction = construct_transfer(checked_program, output_accounts);
let mut input_instruction = construct_transfer(checked_program, input_accounts);

// Unchecked caller inputs replace the value that was checked.
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;

// Each invoke() hands the transfer instruction to whichever program the caller named.
invoke(output_instruction)?;
invoke(input_instruction)?;

Here TOKENKEG_ID denotes TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. The value that passes validation is therefore never the value that executes. Compounding this, the program does not verify the vault's received amount after the input CPI returns, so a CPI that succeeds without transferring anything is accepted as payment.

Attack Analysis

The incident consists of 212 successful attack transactions. The following analysis is based on the transaction 4pBV1G...T3bf, one representative example.

  • Step 1: The attacker invoked swap with a nominal input of 4,957.497101 USDC, for which Aquifer computed an output of 195,849.433667 KMNO. For the KMNO leg they supplied Tokenkeg; for the USDC leg they supplied their own program DMBpPM...NRgb68 together with a fake input account 9gsKJc..., an account owned by that program whose 165 bytes imitate an SPL Token Account carrying the USDC Mint, the attacker's authority, and a u64::MAX balance.

  • Step 2: The output CPI reached Tokenkeg and transferred 195,849.433667 KMNO out of the Aquifer KMNO vault to the attacker's Token Account EUjkGc..., which is controlled by signer 7fTe9p...4gRk7J.

  • Step 3: The input CPI reached DMBpPM...NRgb68 with USDC transfer-shaped data. That program returned success without moving any USDC from the fake source 9gsKJc... into the genuine Aquifer USDC vault 7ULN1Y....
  • Step 4: Aquifer accepted both CPI returns, so the swap committed atomically.

The resulting balance changes for this transaction are as follows.

Account Before After Change
Aquifer KMNO vault 9BHsZp...FHSqG 604,968.018277 KMNO 409,118.584610 KMNO -195,849.433667 KMNO
Attacker KMNO account EUjkGc... 0 KMNO 195,849.433667 KMNO +195,849.433667 KMNO
Aquifer USDC vault 7ULN1Y... 1,620,342.341679 USDC 1,620,342.341679 USDC 0 USDC

These figures describe this example transaction only, not the incident's total loss.

Conclusion

The root cause is an unvalidated caller input on the settlement path, not price or oracle manipulation: the swap path validated a Token Program constant and then replaced it with a caller-supplied one before invoking the instruction, so which program executed the transfer that should have paid Aquifer was the caller's choice. With no post-transfer balance check on the vault, a program that returns success without moving tokens satisfies the payment while the transfer in the other direction delivers real assets.

Each CPI should be bound to the Token Program that owns the Mint being moved, resolved from the Mint Account rather than taken from caller-supplied accounts, and the vault's balance should be read before and after the input transfer so that the swap reverts unless the vault actually received the quoted amount.


Notional Finance

On 2026/09/03-09/04 (UTC), Notional Finance V1 on Ethereum was exploited for approximately $1.73M, drained as 69,257.37 DAI and 1,658,524.86 USDC. Before letting an account take on debt, the protocol values everything that account holds and owes, and an unsafe numeric conversion on that path collapsed a debt of the right size to zero. The check therefore let through an account whose liability had vanished, while the large claim it had created sat intact on another contract the attacker controlled. The attacker had timed that forged claim to come due at the protocol's next maturity, midnight UTC, and settled it minutes later to withdraw the DAI and USDC the protocol still held.

Background

Notional Finance V1 is a fixed-rate lending protocol on Ethereum. It represents cash flows at predefined maturities with fCash: a CASH_RECEIVER is a positive position entitled to receive assets at maturity, and a CASH_PAYER is a negative position obligated to pay them. Each account's fCash and other positions are tracked in its Portfolio, where an asset is identified by its cash group together with its maturity; the cash group fixes the currency it settles in. A single asset's notional, the amount it settles for at maturity, is a uint128.

ERC1155Trade.safeTransferFrom() creates an fCash pair between two accounts. The call is shaped like an ERC-1155 transfer, but nothing changes hands: it calls Portfolios.mintfCashPair() to create two offsetting positions, a positive one for the receiver and an equal negative one for the payer. Each side is written into the corresponding Portfolio by _upsertAsset(), which folds a new position into an existing entry only when the cash group and the maturity both match, adding the two notionals through a SafeUInt128-checked addition.

Solvency is checked on the payer through freeCollateral(), which combines the account's Escrow cash balances with its Portfolio valuation, summing entries per currency into a signed int256. Each currency balance is then converted into ETH at that currency's exchange rate, with the rate and decimal scaling applied as integer divisions, and the final free collateral must be non-negative. At maturity an fCash position is settled into the account's cash balance through Escrow.portfolioSettleCash(), and a positive cash balance can then be withdrawn from the Escrow as the corresponding underlying asset.

Vulnerability Analysis

The buggy contracts are the ERC1155Trade entry point (0xbba8...ef08), which mints fCash pairs without a notional limit, and the Escrow (0x9abd...f683), whose collateral valuation carries two arithmetic defects in _convertToETH() that can value a debt at zero.

First, an unchecked narrowing conversion can truncate a large debt to zero. The function converts balance.abs() straight to uint128 with a raw cast and never checks that the value fits the target type. The two types are nowhere near each other: a signed int256 reaches to 2^255 - 1, while uint128 stops at 2^128 - 1. A balance of exactly -2^128 therefore sits comfortably inside int256, and balance.abs() produces 2^128 intact. It is the cast that fails: 2^128 lands one step past what uint128 can hold and wraps to 0, so the valuation that follows operates on a zero balance and the entire debt drops out of the free collateral computation. The same function uses SafeCast.toUint128() for the later conversion of the calculated ETH value, which would revert on an out-of-range result, while the earlier conversion stays a direct cast and truncates silently.

Second, integer division can round small debts down to zero. The divisions by er.rateDecimals and baseDecimals truncate their remainders, so a sufficiently small debt is also valued at 0 and omitted from free collateral.

Attack Analysis

The following analysis is based on the transaction 0xe1589a...25d60a.

  • Step 1: At 23:58:47 UTC on September 3, 2026, the attacker called safeTransferFrom() on ERC1155Trade to mint an fCash pair with an amount of 1, using cashGroupId = 2 and maturity timestamp 1788480000 (September 4, 2026, 00:00 UTC), 73 seconds ahead and the nearer of the two maturities the protocol had open. During the mint, _upsertAsset() recorded the negative fCash liability on the attacker's contract and the positive claim on the receiver contract, and Portfolios immediately checked the attacker contract's free collateral. That contract held no balance in any currency, so any positive valuation of the new liability would have failed the check. The second defect covered it: at the current exchange rate a debt of 1 does not survive the two integer divisions, so it was valued at zero and the check passed.

  • Step 2: The attacker called safeTransferFrom() again to mint a second pair with an amount of uint128.max (340,282,366,920,938,463,463,374,607,431,768,211,455). This pair reused cashGroupId = 2 but carried maturity timestamp 1796256000 (December 3, 2026, 00:00 UTC), the other open maturity, and sent its positive side to a different receiver contract. The negative side landed on the attacker's contract again, next to the one from Step 1. A single mint can never exceed 2^128 - 1, always one unit short of the value that truncates, so reaching it takes two positions. The different maturity kept the two in separate entries, out of reach of the checked addition that would have reverted on a merge; the shared cash group still put them in the same currency slot at valuation, where the unit from Step 1 carried the total to exactly -2^128.

  • Step 3: During the collateral check, the Escrow proxy called convertBalancesToETH(). The aggregated negative balance reached exactly -2^128, was passed into _convertToETH(), and was truncated to zero, so the account's ETH-denominated liability was reported as zero.

  • Step 4: With the debt gone from collateral accounting, the second receiver contract held a large positive fCash position that the protocol treated as available collateral. Still inside the same transaction, it minted two more fCash pairs against that collateral and handed the positive side to two further receiver contracts: 69,257.37 under cashGroupId = 2, which settles in DAI, and 1,658,524.86 under cashGroupId = 3, which settles in USDC. Both figures came from balanceOf calls the attacker had made on the Escrow at the start of the transaction, so each claim was sized to a balance the Escrow actually held. Each carried maturity 1788480000, 73 seconds out.

  • Step 5: At 00:01:35 UTC on September 4, 2026, 95 seconds after that maturity, the attacker settled the two matured claims in transaction 0xc3f3e3...a24efa and withdrew ~69,257.37 DAI and ~1,658,524.86 USDC from the Escrow. The funds were later forwarded to 0x8aaf...3be6.

Conclusion

The mint path imposes no notional limit, and two arithmetic defects in collateral valuation each let one solvency check pass: rounding let an account holding nothing take on its first liability, and a silent narrowing conversion then valued a debt of exactly the right size at zero, so the second check passed on an account that was deeply insolvent. The forged positive position on the receiver contract was then treated as available collateral, which let the attacker split it into claims matching the Escrow's balances and withdraw the DAI and USDC it still held.

Signed balances must be range-checked before any narrowing conversion, and a conversion that cannot represent its input should revert rather than truncate. More broadly, a solvency check should never report a non-zero debt as zero, whether the arithmetic loses it to a cast or to a rounding step. Applying the checked cast the same function already uses further down, or capping the notional a single pair mint can create, would each have stopped this exploit on its own.

Get Started with Phalcon Security

Detect every threat, alert what matters, and block attacks.

Try now for free

References

[1] https://github.com/InjectiveFoundation/injective-core/commit/b994d6b603eb53a38312e547f7bbe3c69d40495b

[2] https://mpost.io/injective-exploited-for-4-9m-via-market-id-collision-in-binary-options-settlement-logic/

[3] https://x.com/flow_blockchain/status/2094506622429307061

[4] https://bitquery.io/investigations/aquifer-solana-hack-2-5-million

About BlockSec

BlockSec is a full-stack blockchain security and crypto compliance provider. We build products and services that help customers to perform code audit (including smart contracts, blockchain and wallets), intercept attacks in real time, analyze incidents, trace illicit funds, and meet AML/CFT obligations, across the full lifecycle of protocols and platforms.

BlockSec has published multiple blockchain security papers in prestigious conferences, reported several zero-day attacks of DeFi applications, blocked multiple hacks to rescue more than 20 million dollars, and secured billions of cryptocurrencies.

Sign up for the latest updates
From Incidents to Regulation: Why Crypto Institutions Need Blockchain Penetration Testing
Security Services

From Incidents to Regulation: Why Crypto Institutions Need Blockchain Penetration Testing

Exchanges, payment firms, custodians, and wallet providers now lose the most money beyond the smart contract—in signing, custody, keys, people, and supply chains. Code-level audit and transaction-level monitoring each leave a gap, and traditional penetration tests may miss crypto's signing and fund semantics. This article opens our blockchain penetration testing series with the two legs of the case for institutions in scope: where the risk actually comes from, and how NYDFS, DORA, VARA, SFC, and MAS treat adversarial testing across five jurisdictions.

What Is Blockchain Penetration Testing? Definitions and Boundaries
Security Services

What Is Blockchain Penetration Testing? Definitions and Boundaries

No widely accepted definition of blockchain penetration testing exists, and many proposed ones tangle it with audit, scanning, and bug bounty. This article sets out a working definition—an adversarial, hands-on assessment of a running system, under agreed scope and rules of engagement, that validates exploitable paths and control chains—and what web3 adds: a money-handling threat model whose defining composition gap is the off-chain-to-on-chain handoff. It then maps the five testable capabilities of that chain and routes nearby objectives to code audit, wallet security audit, web3 security testing, scanning, and bug bounty.

Rules of Engagement and Production Safety for Institutional Blockchain Penetration Testing
Security Services

Rules of Engagement and Production Safety for Institutional Blockchain Penetration Testing

A penetration test that touches signing, withdrawal, and ledger systems is prepared before it runs. This article follows the engagement lifecycle: turning a business decision into objective, scope, named owners, and authorized access; recording authority, permitted techniques, operating limits, prohibited activity, communications, and evidence handling in a Rules of Engagement document; and protecting live service with measurable stop criteria, monitoring, change coordination, and named pause authority. It closes with the remediation and retest that turn findings into validated controls.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit