Balancer Merkle Orchard Duplicate-Claim Drain Bug (2023-01-22)

  • Name: Balancer Merkle Orchard duplicate-claim drain bug
  • Date: 2023-01-22
  • Protocol: Balancer
  • Chain: Ethereum, Polygon, Arbitrum
  • Category: duplicate claim / idempotency failure / Merkle-distributor replay / bitmap-commit ordering failure
  • Loss: no realized theft; whitehat disclosure prevented a drain of roughly $3.2M in Merkle Orchard funds held across Balancer Vaults on Ethereum mainnet, Polygon, and Arbitrum
  • Summary: Balancer’s legacy MerkleOrchard reward-distribution contract let liquidity providers batch multiple incentive claims in one call to claimDistributions(). The bug was that _processClaims() accumulated currentClaimAmount and per-token amounts[claim.tokenIndex] for every entry in the caller-supplied claims array, but only committed the bitmap of consumed claims when the loop left the current claim set or reached the final array element. If the attacker repeated the exact same valid claim several times inside one batch, each duplicate increased the amount to withdraw while _setClaimedBits() still ran only once for that claim bit. The result was classic non-idempotent payout logic: one authentic Merkle leaf could be cashed out many times in a single transaction, and Balancer’s Vault would honor the inflated aggregate via manageUserBalance(... WITHDRAW_INTERNAL ...).
  • Trigger / entry point:
    • The vulnerable user entry point was MerkleOrchard.claimDistributions(address claimer, Claim[] claims, IERC20[] tokens) at 0xdAE7e32ADc5d490a43cCba1f0c736033F2b4eFca.
    • claimDistributions() called internal function _processClaims().
    • _processClaims() grouped claims by channel (keccak256(token, distributor)) and distribution-word index, accumulated payout amounts for each claim record, and only later committed claimed bits.
    • Duplicate entries in the same claim set therefore increased payout before uniqueness was enforced.
  • Root cause:
    • Balancer treated claims[] as if it were already a set of distinct entitlements instead of adversarial user input.
    • The vulnerable implementation explicitly optimized for gas by grouping claims into a claims set and only committing bitmap state when the loop changed sets or hit the last element; that optimization created the replay window.
    • _setClaimedBits() protected against already-claimed bits across committed state, but not against the same uncommitted bit being counted repeatedly inside the current in-memory batch.
    • The final payout path was based on the sum of array entries, not on the set of distinct proved entitlements.
  • Exploit path:
    1. The attacker identifies one valid unclaimed Merkle Orchard leaf and proof. In the public PoC Balancer and Immunefi used a real historical BAL claim from block 15837793: distributionId = 52, balance = 5.568441214478 BAL, distributor = 0xd2EB7Bd802A7CA68d9AcD209bEc4E664A9abDD7b, tokenIndex = 0, with the original Merkle proof copied from transaction 0x7f80cc306ba86a25be806df13264e3c7e8363f0295c822fa302378bd38ab0727.
    2. They build a Claim[] array that repeats that exact claim many times inside one claimDistributions() call.
    3. _processClaims() sees the duplicates as belonging to the same channel and same distribution word, so it keeps OR-ing the same bit into currentBits while still adding claim.balance into currentClaimAmount for every repetition.
    4. The same repeated entries also keep incrementing amounts[claim.tokenIndex] used for the final Vault withdrawal.
    5. The first claim in the batch is especially important: because currentChannelId starts at zero, the contract treats it as the start of a new claims set and still does not commit any bits yet. Duplicates of that first claim therefore keep accumulating value before _setClaimedBits() has had any chance to reject reuse.
    6. Only when the loop reaches the last element does _setClaimedBits(currentChannelId, claimer, currentWordIndex, currentBits) run, which records the claim bit once rather than rejecting the duplicates that have already inflated the payout.
    7. require(_verifyClaim(...)) still passes because every repeated entry is a genuine Merkle leaf with a genuine proof; the bug is not proof forgery but same-batch non-idempotent payout.
    8. _deductClaimedBalance() and manageUserBalance() then process the inflated aggregate amount as if it represented distinct claims.
    9. The attacker receives N × claim.balance for one real Merkle entitlement, bounded mainly by the remaining internal balances parked for that distribution token set.
  • What the attacker needed to be true:
    • They needed one valid, not-yet-claimed Merkle Orchard leaf and proof.
    • claimDistributions() had to accept caller-supplied arrays without duplicate-claim rejection.
    • Claimed-bit state had to remain uncommitted until after duplicate entries had already been aggregated into payout amounts.
    • The Vault had to still hold withdrawable internal balances for the relevant Orchard distributions.
  • Technical anatomy worth remembering:
    • This was not a Merkle-proof forgery bug. Every repeated claim still had to be a real leaf with a valid proof, and the public PoC deliberately reused real calldata from a historical claim transaction.
    • The pre-fix MerkleOrchard.sol implementation on Balancer’s merkle-orchard-v2 branch parent commit (a8e5f93c555cc842af586f74022f48dbcd58bf67) documented its own optimization strategy: group claims by channel and word index, aggregate bits and amounts in memory, and only commit storage when changing groups or processing the last claim. That exact optimization is what made duplicate entries additive before they were final.
    • The failure lived in the gap between proof is valid and this entitlement is unique within the submitted batch.
    • _setClaimedBits() was present, but its position in the flow made it a cross-batch replay guard instead of a same-batch deduplication guard.
    • The same bug inflated two accounting layers at once: currentClaimAmount for the current channel and amounts[claim.tokenIndex] for the final token withdrawal.
    • The patch commit (f2abccfaabbb43322104cdf9210db62960dca287, “Merkle orchard: claim fixes (#2269)”) removed the cross-entry aggregation path and instead called _setClaimedBits(currentChannelId, claimer, distributionWordIndex, currentBits) and _deductClaimedBalance(currentChannelId, claim.balance) for each claim before continuing.
    • Because Balancer’s Merkle Orchard ultimately withdrew through Vault internal balances, the bug was directly monetizable even though the Orchard itself was a rewards helper rather than a full vault.
    • The vulnerable shape is easy to miss in review because the code did maintain a claimed bitmap and did verify proofs; what failed was the ordering between in-memory aggregation and consumed-state commitment.
  • Representative transactions and contracts:
    • Vulnerable Merkle Orchard contract: 0xdAE7e32ADc5d490a43cCba1f0c736033F2b4eFca
    • Balancer Vault: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Pre-fix source shape preserved in Balancer repo parent commit a8e5f93c555cc842af586f74022f48dbcd58bf67, where _processClaims() grouped claims sets and deferred _setClaimedBits() / _deductClaimedBalance() until group rollover or final loop iteration.
    • Fix commit: f2abccfaabbb43322104cdf9210db62960dca287 (Merkle orchard: claim fixes (#2269)), which switched to per-claim bitmap consumption and per-claim balance deduction.
    • Example historical claim transaction used in Balancer / Immunefi’s PoC setup: 0x7f80cc306ba86a25be806df13264e3c7e8363f0295c822fa302378bd38ab0727 at block 15837793
    • Example distributor in the public PoC leaf: 0xd2EB7Bd802A7CA68d9AcD209bEc4E664A9abDD7b
    • Example token used in the public PoC: BAL 0xba100000625a3754423978a60c9317c58a424e3D
  • Response / mitigation:
    • Whitehat 0xriptide reported the bug to Balancer on 2023-01-22.
    • Balancer publicly disclosed the issue after mitigation and paid a 50 ETH bounty even though Merkle Orchard was outside formal bounty scope.
    • The immediate mitigation was operational rather than a live in-place patch: Balancer created new distributions to move Merkle Orchard funds to Balancer Treasury addresses on each affected network.
    • Balancer planned later redistribution from a patched Orchard deployment rather than leaving vulnerable balances parked in the legacy contract path.

Three entitlement-publication promotions this incident collapsed

  1. One valid Merkle leaf and proof existed

    • The attacker still needed a real, unclaimed Orchard entitlement with a genuine Merkle proof.
    • That only established this reward right exists. It did not yet establish this batch contains one fresh economic claim each time the row appears.
  2. Balancer promoted repeated copies of that same leaf into canonical fresh-claim truth inside the batch

    • _processClaims() verified every repeated entry and immediately added claim.balance into currentClaimAmount and amounts[claim.tokenIndex], while _setClaimedBits() still waited until claim-set rollover or the final loop iteration.
    • In other words, the contract published caller-supplied duplicate rows as if they were distinct newly exercised entitlements before uniqueness had actually been sealed.
  3. That batch-published claim story still inherited live Vault-withdraw authority

    • Once the inflated aggregate had been constructed, _deductClaimedBalance() and manageUserBalance(... WITHDRAW_INTERNAL ...) honored it against real balances parked in the Balancer Vault.
    • The protocol therefore turned one authentic reward proof into many units of immediately withdrawable balance because payout authority followed the duplicated batch story rather than the set of newly consumed rights.

The reusable review split is: a real proof exists, this exact batch row still counts as one fresh entitlement, and that accepted entitlement still deserves live withdrawal authority. Merkle Orchard collapsed all three into one happy-path assumption.

Why this incident matters

  • It is a very clean duplicate-claim case where the same entitlement can be consumed multiple times inside one batch even though the contract does maintain a claimed-bits bitmap.
  • That makes it a stronger analytical cousin to missing claimed bitmap entirely bugs: the protocol knew uniqueness mattered, but enforced it at the wrong time.
  • It also preserves a useful review lesson for Merkle distributors and batched reward systems: valid proof and unconsumed proof are separate checks, and both must hold before any payout amount is accumulated.
  • The whitehat disclosure matters on its own because this was a real drain path against live funds, not a toy edge case.

Pattern tags:

  • Same-batch duplicate-claim replay: the attacker did not need to forge a new entitlement; one real Merkle leaf could be repeated inside a single claimDistributions() batch before consumed-state commitment caught up.
  • Proof-validity versus uniqueness split: every duplicate entry still passed _verifyClaim(...), so the exploit lived in the gap between this proof is authentic and this entitlement has not already been counted in this batch.
  • Bitmap commit ordering failure: the claimed bitmap existed, but _setClaimedBits() ran only after the current claim set had already been summed into payout totals.
  • Rewards helper as live custody surface: because Merkle Orchard ultimately withdrew through Balancer Vault internal balances, an apparently auxiliary distributor helper still had direct line-of-sight to real treasury-like funds.
  • Design companions: duplicate-claim-idempotency-failure, exit-right-consumed-state-binding, and reward-entitlement-identity-and-batch-canonicalization.

Why the exploit worked

  • Balancer’s batch processor treated caller-supplied claims[] entries as additive payout records before proving they were a set of unique entitlements.
  • The claimed-bit guard protected only against already-committed state, not against the same still-uncommitted bit appearing repeatedly in the current in-memory batch.
  • Because _processClaims() inflated both currentClaimAmount and amounts[claim.tokenIndex] before _setClaimedBits() executed, one authentic Merkle entitlement could become many units of withdrawable Vault balance.
  • The vulnerability stayed fully monetizable because the Orchard did not merely track points or offchain accounting; it could immediately withdraw real token balances parked inside Balancer’s Vault.

Detection and audit pivots

  • For every Merkle distributor or batched rewards helper, ask whether the code first proves each entry is distinct and still unconsumed and only then accumulates payout totals. If the order is reversed, the helper is probably replayable.
  • Treat caller-supplied arrays as hostile multisets, not friendly lists. Explicitly fuzz duplicate entries, same-proof repeats, same-bit repeats inside one word, and sorted versus unsorted batches.
  • Compare proof verification, consumed-state write, and asset release as one pipeline. If uniqueness is only enforced after aggregation, the payout path is already trusting fiction.
  • Review bitmap / nonce / nullifier defenses for same-transaction behavior, not only cross-transaction replay. Many helpers protect against claimed yesterday while still failing on counted twice right now.
  • If an auxiliary distributor can pull real balances from a Vault, treasury, or reward escrow, audit it like custody code rather than like harmless accounting glue.

Failure-prevention checklist

  • Consume the entitlement before counting it. A Merkle leaf, nonce, bitmap bit, or nullifier should be committed at the first moment the contract decides this proof is good enough to pay, not after later aggregation logic finishes.
  • Canonicalize the batch key before summing anything. If batching depends on (token, distributor, wordIndex) or similar grouping, derive that grouping once, reject duplicates eagerly, and avoid any design where repeated entries can add value before uniqueness is sealed.
  • Treat user-supplied arrays as adversarial multisets. If the protocol wants a set, either enforce uniqueness onchain or accept only a canonicalized object whose uniqueness is already proven.
  • Keep proof authenticity, entitlement uniqueness, and balance release as one atomic pipeline. A valid Merkle proof should never increase any payout accumulator until the contract has also proved the claim is still unconsumed.
  • Make same-transaction replay part of the test plan. Bitmap, nonce, and nullifier systems should be fuzzed for repeated entries inside one call, not just for repeated calls across transactions.
  • Audit helper contracts that can reach Vault, treasury, or escrow balances as custody code. Rewards distributor is not a safe downgrade if one batch function can still withdraw real assets.
  • Prefer designs where the amount to withdraw is derived from the set of newly consumed rights, not from raw caller-provided rows. Aggregation should flow from committed state transitions, not from trusting the input list.

Audit prompts

  • At what exact line does the contract transition from this entitlement is valid to this entitlement is consumed, and can any payout accumulator grow before that transition lands?
  • If the same claim appears twice in one calldata array, which invariant rejects it: proof verification, duplicate-key detection, bitmap write, or nothing until after amounts were already summed?
  • Does the contract reason about claims as a canonical set, or does it merely hope the caller supplied one?
  • Are grouping keys such as token index, distributor, channel id, distribution word, epoch, or recipient reused across logic branches in a way that lets one still-uncommitted right be counted multiple times?
  • Does the final withdrawal amount come from distinct consumed entitlements, or from iterating over user input and trusting later bookkeeping to catch duplicates?
  • If a helper contract can move balances out of a central Vault or treasury, what prevents a pure accounting bug in that helper from becoming immediate asset loss?
  • What adversarial tests prove that duplicate proofs, duplicate bits, duplicate nullifiers, or reordered batches cannot increase net payouts?
  • If the code uses bitmaps, nonces, or nullifiers, are those state writes placed early enough that same transaction, same call, same proof repeated is impossible?

Lessons that should already be standard

  • Valid proof is only half the authorization story. Payment also requires valid proof that has not already been consumed in this exact batch.
  • A bitmap written late is often just a cross-transaction replay guard pretending to be an idempotency guarantee.
  • Batching logic is balance-sheet logic. The moment a helper can aggregate entitlements and withdraw from a Vault, it deserves the same suspicion as a vault withdrawal path.
  • Caller-supplied arrays are not sets unless the contract makes them sets. Reviewers should assume duplicate rows are deliberate attack input.
  • Merkle distributors, claim routers, and reward escrows fail in the same old way as bridges and vaults: when the protocol sums user input before it has committed the one-shot right being exercised.
  • The clean design target is stronger than proof verifies: the released amount should be a deterministic function of newly consumed entitlements and nothing else.

Internal linkages

Control surface