Belt Finance StrategyAlpaca balanceSnapshot Undercount / Share-Overmint Near Miss (2021-08-10)

  • Name: Belt Finance StrategyAlpaca balanceSnapshot undercount / share-overmint near miss
  • Date: 2021-08-10
  • Protocol: Belt Finance
  • Chain: BNB Chain
  • Category: issuance / redemption solvency asymmetry / direct-transfer state desynchronization / share overmint / whitehat near miss
  • Loss: no realized theft; Immunefi’s review estimated roughly $60M of BNB-denominated value at risk, with about $10M drainable per attack iteration before repetition
  • Summary: Whitehat Alexander Schlindwein found that Belt’s Alpaca strategy contracts could be tricked into understating their own assets and then overminting strategy shares. The bug sat in StrategyAlpacaImpl.withdraw(): the contract always subtracted the requested withdrawal amount from balanceSnapshot, even when no strategy position had actually been unwound because enough want already sat idle in the contract. An attacker could first send wrapped BNB directly to the strategy contract so wantLockedInHere() looked artificially high, then withdraw shares to trigger the bad accounting branch, making Belt think the strategy held less than it really did. A later deposit minted too many strategy shares against that understated total, earn() refreshed the snapshot back to reality, and the attacker could then redeem the overminted shares for an unfair share of the vault.
  • Trigger / entry point:
    • The vulnerable logic lived in Belt’s BNB-chain Alpaca strategy implementation, StrategyAlpacaImpl, in the withdraw(uint256 _wantAmt) path.
    • Belt strategy tokens minted shares against wantLockedTotal(), which depended on both idle wantLockedInHere() balance and the strategy-side balanceSnapshot.
    • The attacker could transfer want tokens directly to the strategy contract without going through Belt’s accounting flow.
    • That direct transfer changed whether withdraw() thought it needed to call _withdraw(...), but the contract still decremented balanceSnapshot as if a strategy unwind had happened.
  • Root cause:
    • Belt relied on a cached strategy-balance variable, balanceSnapshot, and assumed withdrawal accounting would only ever be driven by internal asset flows.
    • withdraw() subtracted _wantAmt from balanceSnapshot unconditionally, even when the withdrawal had been satisfied entirely from idle balance and _withdraw(...) had not reduced strategy exposure.
    • A direct token transfer to the strategy contract let an attacker control that branch condition and create a gap between actual assets and accounted assets.
    • Once total assets were understated, Belt’s share-minting logic sold claims too cheaply, while the redemption side still honored those claims against the strategy’s real assets after earn() recomputed the snapshot.
  • Exploit path:
    1. The attacker deposits BNB / WBNB through Belt normally and receives strategy shares.
    2. The attacker sends WBNB directly to the strategy contract instead of using Belt’s tracked deposit path, temporarily increasing wantLockedInHere().
    3. The attacker withdraws shares. Because the direct transfer makes wantLockedInHere() large enough, the if (_wantAmt > wantLockedInHere()) branch does not call _withdraw(...).
    4. Even though no strategy position was actually unwound, the buggy code still executes balanceSnapshot = balanceSnapshot.sub(_wantAmt), understating Belt’s tracked assets.
    5. The attacker deposits again while wantLockedTotal() is falsely low, so the strategy token mints too many shares for the assets supplied.
    6. The attacker calls earn(), which refreshes balanceSnapshot from the upstream bank and restores Belt’s accounting to the real asset total.
    7. With the corrected snapshot but the overminted share balance still in hand, the attacker withdraws again and cashes out more of the strategy than they fairly funded.
    8. The sequence can then be repeated until the vault is largely drained.
  • What the attacker needed to be true:
    • The strategy had to accept direct token transfers that changed wantLockedInHere() without updating the tracked strategy snapshot.
    • Share minting had to key off the understated wantLockedTotal() value quickly enough that a second deposit could exploit the accounting gap.
    • earn() or another refresh path had to restore balanceSnapshot to reality before final redemption, so the overminted shares could be cashed out at full value.
    • The strategy needed enough TVL that the temporary direct-transfer cost and the repeated attack steps still produced a profitable spread.
  • Technical anatomy worth remembering:
    • This was not a donation / share-price inflation attack where the attacker made each share look more valuable. It was the mirror image: the attacker made Belt think the vault had fewer assets than it really did, then bought shares at a discount and redeemed them after accounting normalized.
    • The direct transfer mattered because it changed control flow, not because Belt intentionally credited the donation as user equity.
    • balanceSnapshot was a classic cached truth variable: safe only if every path that can change economic state also updates the cache under the same assumptions.
    • The bug only became drainable because Belt’s mint side and redeem side temporarily disagreed about the same reserve base. Once that asymmetry existed, the strategy token itself became the attack rail.
  • Representative code and artifacts:
    • Vulnerable implementation family: contracts/bsc/earnV2/strategies/alpaca/StrategyAlpacaImpl.sol
    • Public vulnerable reference commit cited by Immunefi: b45069e946dfe19418ce49637b3590b0f3e5a217
    • Fix commit: 064282d554a424ca95ab7f87d8ec04c5f92cf343
    • Core buggy logic before the fix:
      • if(_wantAmt > wantLockedInHere()) { _withdraw(_wantAmt.sub(wantLockedInHere())); }
      • balanceSnapshot = balanceSnapshot.sub(_wantAmt);
    • Core patched logic moved the snapshot decrement inside the _withdraw(...) branch so only actually-unwound strategy exposure reduced balanceSnapshot.
  • Response / mitigation:
    • Schlindwein reported the issue through Immunefi on 2021-08-10.
    • Belt patched the strategy contracts, with the public fix moving the balanceSnapshot decrement so it only applied to the portion actually withdrawn from the upstream strategy position.
    • Immunefi and BNB Chain’s Priority One program paid a combined $1.05M bounty for the report.
  • Key transaction hashes:
    • No public theft transaction hashes exist because the issue was disclosed and fixed before a live exploit.
    • The key public preservation artifacts are the vulnerable source snapshot, the fix commit, and Immunefi’s step-by-step exploit description.

