Overview
Late last year the ChainSecurity team reported a vulnerability via Immunefi involving read-only reentrancy that we thought might render getRate() and related functions manipulable, and thus unsafe to use in many contexts.
Upon further investigation, we realized it actually applied to a broader class of pools (vs. only pools with certain tokens), but it still seemed limited to potential interactions with partner integrations. We didn’t see a way to exploit it directly, so thought it was sufficient to document the potential dangers for integrators.
After the holiday break, looking at the issue with fresh eyes, we realized there was in fact a possible exploit on the pools themselves: albeit a very obscure and inefficient one. Nevertheless, Balancer Labs took immediate measures to reduce the chance of an exploit.
Context
It turns out there were four classes of pools:
A) Pools and pool types that were unaffected.
B) Pools that have unsafe external rate functions, but are not vulnerable on their own.
C) Pools where unsafe view function calls (e.g., getRate
) could lead to a DoS attack, but could be mitigated through governance actions, mainly by placing pools into Recovery Mode, which turns off protocol fees and sends the contracts down code paths that are not vulnerable: i.e., they become type B pools.
The Emergency SubDAO multisig acted to place these pools in Recovery Mode. It also disabled the factories that had this feature, so that no new vulnerable pools could be created.
D) Pools where the unsafe rates could lead to loss of funds: mainly, nested Composable Stable pools. These pools could not be mitigated, and could also not be paused, since we are already outside the factory’s pause window.
Balancer’s Twitter disclosed the list of affected pools that had liquidity, and asked LPs to withdraw immediately. Also, governance acted to kill the associated gauges.
Affected Contracts
Stable Pool
Phantom Stable Pool
Linear Pool (V3)
Composable Stable Pool (V1, V2)
Weighted Pool (V2)
Managed Pool
Vulnerability Brief
The Balancer Vault is non-reentrant, meaning you cannot initiate a Vault interaction if you are already in the context of another: i.e., you cannot swap inside a join, or exit a pool and transfer internal balance in the same interaction.
Of course, that doesn’t mean you can’t call the Vault at all during an operation. Read-only reentrancy occurs when a contract calls a view (read-only) function on the Vault. This is generally perfectly safe, since by definition view functions cannot modify contract state: and you can’t get up to much mischief without modifying state.
Pool contracts are also non-reentrant for the most part, and likewise internally safe. However, there is no way to automatically enforce a reentrancy scope that encompasses both the Vault and pools: especially when pools are nested inside each other.
It is possible, then, for read-only reentrancy to result in pools receiving stale data during joins and exits, and then using it to update pool state, in such a way as to cause incorrect behavior on subsequent operations.
In a nutshell, the vulnerability arises from the fact that the total supply (which is updated in the pool code), and the balances (updated in the Vault) can get out of sync in certain circumstances. Calling the Vault from an outside contract - which succeeds because it’s outside the Vault reentrancy context - can mislead the pools into updating their own state in ways that can be exploited. For instance, they can cause exorbitant protocol fees to be paid, or maliciously update caches to manipulate rates, making it possible to extract value from the pool via incorrect pricing: i.e., tricking pools into making bad trades.
In _onJoinOrExit
note that the _callPoolBalanceChange
function (which processes token transfers) is called before the set balance functions, which update the pool accounting. _callPoolBalanceChange
calls _processJoinPoolTransfers
, which calls _handleRemainingEth
unconditionally: effectively making every token a callback token, since if you send any unused ETH in a join, it will be returned to the caller. If this caller is a malicious attacker contract, it can define a fallback function that calls into the pool and updates state: for instance, the token rate cache. Since (in the case of affected pools) this happens after the BPT minting but before the token balances are updated in the Vault, the invariant and rate calculations - anything that relies on balances or supply (especially the relationship between them) - will be wrong, and can be manipulated.
Here’s how that can work when joining a Weighted Pool (the simplest example):
Weaponizing the Bug
As mentioned before, we grouped pools into 4 different types. Type A pools are unaffected (these are typically pools that don’t support regular joins or exits to begin with), and Type B pools are subject to their own view functions being affected (notably getRate()
, which can be caused to return bogus values) - but their own operation remains secure.
Type C pools use a protocol fee percentage cache, which can be updated by anyone. Before they update their local copy of the protocol fees however, they attempt to pay any fees due using the old protocol fee percentage, which involves looking at the growth of the invariant and BPT supply ratio over time. Since this can be manipulated, these pools can be tricked into paying excess protocol fees by forcing a reentrant protocol fee cache update during an exploited call to joinPool
. This has been mitigated by placing these pools into Recovery Mode, which overrides all protocol fee percentages and sets them to zero, ignoring the cache and therefore preventing any excess payout of fees.
Type D pools are Stable pools that contain the BPT of another Type B Pool as one of their tokens. Stable pools feature a rate cache for their underlying tokens, and as such it is possible to force a Type D pool to update its rate cache for a vulnerable Type B while manipulating its rate, resulting in the Stable pool mis-pricing the asset. Since Stable pools are fairly insensitive to fluctuations in their balances, profiting from this attack is non trivial and requires significant access to capital to compensate for the cost of the attack. The only mitigation that exists here is to increase the amplification factor, which will increase the economic cost of the attack (by requiring more capital and gas).
Ongoing Mitigation Efforts
We considered doing a white hat hack to recover and distribute the remaining funds, but the response to our call to withdraw liquidity was very strong and effective. Around 85% of the liquidity had been withdrawn in the first 24 hours, and at the time of writing, it is nearly all gone. The highest liquidity DOLA pool went from $8m to around $130k (now down to $4k, as expected after removing incentives). At the time of writing, the total TVL of all affected pools together is < $50k.
We disclosed it privately to a long list of partners that were potentially affected, and have been working with them all along to ensure that any vulnerabilities were identified and patched in advance of this announcement. To the best of our knowledge and belief, these have all been addressed.
We are continuing efforts to contact LPs, directly and through partners, to reduce the liquidity even further. We also expect that ending incentives (see the proposal above to kill the gauges) will take out most of the stragglers.
Given the complexity and capital inefficiency of the exploit, we believe it is not profitable at these liquidity levels. The risk has decreased to the point where a white hat effort might actually be more costly than a hack, especially considering the dev time and resources we would need to devote to do it safely.
Conclusion and Future Plans
We’ve done all we can to contain and mitigate this vulnerability: and quite successfully. No funds have been lost, and the chances of an exploit decrease with each passing day.
The downside of such an aggressive response is that we are now in a state where our revenue stream from protocol fees is near zero, and creation of new Weighted, Stable, and Managed Pools has been halted.
Luckily, the changes required to render the pools safe are very minor, and are already in review. (We are also enlisting prominent partners in the review effort, to ensure that we have not missed anything in our analysis.)
A permanent fix would of course require a Vault migration (i.e., rearranging the order of calls so that balances are in sync before any external calls), which - at least for mainnet - will have to wait for V3, as it would be too disruptive to migrate to a new Vault at this time, especially when there are far simpler (and equally effective) fixes that can be made at the pool level.
At the pool level, we simply need to ensure that no state-altering functions on pools can be called externally when inside the Vault’s re-entrancy context: i.e., a public call that updates a protocol fee or rate cache can be called externally on its own, but cannot be called during a Vault operation (swap/join/exit). The simplest way to do this is to wrap the call with a function that cheaply enters the Vault context: which would revert if we were already in that context.
To restore normal operations, new versions of the affected pools will be deployed, hopefully within the next couple of weeks, and liquidity providers will be able to migrate their funds at that time. (Any incentives will of course be moved to the new pools.) We will endeavor to make this transition as smooth and painless as possible, and most users will be able to migrate directly through the Balancer UI.
We will provide timely updates on this thread, and on Twitter and Discord, if there are major new developments.
Vulnerable Pools
Mainnet
- Balancer DOLA bb-a-usd Stable (0x5b3240b6be3e7487d61cd1afdfc7fe4fa1d81e6400000000000000000000037b)
- Balancer Aave Boosted StablePool (0xa13a9247ea42d743238089903570127dda72fe4400000000000000000000035d)
- Balancer 50wstETH-50bb-a-USD (0x25accb7943fd73dda5e23ba6329085a3c24bfb6a000200000000000000000387)
- Balancer 50STG-50bb-a-USD (0x4ce0bd7debf13434d3ae127430e9bd4291bfb61f00020000000000000000038b)
- Balancer 50BADGER-50rETH (0xe340ebfcaa544da8bb1ee9005f1a346d50ec422e000200000000000000000396)
- cbETH-wstETH StablePool (0x4edcb2b46377530bc18bb4d2c7fe46a992c73e100000000000000000000003ec)
- Balancer 50TEMPLE-50bb-a-USD (0x173063a30e095313eee39411f07e95a8a806014e0002000000000000000003ab)
- sfrxETH-stETH-rETH StablePool (0x8e85e97ed19c0fa13b2549309965291fbbc0048b0000000000000000000003ba)
- Balancer 50rETH-50bb-a-USD (0x334c96d792e4b26b841d28f53235281cec1be1f200020000000000000000038a)
- Balancer 50rETH-50RPL (0x0fd5663d4893ae0d579d580584806aadd2dd0b8b000200000000000000000367)
- Balancer 50wstETH-50LDO (0x6a5ead5433a50472642cd268e584dafa5a394490000200000000000000000366)
- Balancer 50wstETH-50ACX Pool (0x798b112420ad6391a4129ac25ef59663a44c88bb0002000000000000000003f4)
- Tranchess qETH/ETH Balancer Pool (0xc9c5ff67bb2fae526ae2467c359609d6bcb4c5320000000000000000000003cc)
- Balancer 50COMP-50wstETH (0x496ff26b76b8d23bbc6cf1df1eee4a48795490f7000200000000000000000377)
- Balancer graviAURA Stable Pool (0x6a9603e481fb8f2c09804ea9adab49a338855b900000000000000000000003a8)
- 4Pool Chainlink (0xbd482ffb3e6e50dc1c437557c3bea2b68f3683ee0000000000000000000003c6)
- TUSD bb-a-usd Stable Pool (0x2ba7aa2213fa2c909cd9e46fed5a0059542b36b00000000000000000000003a3)
- 4Pool Chainlink V2 (0x81b7f92c7b7d9349b989b4982588761bfa1aa6270000000000000000000003e9)
- Balancer 50wstETH-50UMA Pool (0x43bdd55d9c98ae9184dc3a869ab89a83762156d50002000000000000000003f3)
- FIAT bb-a-usd StablePool (0xac976bb42cb0c85635644e8c7c74d0e0286aa61c0000000000000000000003cb)
- FRAX Base Pair (0x4c8d2e60863e8d7e1033eda2b3d84e92a641802000000000000000000000040f)
- Balancer TUSD bb-a-usd Stable (0x53bc3cba3832ebecbfa002c12023f8ab1aa3a3a0000000000000000000000411)
- Balancer 50wstETH-50bb-a-USD (0xb9bd68a77ccf8314c0dfe51bc291c77590c4e9e6000200000000000000000385)
- 50USDC50AURA (0x5210287a2a440c06d7f3fcc4cc7b119ba8de433900020000000000000000037f)
- 50DC50WETH (0x7152a37bbf363262bad269ec4de2269dd0e84ca30002000000000000000003bd)
- FRAX Base Pair (0x9fb771d530b0ceba5160f7bfe2dd1e8b8aa1340300000000000000000000040e)
- Python Test Weighted Pool V2 (0x0ce45ba1c33e0741957881e05daff3b1e2954a9b000200000000000000000365)
Polygon
- Balancer MAI bb-am-usd Stable (0xb54b2125b711cd183edd3dd09433439d5396165200000000000000000000075e)
- Balancer stMATIC Stable Pool (0x8159462d255c1d24915cb51ec361f700174cd99400000000000000000000075d)
- Balancer Aave Boosted StablePool (0x48e6b98ef6329f8f0a30ebb8c7c960330d64808500000000000000000000075b)
- Balancer MaticX Stable Pool (0xb20fc01d21a50d2c734c4a1262b4404d41fa7bf000000000000000000000075c)
- Balancer csMATIC Stable Pool (0x02d2e2d7a89d6c5cb3681cfcb6f7dac02a55eda400000000000000000000088f)
- Balancer 50THX-50stMATIC (0x8ac5fafe2e52e52f5352aec64b64ff8b305e1d4a0002000000000000000007ab)
- Balancer 80SD-20maticX (0x4973f591784d9c94052a6c3ebd553fcd37bb0e5500020000000000000000087f)
- Balancer MAI bb-am-usd Stable (0xb54b2125b711cd183edd3dd09433439d5396165200000000000000000000075e)
- Balancer tetuQi StablePool (0x05f21bacc4fd8590d1eaca9830a64b66a733316c00000000000000000000087e)
- Balancer vQi StablePool (0xbf29ef6e23af0ac5b6bf931c8b3f1080f5bc120600000000000000000000091f)
- Balancer wUSDR bb-am-usd Stable (0x600bd01b6526611079e12e1ff93aba7a3e34226f0000000000000000000009e4)
- 2eur (agEUR) (0xa48d164f6eb0edc68bd03b56fa59e12f24499ad10000000000000000000007c4)
- Balancer aMATICc Composable Stable Pool (0x2d46979fd4c5f7a04f65111399cff3da2dab5bd9000000000000000000000807)
- Python Test Weighted Pool V2 (0x9d75cc71555ddabcb89b52c818c2c689e2191401000200000000000000000762)
- 2EUR (PAR) (0x7d60a4cb5ca92e2da965637025122296ea6854f900000000000000000000085e)
- 2SGD (0x92bc61bd96f275ea5507a3e1e5fbf0bc69cc98dc00000000000000000000085d)
- 4USD (0xe2dc0e0f2c358d6e31836dee69a558ab8d1390e70000000000000000000009fa)
- Python Test Composable Stable Pool (0x0a95e37fdc3853082e5100a91dbdfcc0e7a2bc8900000000000000000000087c)
- Python Test Composable Stable Pool 2 (0x2d09717c39f2e598d3283f77df339d55acdc9941000000000000000000000881)
- Balancer csMATIC Stable Pool (0x089443665084fc50aa6f1d0dc0307333fd481b85000000000000000000000884)
- 2BRL (BRZ) (0xe22483774bd8611be2ad2f4194078dac9159f4ba0000000000000000000008f0)
- Balancer MaticX Stable Pool (0xb553c155f95ab42b674d6fe501693e30d54a47e2000000000000000000000755)
- 2MXN (XOC) (0x9e0a3a9b5a4e0b6dc299a56ef19002f23842be8d000000000000000000000862)
- 2EUR (EURT) (0x94970a3f6a6aab442aefad68ff57abec0b9e3c0100000000000000000000085f)
- 2JPY (JPYC) (0x7f408fbcfc88917bff6a79b0ed0646fa090627de000000000000000000000863)
- 2TRY (TRYB) (0x7a4ffa9285aeb177e0806e03367ce3850438668a000000000000000000000891)
- 2TRY (0x7982c1b61abdc36942301ff2377d92b43784f120000000000000000000000861)
- 2EUR (EURe) (0x7913e4c8d00044689ff5c7c12d2f1b4a2fde4994000000000000000000000860)
- 2CAD (0x76afd126f46ab4fdf2ece8b1a2c149f7cf95d9fb00000000000000000000085c)
- FRAX-bb-am-USDC (0x7079a25dec33be61bbd81b2fb69b468e80d3e72c0000000000000000000009ff)
- 3BRL (0x47401399b2eca91930c99126df20a11531f99465000000000000000000000840)
- 2TRY (TRYB) (0x11202b1ec9271bf000c1d1ec7a4f70b93519a862000000000000000000000890)
Arbitrum
- Balancer stETH StablePool (0xfb5e6d0c1dfed2ba000fbc040ab8df3615ac329c000000000000000000000159)
- Balancer 50wstETH-50USDC (0x178e029173417b1f9c8bc16dcec6f697bc323746000200000000000000000158)
- Balancer 50wstETH-50LDO (0x13f2f70a951fb99d48ede6e25b0bdf06914db33f00020000000000000000016b)
- Balancer 50wstETH-50TENDIE (0xf93579002dbe8046c43fefe86ec78b1112247bb800020000000000000000021d)
- Python Test Weighted Pool V2 (0x3dd0843a028c86e0b760b1a76929d1c5ef93a2dd000200000000000000000130)
- Balancer 50wstETH-50WETH (0x2d011adf89f0576c9b722c28269fcb5d50c2d179000200000000000000000157)
- Balancer Aave Boosted StablePool (0xcc98357eaf3e227bdef6b97780aae84bea9b02b00000000000000000000000fe)
Optimism
- It’s MAI Life (0x1f131ec1175f023ee1534b16fa8ab237c00e238100000000000000000000004a)
- Smells Like Spartan Spirit: (0x479a7d1fcdd71ce0c2ed3184bfbe9d23b92e8337000000000000000000000049)
- Lido Shuffle (0xde45f101250f2ca1c0f8adfc172576d10c12072d00000000000000000000003f)
- Overnight Pulse (0xb1c9ac57594e9b1ec0f3787d9f6744ef4cb0a02400000000000000000000006e)
- Steady Beets, Boosted (0x6222ae1d2a9f6894da50aa25cb7b303497f9bebd000000000000000000000046)
- Here comes the Sonne (0x428e1cc3099cf461b87d124957a0d48273f334b100000000000000000000007f)
- optimism Test StablePhantom Pool (0xb0f2c34b9cd5c377c5efbba3b31e67114810cbc8000000000000000000000030)
- Overnight Pulse (0x7d6bff131b359da66d92f215fd4e186003bfaa42000000000000000000000058)
- Test Boosted USD (0x9964b1bd3cc530e5c58ba564e45d45290f677be2000000000000000000000036)
- Test Boosted USD/MAI (0xf572649606db4743d217a2fa6e8b8eb79742c24a000000000000000000000039)
- Test Boosted WETH (0x62de5ca16a618e22f6dfe5315ebd31acb10c44b6000000000000000000000037)
- Overnight Pulse (0x96a78983932b8739d1117b16d30c15607926b0c500000000000000000000006d)
- Test Boosted USD/MAI (0x593acbfb1eaf3b6ec86fa60325d816996fdcbc0d000000000000000000000038)
- Enter the Stargate (0x05e7732bf9ae5592e6aa05afe8cd80f7ab0a7bea00020000000000000000005a)
- Yellow submarine, our yield machine (0x981fb05b738e981ac532a99e77170ecb4bc27aef00010000000000000000004b)
- Galactic Dragon (0x785f08fb77ec934c01736e30546f87b4daccbe50000200000000000000000041)
- Happy Road reloaded (0xb0de49429fbb80c635432bbad0b3965b2856017700010000000000000000004e)
- Lido’s Swan Song (0xc77e5645dbe48d54afc06655e39d3fe17eb76c1c00020000000000000000005c)
- Wonderwall (0x359ea8618c405023fc4b98dab1b01f373792a12600010000000000000000004f)
- Just BEET it (0xf30db0ca4605e5115df91b56bd299564dca0266600020000000000000000005d)
- Test Boosted Tricrypto (0xe0b50b0635b90f7021d2618f76ab9a31b92d009400010000000000000000003a)
- Helter Skelter (0x899f737750db562b88c1e412ee1902980d3a4844000200000000000000000081)
- Enter the Stargate (0x435272180a4125f3b47c92826f482fc6cc165958000200000000000000000059)
- Python Test Weighted Pool V2 (0x3fdb6fb126521a28f06893f9629da12f7b7266eb000200000000000000000031)
Fantom
- Tenacious Dollar (0xa10285f445bcb521f1d623300dc4998b02f11c8f00000000000000000000043b)
- Beets Yearn Boosted StablePool (USD)(0x5ddb92a5340fd0ead3987d3661afcd6104c3b757000000000000000000000187)
- Lock, staked and two smoking Fantoms (0xc0064b291bd3d4ba0e44ccfc81bf8e7f7a579cd200000000000000000000042c)
- Mor Steady Beets, Yearn Boosted (0xa55318e5d8b7584b8c0e5d3636545310bf9eeb8f000000000000000000000337)
- Pirate in C (0xeadcfa1f34308b144e96fcd7a07145e027a8467d000000000000000000000331)
- From gods, boosted and blessed (0xdfc65c1f15ad3507754ef0fd4ba67060c108db7e000000000000000000000406)
- My Beautiful Dark Twisted Decentralized Dollar (0x6da14f5acd58dd5c8e486cfa1dc1c550f5c61c1c0000000000000000000003cf)
- Smells like tinSPIRIT (0xecc53ac812123d471360ea3d90023318868b56a5000000000000000000000429)
- The One True Metronome (0x1352fd97a1828093bf375f62e088bc196facd1ee000000000000000000000404)
- The Grand Steady Orchestra Of TUSD, Yearn Boosted (0x31adc46737ebb8e0e4a391ec6c26438badaee8ca000000000000000000000306)
- My Beautiful Dark Twisted Decentralized Dollar (0x8d13d878e44e8005efc0db4a831b95f84cb4b1540000000000000000000003c6)
- Guqin Qi, Tarot Boosted (0xa9cb51abfbbf2ca877b290e988b453f8bf4ab630000000000000000000000430)
- My Beautiful Dark Twisted Decentralized Dollar (0x57793d39e8787ee6295f6a27a81b6cca68e85cdf000000000000000000000397)
- Beets Yearn Boosted StablePool (USD) (0x64dea772866476c9f88fbe95ee83664d6c909c1800000000000000000000022c)