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 collidingINJ-denominated insurance fund andUSDC-denominated binary options market, seeded the fund with12,744,000,000rawINJ(worth about$0.000000063), and deposited30,267.02 USDCacross 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,
037dplaced a BUY of15,930at0.10and037cplaced a SELL of15,930at0.10. The EndBlocker matched them into a LONG for037dcarrying1,593of margin and a SHORT for037ccarrying14,337. Total margin on the books is1.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,037dclosed its long at0.90withmargin = 0and received1,593 + (0.90 - 0.10) * 15,930 = 14,337. That payout came out of the margin locked by the incoming opener037e, which placed a BUY of15,930at0.90and locked14,337. The realized profit of0.8Q = 12,744now sits in037d's available balance outside the market pool, while both remaining position margins stay on the books: liabilities read1.8Q = 28,674against a pool that still holds1.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 and037e's LONG,14,337of margin each. The oracle stayed silent, so the refund path computed liabilities of1.8Q = 28,674against idealized assets of1.0Q = 15,930and reported a deficit of0.8Q = 12,744. That deficit exists only inside the refund path's accounting. Under a single priceS, each side's payout depends only onSand not on where it entered: the short takes(1 - S) * Qand the longS * Q, summing to exactly theQin 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) * Qand the long0.90 * Q,14,337each. The short collected its full margin as if the price had never moved off0.10, the same0.8Qthe attacker had already withdrawn in Step 3. -
Step 5:
PayDeficitFromInsuranceFund()cleared the deficit by moving12,744,000,000rawINJout of the colliding fund and crediting12,744 USDCto 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 heldINJdust rather thanUSDC. -
Step 6: In transaction 0xcb33ad...152eff at block
181024803, the attacker withdrew43,010,985,663rawUSDC, or43,010.99 USDC, against the30,267.02 USDCdeposited. The net gain is12,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.
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 ankrFLOWfrom the Uniswap V3ankrFLOW/WFLOWpool, redeemed it throughunstakeCerts()for~5,999.25 FLOW, deposited5,000.50 FLOWthroughstakeBondsWithCode()to mint5,000.50 aFLOWEVMbbacked by the same amount ofankrFLOW, and calledunlockShares()to release thatankrFLOWto repay the loan and its0.50 ankrFLOWpremium. About998.75 FLOWremained as working capital. -
Step 2: The attacker repeated a Uniswap V3 swap 50 times. Each cycle swapped
ankrFLOWforWFLOWwith a price limit, minted theankrFLOWowed to the pool inside the swap callback by routingFLOWthroughstakeBondsWithCode()andunlockShares(), then unwrapped theWFLOWoutput for the next cycle. Across the 50 cycles the pool received~38,634,755.38 ankrFLOWand paid out~46,265,167.78 WFLOW, raising the attacker's balance from~998.75 FLOWto~7,631,411.14 FLOW. -
Step 3: The attacker converted
~38,601.95 FLOWthrough the bearing path and redeemed the resultingankrFLOWthroughunstakeCerts(), draining the~46,316.57 FLOWredemption buffer thatFlowStakingPoolstill 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
ankrFLOWandWFLOWas correlatedFLOWassets and raised theankrFLOWLTV from 78.5% to 97%. They deposited~7,639,125.76 FLOWthroughstakeBondsWithCode(), unlocked the matchingankrFLOW, 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 ofankrFLOW, bringing collateral to~13,307,608.86 ankrFLOWand 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 WFLOWoracle 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
swapwith a nominal input of4,957.497101 USDC, for which Aquifer computed an output of195,849.433667 KMNO. For theKMNOleg they supplied Tokenkeg; for theUSDCleg they supplied their own programDMBpPM...NRgb68together with a fake input account9gsKJc..., an account owned by that program whose 165 bytes imitate an SPL Token Account carrying theUSDCMint, the attacker's authority, and au64::MAXbalance. -
Step 2: The output CPI reached Tokenkeg and transferred
195,849.433667 KMNOout of the AquiferKMNOvault to the attacker's Token AccountEUjkGc..., which is controlled by signer7fTe9p...4gRk7J.

- Step 3: The input CPI reached
DMBpPM...NRgb68withUSDCtransfer-shaped data. That program returned success without moving anyUSDCfrom the fake source9gsKJc...into the genuine AquiferUSDCvault7ULN1Y....

- 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()onERC1155Tradeto mint an fCash pair with an amount of1, usingcashGroupId = 2and maturity timestamp1788480000(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, andPortfoliosimmediately 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 of1does 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 ofuint128.max(340,282,366,920,938,463,463,374,607,431,768,211,455). This pair reusedcashGroupId = 2but carried maturity timestamp1796256000(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 exceed2^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.37undercashGroupId = 2, which settles inDAI, and1,658,524.86undercashGroupId = 3, which settles inUSDC. Both figures came frombalanceOfcalls 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 maturity1788480000, 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 DAIand~1,658,524.86 USDCfrom the Escrow. The funds were later forwarded to0x8aaf...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.
References
[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.
-
Official website: https://blocksec.com/
-
Official Twitter account: https://twitter.com/BlockSecTeam



