bEarn Fi bVaults BUSD/ibBUSD Denomination Mismatch / Leftover-Yield Inflation Drain (2021-05-16)

  • Name: bEarn Fi bVaults BUSD/ibBUSD denomination mismatch / leftover-yield inflation drain
  • Date: 2021-05-16
  • Protocol: bEarn Fi
  • Chain: BNB Smart Chain
  • Category: cross-module asset-denomination mismatch / internal accounting inflation / vault drain
  • Loss: about 10.86M BUSD directly to the attacker; about $17.9M-$18M total vault damage including opportunistic user withdrawals and emergency exits before the system was fully contained
  • Summary: bEarn Fi’s BUSD single-stake bVault routed deposits through BvaultsBank into an Alpaca-based BvaultsStrategy that treated the same numeric amount as two different assets in two different modules. The bank asked to withdraw _wantAmt in BUSD terms, while the strategy withdrew _wantAmt in ibBUSD terms from Alpaca’s farm, even though each ibBUSD was worth more than 1 BUSD. The strategy then returned only the requested BUSD amount to the bank and left the extra BUSD behind inside the strategy contract. On the next deposit, _farm() treated that leftover balance as fresh strategy assets and added it into wantLockedTotal, so the vault’s internal accounting started recognizing phantom yield. By looping large flash-loan-funded deposits and withdrawals, the attacker kept inflating wantLockedTotal and then withdrawing more value than had really been deposited, eventually draining the BUSD vault.
  • Trigger / entry point:
    • The affected user-facing vault was bEarn Fi’s BUSD bVault bank at 0xB390B07fcF76678089cb12d8E615d5Fe494b01Fb.
    • The key strategy module was the BUSD Alpaca strategy at 0x21125d94Cfe886e7179c8D2fE8c1EA8D57C73E0e.
    • The strategy invested into Alpaca’s ibBUSD vault 0x7C9e73d4C71dae564d41F78d56439bB4ba87592f and staked via Alpaca FairLaunch 0xA625AB01B08ce023B2a342Dbb12a16f2C8489A8F.
    • Public analyses trace the exploit through repeated flash-loan-funded deposit / withdraw loops, including transactions 0x603b2bbe2a7d0877b22531735ff686a7caad866f6c0435c37b7b49e4bfd9a36c and 0x6bf610ecaf2f89f41bcad7aca4646199430839e7cf979fbcafa896e5126361d1.
  • Root cause:
    • BvaultsBank and BvaultsStrategy did not agree on the unit domain of _wantAmt during withdrawal. The bank used BUSD-denominated amounts, while the strategy passed the same integer onward as if it were denominated in ibBUSD.
    • Because ibBUSD was an interest-bearing receipt token worth more than 1 BUSD, withdrawing _wantAmt ibBUSD from Alpaca yielded more than _wantAmt BUSD.
    • The strategy’s withdraw(address, uint256 _wantAmt) then transferred only _wantAmt BUSD back to the bank and silently kept the surplus BUSD balance locally.
    • On later deposits, _farm() swept the full local BUSD balance into Alpaca and increased wantLockedTotal by that full amount, causing bookkeeping to recognize the leftover surplus as legitimate vault yield.
    • Shortfall-tolerant accounting in withdraw() masked inconsistencies instead of reverting: if the strategy did not have the exact amount its own accounting implied, it clipped _wantAmt to available balance rather than forcing the mismatch to surface.
  • Exploit path:
    1. The attacker, using EOA 0x47f341d896b08daacb344d9021f955247e50d089 and exploit contract 0xef39F14213714001456E2E89EDdBDF8c850C3BE6, borrowed roughly 7.8M BUSD from C.R.E.A.M.’s BUSD market 0x2Bc4eb013DDee29D37920938B96d353171289B7C.
    2. They deposited the flash-loaned BUSD into BvaultsBank, which forwarded the assets into the Alpaca-backed strategy.
    3. The strategy deposited BUSD into Alpaca, received fewer ibBUSD tokens than the raw BUSD number because ibBUSD had appreciated, and staked those ibBUSD shares in FairLaunch.
    4. The attacker then withdrew roughly the same numeric amount from the bVault. The bank interpreted that number as BUSD, but the strategy withdrew the same number of ibBUSD shares from Alpaca.
    5. Burning those ibBUSD shares yielded more BUSD than the attacker had asked the bank to withdraw. The strategy returned only the requested BUSD amount and left the extra BUSD sitting in the strategy contract.
    6. On the next deposit, _farm() swept that leftover BUSD back into Alpaca and added it to wantLockedTotal, so the bank’s share accounting started treating the leftover as earned yield attributable across shares.
    7. Because BvaultsBank used wantLockedTotal to price user share value, the attacker could withdraw slightly more BUSD than they had really put in on each loop.
    8. Repeating the loop dozens or hundreds of times with flash-loaned size turned the per-loop phantom yield into a full vault drain.
  • What the attacker needed to be true:
    • The strategy had to use a receipt token with a non-1:1 exchange rate against the bank’s asset, here ibBUSD > 1 BUSD.
    • The bank and strategy had to pass the same integer amount across their interface without converting units.
    • The strategy had to return only the requested BUSD amount upstream while leaving the excess BUSD in its own balance instead of reconciling it back into the bank’s accounting.
    • _farm() and wantLockedTotal had to treat leftover local balance as legitimate strategy principal or yield.
    • The attacker needed a large temporary BUSD balance so small percentage mismatches per loop became material profit.
  • Technical anatomy worth remembering:
    • This was not an oracle attack and not a classic reentrancy bug. The core failure was dimensional: one module meant BUSD, another meant ibBUSD, and both accepted the same integer as if the unit were obvious.
    • The strategy’s accounting variable wantLockedTotal was not recomputed from live underlying value in Alpaca; it was incrementally updated from what the strategy thought it had deposited and withdrawn. That made leftover balance look like real earned yield.
    • The masking behavior in strategy withdrawal mattered. A design that reverted on accounting mismatch would have broken loudly; instead, clipping _wantAmt to available balance let the vault continue operating while its internal state drifted further from reality.
    • The exploit demonstrates how vault stacks can fail at module boundaries even when each component appears locally reasonable. The dangerous edge was the interface between bank accounting, strategy accounting, and receipt-token semantics.
  • Representative transactions and addresses:
    • Attacker EOA: 0x47f341d896b08daacb344d9021f955247e50d089
    • Attacker contract: 0xef39F14213714001456E2E89EDdBDF8c850C3BE6
    • Representative exploit tx: 0x603b2bbe2a7d0877b22531735ff686a7caad866f6c0435c37b7b49e4bfd9a36c
    • Representative exploit tx: 0x6bf610ecaf2f89f41bcad7aca4646199430839e7cf979fbcafa896e5126361d1
    • bVault bank: 0xB390B07fcF76678089cb12d8E615d5Fe494b01Fb
    • BUSD Alpaca strategy: 0x21125d94Cfe886e7179c8D2fE8c1EA8D57C73E0e
    • Alpaca ibBUSD vault: 0x7C9e73d4C71dae564d41F78d56439bB4ba87592f
    • Alpaca FairLaunch: 0xA625AB01B08ce023B2a342Dbb12a16f2C8489A8F
    • C.R.E.A.M. BUSD market used for flash liquidity: 0x2Bc4eb013DDee29D37920938B96d353171289B7C
  • Response / mitigation:
    • bEarn disabled or contained the affected vault and later said only the single-stake BUSD bVault using the Alpaca strategy was impacted.
    • The team said future new bVaults would launch with deposit caps until the corresponding strategy had passed a full audit.
    • bEarn later published a compensation plan using remaining funds, DAO funds, and other project resources, and public summaries note that affected users eventually received staged compensation claims.
    • The incident also highlighted secondary loss amplification: after the exploit began, other users withdrew at the distorted rate or used emergencyWithdraw, which increased realized damage beyond the attacker’s direct take.

