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.2Min Merkle Orchard funds held across Balancer Vaults on Ethereum mainnet, Polygon, and Arbitrum - Summary: Balancer’s legacy
MerkleOrchardreward-distribution contract let liquidity providers batch multiple incentive claims in one call toclaimDistributions(). The bug was that_processClaims()accumulatedcurrentClaimAmountand per-tokenamounts[claim.tokenIndex]for every entry in the caller-suppliedclaimsarray, 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 viamanageUserBalance(... WITHDRAW_INTERNAL ...). - Trigger / entry point:
- The vulnerable user entry point was
MerkleOrchard.claimDistributions(address claimer, Claim[] claims, IERC20[] tokens)at0xdAE7e32ADc5d490a43cCba1f0c736033F2b4eFca. 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.
- The vulnerable user entry point was
- 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 setand 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.
- Balancer treated
- Exploit path:
- 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 transaction0x7f80cc306ba86a25be806df13264e3c7e8363f0295c822fa302378bd38ab0727. - They build a
Claim[]array that repeats that exact claim many times inside oneclaimDistributions()call. _processClaims()sees the duplicates as belonging to the same channel and same distribution word, so it keeps OR-ing the same bit intocurrentBitswhile still addingclaim.balanceintocurrentClaimAmountfor every repetition.- The same repeated entries also keep incrementing
amounts[claim.tokenIndex]used for the final Vault withdrawal. - The first claim in the batch is especially important: because
currentChannelIdstarts 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. - 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. 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._deductClaimedBalance()andmanageUserBalance()then process the inflated aggregate amount as if it represented distinct claims.- The attacker receives
N × claim.balancefor one real Merkle entitlement, bounded mainly by the remaining internal balances parked for that distribution token set.
- 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
- 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.solimplementation on Balancer’smerkle-orchard-v2branch 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 validandthis 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:
currentClaimAmountfor the current channel andamounts[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:
0x7f80cc306ba86a25be806df13264e3c7e8363f0295c822fa302378bd38ab0727at block15837793 - Example distributor in the public PoC leaf:
0xd2EB7Bd802A7CA68d9AcD209bEc4E664A9abDD7b - Example token used in the public PoC: BAL
0xba100000625a3754423978a60c9317c58a424e3D
- Vulnerable Merkle Orchard contract:
- Response / mitigation:
- Whitehat
0xriptidereported the bug to Balancer on 2023-01-22. - Balancer publicly disclosed the issue after mitigation and paid a
50 ETHbounty 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.
- Whitehat
Three entitlement-publication promotions this incident collapsed
-
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 establishthis batch contains one fresh economic claim each time the row appears.
-
Balancer promoted repeated copies of that same leaf into canonical fresh-claim truth inside the batch
_processClaims()verified every repeated entry and immediately addedclaim.balanceintocurrentClaimAmountandamounts[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.
-
That batch-published claim story still inherited live Vault-withdraw authority
- Once the inflated aggregate had been constructed,
_deductClaimedBalance()andmanageUserBalance(... WITHDRAW_INTERNAL ...)honored it against real balances parked in the Balancer Vault. - The protocol therefore turned
one authentic reward proofintomany units of immediately withdrawable balancebecause payout authority followed the duplicated batch story rather than the set of newly consumed rights.
- Once the inflated aggregate had been constructed,
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 entirelybugs: 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 proofandunconsumed proofare 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 betweenthis proof is authenticandthis 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 bothcurrentClaimAmountandamounts[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 unconsumedand 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, andasset releaseas 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 yesterdaywhile still failing oncounted 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, andbalance releaseas 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 distributoris 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 validtothis 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 repeatedis impossible?
Lessons that should already be standard
Valid proofis only half the authorization story. Payment also requiresvalid 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
- Core recurring pattern: duplicate-claim-idempotency-failure.
- Positive-design companion: exit-right-consumed-state-binding — the sharper question is what one Merkle entitlement
claimDistributions()should have consumed before any in-memory batch total was allowed to grow. - Adjacent pattern: migration-trust-boundary-failure — both families often look like operational helper code until a supposedly one-time entitlement becomes mintable or replayable.
Control surface
-
The decisive leverage sat in
_processClaims()’s batching logic: when claims were treated asrecords to sumbefore they were enforced asunique entitlements, the Merkle Orchard became drainable with one valid proof repeated enough times. -
The deeper lesson is that payout batching is itself part of custody logic. If uniqueness is only enforced after aggregation, the batcher has already become the exploit surface.
-
Sources:
- https://medium.com/balancer-protocol/logic-error-bug-fix-review-da37f0cc9a08
- https://medium.com/immunefi/balancer-logic-error-bugfix-review-74f5edca8b1a
- https://www.openzeppelin.com/news/the-notorious-b-u-g-digests-january-march-2023
- https://github.com/balancer/balancer-v2-monorepo/commit/f2abccfaabbb43322104cdf9210db62960dca287
-
Last reviewed: 2026-08-03 UTC