NEAR Intents Lost $3.8M on BNB Chain. The Mainnet Was Not the Bug. | PandaSecurity
Web3 Security

NEAR Intents Lost $3.8M on BNB Chain. The Mainnet Was Not the Bug.

PandaSecurity Team •
#NEAR Intents#cross-chain bridge#smart contract audit
Diagram of a cross-chain vault paying out against an Intents balance

On the morning of 1 October, NEAR Intents stopped cross-chain swaps. The preliminary loss was about $3.8 million. The asset was USDT on BNB Chain. The paying contract was the HOT Bridge treasury listed in the project docs, not a NEAR protocol contract. The public note was short: a bug in how the Omni deposit and withdrawal layer talks to the Intents contract, the contract side already patched, users to be paid back in full.

That is enough for a status post. It is not enough for a write-up. The funds left a BSC vault on signatures that looked like ordinary withdrawals. Explaining how an address tied to roughly 10 USDT of prior deposits received valid notes means looking at the ledger that issued those notes, not at a broken lock on the vault.

Panda Security went through the near/intents commits from that day. What lines up is not a post-mortem. It is pull request #362, merged into main on 1 October.

The vault only checks the note

NEAR Intents splits two jobs.

Users send coins into a per-chain vault. The HOT Bridge treasury on BNB Chain is one of them. The balance lives on the Intents ledger, not in the vault contract. A withdrawal needs a short note from the Intents or Omni side: who gets paid, how much, and which nonce. Anyone can submit that note to withdraw. The vault checks the signature and pays.

That pattern is normal for a bridge. It is not, by itself, the bug. The bug is whatever the ledger believed when it signed. The vault does not re-check the deposit. It trusts the signer. If the signer’s books are high, the vault still pays.

The on-chain sequence is plain. The attacker address deposited about 10 USDT, withdrew 10 and then 11 as tests, then took five larger amounts over about six hours, about 3.865 million USDT in all. Each call came from the attacker wallet and carried a 65-byte signature in the same shape as a normal withdrawal. The USDT was swapped to BNB within minutes. About a fifth of the loot later moved through NEAR Intents itself. SHIELD flagged the outlier, and the service paused. The first large withdrawal was about 13 hours before the public note.

NEAR L1 was not the failure. That distinction matters. A headline that welds the product layer to the chain is wrong.

What the deployed refund actually did

#362 is titled chore: reproduce bug. One commit is marked as a temporary local change that reflects the deployed state. The diff is a single expression.

Deployed:

let refund_amount = balance_left.min(requested_refund);

Main now:

let refund_amount = requested_refund.min(*deposited).min(balance_left);

balance_left is the receiver’s whole balance. deposited is the amount in this call. The deployed revision did not take the minimum with deposited.

The regression comment is specific. A receiver that already held a large balance could ask for a refund of 1e35 on a deposit of 1. The refund was filled from that balance, the mt_burn event grew past TOTAL_LOG_LENGTH_LIMIT, and mt_resolve_deposit failed. The authors wrote that this had already happened on the deployed revision.

#364, merged the same day, caps batch transfers, deposits, and withdrawals at 10 tokens. Oversized token ids blowing the refund log are the other face of the same class of bug. #361 disables state_init. That shrinks the surface. It is not the refund formula.

So the line “contract side patched” matches the repo. The patch caps a refund by both this deposit and the receiver balance, and it limits batch size.

A failed callback is not a credit to the attacker

This is where write-ups usually slide.

A failed callback does not mean the deposit stayed credited, the refund did not roll back, and the attacker walked off with an inflated balance to cash out on BSC. On NEAR, a receipt that has already succeeded is not undone because a later callback fails. mt_resolve_deposit failing means that refund burn did not commit. The pull request reproduces the failure. It does not reproduce a successful theft.

The path that can touch an old balance is the other one: the callback succeeds, and the uncapped formula burns the receiver’s existing balance. That path is real in the deployed expression. This test does not exercise it. Nothing in the repo ties it to HOT Bridge withdraw(nonce, contract_id, receiver_id, amount, signature) on BNB Chain.

The public record still has a gap between the $3.8 million and this pull request. The team said the handoff was buggy. The pull request shows the deployed NEP-245 refund was not capped by the deposit, and that this behavior had already failed mt_resolve_deposit in production. What is missing is how the Intents balance behind those BSC withdrawals was created. Until the post-mortem lands, treating the two as one root cause is closing the case for the team.

We are not closing it.

Why this class of bug gets through review

Read the vault alone and the signature, nonce, and time window are there. Read Intents alone and the refund looks like an ordinary min. The miss is two invariants that were never checked together.

The vault’s invariant is: no valid signature, no payout. The Intents invariant should have been: a refund cannot exceed this deposit, and cannot exceed the receiver balance. The deployed code only kept the second. Once the signer consumes that ledger, the first invariant cannot save you.

After a product splits “who is owed what” from “who gets paid” onto two chains, an audit that still cuts along file boundaries will keep missing this. A refund log that is too long looks like an availability bug. If it changes a balance, the BSC vault will treat that balance as withdrawal authority. The log limit was the symptom that fired first.

The clock is the other tell. Hours passed between the tests and the large withdrawals, then about 13 more before the note. Detection worked. There was no per-withdrawal cap, and no live check that internal balances still matched what the vaults could pay. SHIELD is a behavior layer. A behavior layer is not a substitute for conservation.

What we actually check

Panda Security does not start these notes from the loss figure in the tweet. Three comparisons come first.

Deployed code against main. Here the gap was one min. A commit that says it reflects deployed state is more useful than the incident prose.

Failed path against successful path. A callback that panics, a refund that succeeds above the deposit, and a replayed signature move different balances. Folding them into one paragraph turns a log bug into a theft.

Whether the external vault trusts the internal books and nothing else. If it does, any inflation of those books is a payment authorization. The audit has to put the Omni signature inputs and the Intents balance in one invariant, not in two reports.

$3.8 million is small next to the volume this product claims, and the funds were reported returned. Size does not fix the structure. A vault that only checks signatures, fed by a ledger that can over-count, will pay the over-count. The refund cap is in. The reconciliation, and the constraint on what the signer is allowed to believe, still wait on the detailed report.

Until that report, two sentences are safe. NEAR mainnet was not the fault. The deployed Intents NEP-245 refund was not capped by the deposit.

Related articles

← Back to Blog