Why this incident matters

  • bEarn is a clean example of how a vault can manufacture fake yield without any external price manipulation: the bug lived entirely inside protocol-controlled accounting.
  • It preserves a recurring lesson that smart-contract interfaces need explicit unit semantics. Passing amount across module boundaries is unsafe when one side means underlying and the other means receipt shares.
  • It also shows why internal totals such as wantLockedTotal should be derivable from live positions rather than only from increment/decrement bookkeeping. If the bookkeeping goes wrong once, the entire strategy can start compounding its own error.
  • The sharper healthy-side boundary is that the strategy contract holds more BUSD than before should not have meant existing vault shares legitimately earned more backing. bEarn needed a reconciliation boundary between seam-generated leftovers and canonical vault yield.

Three seam-authority promotions this incident collapsed

  1. A seam-crossing amount existed between BvaultsBank and BvaultsStrategy

    • The bank passed _wantAmt across the withdrawal boundary expecting a BUSD-denominated request.
    • At this stage the safest truthful statement should only have been the caller wants this much BUSD out. That did not yet mean the strategy was free to spend the same raw integer as ibBUSD shares.
  2. bEarn treated that raw amount as canonical cross-module value truth

    • BvaultsStrategy reused _wantAmt as if it already meant the same economic object inside Alpaca’s ibBUSD domain.
    • Once the strategy redeemed _wantAmt ibBUSD shares but the bank still settled _wantAmt BUSD, the protocol effectively canonized one seam mismatch into local accounting truth instead of forcing reconciliation.
  3. That accepted seam-authored surplus still inherited vault-yield and withdrawal authority

    • The extra BUSD left behind in the strategy did not stay quarantined as evidence of accounting disagreement.
    • _farm() swept it back in, wantLockedTotal recognized it as real backing, and vault share pricing plus later withdrawals spent against that fiction as if it were earned yield.

