Balancer Logic Error Bugfix Review

1 year ago 109
BOOK THIS SPACE FOR AD
ARTICLE AD

On January 22, 2023, whitehat 0xriptide reported a high severity vulnerability to the Balancer protocol, a community-driven DeFi liquidity infrastructure provider. The bug itself would allow liquidity providers to submit duplicate claims to drain all the Merkle Orchard’s assets from the Vault.

At the time of the report submission, across Ethereum mainnet, Polygon, and Arbitrum, Balancer Vaults held around $3.2m of vulnerable funds. Though the Merkle Orchard contract was not part of the bug bounty program’s scope, Balancer awarded the whitehat a 50 ETH bounty due to the report’s relevance.

Balancer should be commended for having a well-run bounty program with a fast response time, and big bug bounty rewards that incentivize these kinds of funds-saving reports. Balancer made the bug public through this Twitter thread.

You can read 0xriptide’s blog post about his responsible disclosure here.

To better understand the vulnerability, we should briefly look into what Merkle trees are. These are data structures which encode and compress a number of different data blocks — nodes which we call “leaves” of the tree — into one single hash word — the Merkle tree “root”.

An example Merkle Tree

The above image illustrates a Merkle tree structure. Each tree leaf X is hashed to create Hₓ. After that, pairs of hashes are concatenated and hashed once again, so from Hₐ and Hᵦ we get H(Hₐ.Hᵦ) = Hₐᵦ. We will repeat this process of concatenation and hashing to finally get a final compressed root hash–in our case, HABCDEFGH (all letters after the initial H are in subscript).

Due to the irreversible nature of hash functions, we cannot reconstruct the leaves from the single tree root. However, we can use these structures to cryptographically prove that a given leaf belongs to the tree.

Looking at our Merkle tree once again, let’s say Alice wants to prove Bob leaf F is present in this Merkle tree, and Bob just has the final computed Merkle root. Alice just needs to provide a Merkle proof, which in this case will be HE, HGH and HABCD. Bob will hash leaf F to get HF. Then it will concatenate it with HE and hash that again. The result will be concatenated with HGH and hashed. Finally, this result will be combined with HABCD and hashed one last time. If this result corresponds to the Merkle tree root that Bob has stored, then Bob has just verified that leaf F was part of the dataset that generated the original root.

The Merkle Orchard contracts were implemented in late 2021. They were used to distribute token incentives before Balancer protocol migrated to their new ve-tokenomics in early 2022.

The idea behind it was for a liquidity provider to be able to claim reward distributions of multiple tokens in a single transaction. That’s done by calling MerkleOrchard.claimDistributions, which calls the internal function _processClaims.

Snippet 1: initial portion of _processClaims

The function will process an array of claims. Each Claim has a distributionId. From that id value, _getIndices will compute a word index and a bit index, which will be used throughout the rest of the function. In a similar fashion, we get the channel id from tokens[claim.tokenIndex] and claim.distributor using the internal function _getChannelId.

Snippet 2: _getChannelId function

Various parts of this initial portion of _processClaims were cut in this snippet, but from what we have here we can see what happens when we have duplicate claims in our array. The channel id will be the same as the current one because it returns the same bytes32 value for each Claim, which means we will enter into the first if statement. Because currentWordIndex == distributionWordIndex also evaluates to true for duplicate claims, it will effectively bypass _setClaimedBits. This function is for setting bits in a bitmap which tracks the committed claims, and reverts the transaction if we try to set already registered bits.

A final noteworthy aspect of the previous snippet is that _setClaimedBits is also skipped for the first claim of the array, because currentChannelId is still zero. So submitting the same claim multiple times inside the array will simply keep on increasing currentClaimAmount without checking submitted bits on the bitmap.

Snippet 3: final portion of _processClaims

The bits of our repeated claim still need to be set, and fortunately the function does by calling setClaimedBits when the final element of the array is reached. The claim gets verified against the stored Merkle tree root, so it still needs to be valid and present a corresponding Merkle proof.

After completing the loop, the function will call manageUserBalance on Balancer’s Vault contract. The MerkleOrchard.claimDistributions function will execute _processClaims with asInternalBalance set as false, which means that kind will be set as WITHDRAW_INTERNAL and Vault.manageUserBalance will send out the funds from its internal balance to the recipient — the address claiming the distributions.

To create a PoC to showcase how one can indeed reuse claims on a single transaction, we first need to have funds to claim from the Merkle Orchard contract. For simplicity, we’ll be using an address that had rewards to claim in the past. We can use Dedaub’s library to check past Merkle Orchard transactions which executed claimDistributions. The selected transaction happened at block 15837793, so we will fork the Ethereum mainnet at block 15837792.

Part of the selected transaction’s calldata, from Phalcon

We will fetch the first claim used in this transaction. The token that we send to claimDistributions is the BAL token.

Snippet 4: our Attack contract

Our Attacker contract will simply put the same claim numerous times into an array. We will just have one token to claim.

Snippet 5: our Foundry test

All data is copied from the selected transaction aforementioned. We will repeat our claim 10 times, but it would be trivial to expand this PoC to drain all vulnerable funds.

Foundry test result

We’ve successfully executed the same claim 10 times, and we received 10 times the funds we were supposed to get. You can check the full PoC here.

Balancer mitigated the issue by creating new distributions to move Merkle Orchard tokens to the Balancer Treasury address on each network Balancer is present on. These funds will later be distributed via a patched Merkle Orchard to be deployed.

We would like to thank 0xriptide for doing an amazing job and making a responsible disclosure to Balancer. Big props also to the Balancer Labs team who did an amazing job responding quickly to the report and resolving it.

If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.

And if you’re feeling good about your skillset and want to see if you will find bugs in Balancer protocol contracts, check out their bug bounty program.

Read Entire Article