Pattern tags

  • issuance-redemption-solvency-asymmetry
  • balance-delta-provenance-failure
  • The reusable shape is unexpected inventory changes branch conditions, cached AUM is published under false assumptions, then later share issuance sells reserve claims against the wrong denominator.
  • Belt is a clean reminder that direct transfers do not need to be credited as new reserves to be dangerous. It was enough that direct-transfer-accessible balance changed which withdrawal path executed and therefore how cached strategy exposure was mutated.

Why the exploit worked

  • The exploit worked because Belt split one reserve truth object into two stories: idle wantLockedInHere() balance and cached upstream exposure in balanceSnapshot. Direct WBNB transfers could change the first story without going through the same accounting path that was supposed to govern the second.
  • withdraw() then treated enough idle balance exists as if it also meant strategy exposure just shrank. In the bugged branch, no Alpaca position was unwound, but balanceSnapshot was still decremented as though one had been.
  • That made the next deposit economically decisive. Share minting trusted the understated wantLockedTotal() immediately, so the attacker could buy strategy shares off a fake smaller asset base even though the real assets were still there.
  • earn() completed the asymmetry by restoring the snapshot back to actual upstream value before redemption. The attacker did not need Belt to stay wrong forever; they only needed Belt to be wrong during issuance and honest again during exit.
  • This is why the incident is more useful than a generic logic error label. The real bug was branch-local cached-AUM publication became attacker-steerable through ambient inventory, and the redemption side still honored the resulting overminted claims against real reserves.