Pattern tags:

  • Underlying-versus-receipt mismatch: BvaultsBank requested withdrawal in BUSD terms while BvaultsStrategy redeemed the same numeric amount as ibBUSD shares from Alpaca.
  • Leftover-balance recycling: excess BUSD created by the seam stayed inside the strategy, then _farm() treated that residue as fresh depositable assets and increased wantLockedTotal.
  • Incremental-bookkeeping drift: the vault trusted strategy-local counters instead of proving live underlying value after each deposit / withdrawal round trip.
  • Silent-shortfall masking: withdrawal logic clipped to available balance instead of reverting on accounting disagreement, so the mismatch stayed latent and compounding.
  • Design companions: cross-module-asset-denomination-mismatch, reserve-backed-claim-truth-security-object, and unexpected-balance-quarantine-and-explicit-recognition-discipline.

Why the exploit worked

  • The bank-strategy seam had no explicit dimensional contract. A raw uint256 _wantAmt crossed the boundary without proving whether it meant BUSD assets or ibBUSD shares, so one side could honestly ask for one thing while the other honestly executed another.
  • Alpaca’s ibBUSD exchange rate had already moved above 1, which turned a semantic mismatch into real economic surplus on every large withdrawal.
  • That surplus did not stop at the seam. The strategy kept the extra BUSD locally, and later _farm() reintroduced the residue as if it were legitimate principal or earned yield, effectively canonizing interface error into vault PPS.
  • In other words, bEarn collapsed three different approvals into one flow: the bank requested assets, the strategy interpreted the same integer as shares, and the resulting leftover still deserved canonical yield and withdrawal authority.
  • wantLockedTotal was maintained incrementally rather than continuously reconciled against live underlying value, so once the first mismatch landed, subsequent loops amplified the accounting fiction instead of washing it out.
  • The strategy’s shortfall-tolerant withdrawal behavior prevented the system from failing loudly. Clipping and continuing preserved liveness while letting the attacker compound the same seam mistake dozens of times under flash-loan size.
  • The attacker did not need market manipulation or privileged access. They only needed large temporary BUSD inventory and a repeatable route through a vault stack that reclassified seam-created leftovers as real backing.

Detection and audit pivots

  • Annotate every bank / strategy / adapter interface with units and review whether each amount means underlying assets, receipt shares, staked shares, or scaled accounting units on both sides of the call.
  • Replay deposit-then-withdraw round trips after the receipt token has appreciated meaningfully above 1 and check whether unexplained residue remains in the strategy contract.
  • Reconcile wantLockedTotal or equivalent internal totals against live underlying value in the external vault after every withdrawal path, not only at happy-path deposit checkpoints.
  • Treat intermediate-contract leftovers as hostile accounting evidence. If strategy-local balance increases after a supposed exact withdrawal, ask whether that residue came from earned yield or from a unit-domain mistake.
  • Search for min(balance, requestedAmount) and similar shortfall-clipping patterns on solvency boundaries. Those branches often hide the first visible symptom of denomination drift.
  • Fuzz large-loop behavior with flash-loan-sized capital. Small per-loop surpluses that look harmless in unit tests can become full drains once the protocol lets the attacker recycle them through price-per-share accounting.
  • Verify that emergency withdrawal, user withdrawal, and strategy unwind paths all consume the same valuation and denomination semantics. If one path settles in assets and another in receipt shares, the seam needs explicit conversion or it is already unsafe.

