Root Cause
NEAR Intents' BNB Chain vault contract tracked user balances but contained a credit-on-attempt, no-debit-on-failure accounting bug: it incremented a user's internal balance when a deposit was initiated, but did not decrement that balance when the transaction failed and tokens were returned to the sender.
Each failed deposit left an inflated "phantom" credit that could be withdrawn against real vault funds.
// Simplified vulnerable pattern
function deposit(uint256 amount) external {
// Attempt the transfer — may revert internally
try token.transferFrom(msg.sender, address(this), amount) {
balances[msg.sender] += amount; // success path
} catch {
// BUG: tokens returned to sender but no balance update here
// In the actual bug, balance was incremented BEFORE the try,
// or the catch path did not subtract — exact form unconfirmed
balances[msg.sender] += amount; // phantom credit survives
}
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
token.transfer(msg.sender, amount); // real funds drained
}
Note: The postmortem describes the semantic effect; the precise Solidity pattern is reconstructed from the published description.
The October 16 postmortem also revealed a monitoring gap: withdrawals continued for nearly 40 hours before the protocol detected the drain, and Omni (the cross-chain messaging layer) was initially — incorrectly — blamed.
Attack Steps
| Step | Action | Phantom Balance |
|---|---|---|
| Funding | Attacker wallet loaded with only 10 USDT | $10 seed |
| 1–23 | Submit deposit; trigger revert condition; tokens returned | +X USDT phantom per attempt |
| 24 | Call withdraw() with accumulated phantom balance | Drain $3.87M USDT |
The attacker executed 23 failed deposits between September 29 and October 1 before draining.
Impact
- Initial Drain: ~$3.87M USDT (BNB Chain vault)
- Scope: BNB Chain USDT vault only; NEAR Protocol, NEAR token, and other chains unaffected
- Date: October 1, 2026
- Recovery: Attacker returned all funds within 24–36 hours, ahead of a 48-hour return deadline
- Net Loss: $0 (full recovery)
- Monitoring Gap: ~40 hours between exploit start and detection
- Fix: Contract patched within ~1 hour of detection; automatic balance-mismatch pausing added
Lessons for Auditors
-
Balance updates must be atomic with token transfers. Always apply the Checks-Effects-Interactions (CEI) pattern: update state after confirming the transfer succeeded, or revert the entire call on failure. Never increment a balance in a catch block or before confirming receipt.
-
Inspect all try/catch blocks that wrap asset movements. A
catch {}that silently swallows a failed transfer while keeping a balance credit is a critical accounting bug. Grep fortry.*transferFrom,try.*transfer, and similar patterns; verify the catch reverts or correctly reverses any preceding state change. -
Cross-chain vaults need real-time invariant monitoring. Alert when
sum(balances[]) > token.balanceOf(vault). A 40-hour detection gap is far too long for a live financial contract. -
Low-cost exploits require low-seed capital. 10 USDT was sufficient to steal $3.87M. Protocols should fuzz-test deposit/withdraw accounting with revert scenarios at minimal balances.