Detection and audit pivots

  • Inventory every place where direct token transfers, prefunds, or unexpected idle balances can change control flow even if the protocol claims those balances are not recognized deposits. Belt shows that branch selection alone can be enough to corrupt cached solvency state.
  • For every cached AUM or snapshot variable, prove that decrements and increments correspond only to real upstream position changes, not merely to withdrawals that happened to be satisfied locally.
  • Differential-test direct transfer -> withdraw -> deposit -> resync/earn -> redeem sequences. The dangerous question is not only whether a cache can drift, but whether an attacker can force cheap claim issuance now, honest payout later in one short cycle.
  • Assert and monitor recognized total assets == idle balance + true upstream position across every branch that can satisfy withdrawals from local inventory, partial upstream unwind, or mixed sources.
  • Review helper paths that refresh cached balances after issuance. If a later earn(), harvest(), sync(), or bank query can silently heal the accounting fiction, it may also complete an issuance/redemption asymmetry rather than mitigate it.

Failure-prevention checklist

  • Keep cached strategy exposure subordinate to one explicit upstream-balance adoption rule. A local idle-balance branch should not be able to publish strategy assets decreased unless the protocol can prove an upstream unwind actually occurred.
  • Quarantine unexpected inventory before it affects branch conditions that also mutate reserve-significant caches. Direct transfers may need to be ignored, swept, or held inert until explicit recognition instead of being allowed to steer withdraw logic.
  • Reuse one canonical total-assets primitive for share minting, share redemption, and cache publication. If issuance and redemption consult different stories about the same reserves, attackers will search for the timing gap.
  • Add invariant tests for deposit -> direct transfer -> withdraw -> deposit -> earn -> withdraw and related mixed-source flows, including cases where withdrawals are fully satisfied from idle balance, partly from idle balance, and entirely from upstream unwinds.
  • Treat earn, harvest, sync, and similar refresh verbs as solvency code. If they can restore true reserves after a temporary understatement, they can also turn a transient accounting bug into a drainable round trip.
  • Alert on raw balance changed without matching ledger entry and on snapshot delta does not match upstream position delta, especially in low-share-supply or high-TVL strategies where one undercount can cheaply overmint many shares.

Audit prompts

  • Which exact code paths can change wantLockedInHere() or other locally observed balances without going through the same journal entry that governs cached upstream exposure?
  • In every withdrawal branch, what proves that any change to balanceSnapshot matches a real reduction in upstream strategy assets rather than merely a local inventory payout?
  • If a user directly transfers the strategy asset to the contract, which invariants guarantee that share price, total-assets reporting, and later share issuance remain unchanged until explicit recognition?
  • Can a later earn(), harvest(), or cache refresh restore the true asset total after the protocol has already issued discounted claims against the understated total?
  • Are mint and redeem paths reading the same canonical reserve object, or can one path consult cached or branch-local state while the other eventually pays against refreshed reality?
  • What pause, cap, or share-issuance throttle limits damage if cached AUM drifts materially below actual reserves for even one block or one user action sequence?

Lessons that should already be standard

  • Direct-transfer handling is not only about donation inflation. Unexpected balances can also steer branch conditions and make cached ledgers lie in the opposite direction.
  • Cached AUM variables are solvency state. If a snapshot can change share issuance economics, it deserves the same adversarial review as a vault’s reserve ledger.
  • Local-inventory payout and upstream-position accounting must stay fused. User got paid from idle funds is not the same fact as the strategy position shrank.
  • Temporary accounting fiction is enough to drain a system when issuance and redemption happen on different timestamps or through different code paths. The protocol does not need to stay wrong; it only needs to be wrong at the cheap side and right again at the payout side.
  • Refresh helpers are not automatically safety valves. Once a resync function can heal the reserve story after bad claims were already minted, it may actually finish the exploit path.
  • Near misses like Belt are especially valuable because they show how a very ordinary-looking optimization — caching upstream exposure — becomes a live custody boundary once share supply is minted against it.

Why this incident matters

  • It is a clean near-miss exemplar of mint side sees fiction, redeem side pays reality in a yield-vault setting rather than a bonding curve or standard-wrapper bug.
  • It also preserves a subtle but reusable lesson: direct transfers do not only cause balance-accounting bugs; they can also steer branch conditions so cached state is updated under false assumptions.
  • The case is a good reminder that cached AUM variables are part of the solvency boundary, not just gas optimizations.

Internal linkages

Control surface