Failure-prevention checklist

  • Make the bank / strategy seam unit-explicit in code. amount should never cross as an untyped integer when one side means BUSD assets and the other can act on ibBUSD shares.
  • Convert exactly once at the boundary using the live wrapper exchange rate. If the bank requests assets, the strategy should compute the necessary share burn explicitly instead of reusing the same raw integer downstream.
  • Recompute strategy solvency from live positions after deposit and withdrawal paths. wantLockedTotal-style counters should be reconciliation outputs, not the only source of truth for PPS.
  • Quarantine unexpected strategy-local balances. Leftover BUSD after an ostensibly exact withdraw should enter a reconciliation bucket or revert the call, not get auto-swept into _farm() as presumptive earned yield.
  • Revert on denomination disagreement or shortfall instead of clipping to current balance. Silent min(balance, requestedAmount) behavior turns a loud seam failure into compounding accounting drift.
  • Add round-trip tests after receipt-token appreciation. Deposit / withdraw loops should be exercised when ibBUSD is materially above 1, with large sizes and repeated cycles, not only at a fresh 1:1 bootstrap state.
  • Keep emergency, normal withdrawal, and harvest paths on one valuation story. Any path that settles in assets while another path reasons in receipt shares needs the same explicit conversion and reconciliation boundary.
  • Treat wrapper integrations as balance-sheet migrations, not routing glue. Any adapter using interest-bearing or appreciating receipt tokens should get dimensional-analysis review before production and after wrapper changes.

Audit prompts

  • What exact unit does _wantAmt represent on each side of the BvaultsBank -> BvaultsStrategy interface, and where is that meaning enforced in code rather than assumed socially?
  • If the bank asks for X BUSD, what exact object is the strategy redeeming downstream: X BUSD of underlying, X ibBUSD shares, or whatever balance happens to be locally available?
  • After a full deposit / withdraw round trip, can any unexplained BUSD residue remain inside the strategy contract, and what code path decides whether that residue is reconciliation evidence or yield?
  • Is wantLockedTotal derived from live Alpaca position value plus local balances, or merely incremented and decremented from prior assumptions that can preserve one old seam mistake indefinitely?
  • Which branches clip, floor, or tolerate shortfall instead of reverting on accounting disagreement, and could those branches hide the first visible symptom of denomination drift?
  • If ibBUSD appreciates further, does the same loop become more profitable automatically, and has the protocol tested that exchange-rate sensitivity under flash-loan-sized repetition?
  • Can emergency withdrawal, strategy unwind, or harvest paths reuse the same ambiguous amount semantics under different code paths?
  • If a reviewer looked only at transfer logs, would they still notice that the strategy redeemed and re-staked more economic value than the bank believed it was moving?

Lessons that should already be standard

  • Internal module seams can mint fake yield without any oracle manipulation, governance abuse, or reentrancy. A dimensional-analysis mistake inside protocol-authored accounting is enough.
  • A strategy-local leftover is not free alpha. Unexpected residue after an exact withdrawal is evidence that one solvency-critical interpretation of value has already diverged from another.
  • Incremental totals over wrapper positions are brittle unless live reconciliation stays close. Once the protocol starts compounding its own bookkeeping fiction, every later honest action helps crystalize the theft.
  • Wrapper and receipt-token integrations are valuation boundaries, not just routing convenience. Underlying amount and receipt-share amount must be treated as different security objects until explicitly converted.
  • Silent clipping on solvency boundaries is often worse than a revert. It preserves apparent liveness by hiding the mismatch the attacker most needs to repeat.
  • Flash loans matter here not because they create the bug, but because they let attackers industrialize a small per-loop accounting seam until it becomes treasury-scale loss.
  • The strategy holds more tokens now was never the same thing as existing vault shares earned more backing. Recognition and attribution needed a stronger boundary than raw local balance growth.

Internal linkages

Control surface