Balancer bb-a-USD stale-rate exploit
Exploit Transactions
0x2a027c8b915c3737942f512fc5d26fd15752d0332353b3059de771a35a606c2dVictim Addresses
0x7b50775383d3d6f0215a8f290f2c9e2eebbeceb2Ethereum0x9210f1204b5a24742eba12f710636d76240df3d0Ethereum0x804cdb9116a10bb78768d3252355a1b18067bf8fEthereum0x2bbf681cc4eb09218bee85ea2a5d3d13fa40fc0cEthereumLoss Breakdown
Similar Incidents
OxODex Stale Withdrawal Drain
34%TheNFTV2 Stale Burn Approval
33%Floor DAO Stale Epoch Harvesting
31%Conic crvUSD Oracle Exploit
30%MahaLend Liquidity Index Inflation
30%Aave AMM LP Oracle Manipulation Through Delegated Recursive LP Looping
30%Root Cause Analysis
Balancer bb-a-USD stale-rate exploit
1. Incident Overview TL;DR
In Ethereum mainnet transaction 0x2a027c8b915c3737942f512fc5d26fd15752d0332353b3059de771a35a606c2d at block 18004652, an unprivileged adversary used a flash-loan-funded helper contract to distort the Balancer USDC Aave Linear Pool rate, refresh the bb-a-USD StablePhantomPool token-rate cache with that manipulated value, and then trade against the boosted pool while the cache remained stale. The path ended with repayment of the Aave flash loan and final profit of 114324.735265344932856250 DAI and 253461.006245 USDT. The root cause is that StablePhantomPool treated mutable linear-pool BPT rates as external rate-provider inputs and cached them for later pricing, even though those rates were synchronously attacker-manipulable through public swaps.
2. Key Background
bb-a-USD is a Balancer StablePhantomPool whose constituent tokens are the BPTs of the Balancer Aave USDC, DAI, and USDT linear pools. The boosted pool constructor configured those same linear-pool BPT contracts as external rate providers, each with a cache duration. That means the boosted pool did not compute constituent token value directly from fresh state on every economically relevant swap; instead it relied on cached values pulled from the linear pools.
The key point is that the linear-pool rate was not a trusted oracle. LinearPool.getRate() is derived from the live pool balances, target range, and wrapped-token state, so a trader who can move the linear pool can also move the reported rate. The attacker only needed public access to Aave flash loans, Balancer Vault swaps, and Uniswap V2 routing, all of which were permissionless at the exploit block.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an attack-class pricing flaw in cross-pool composition. StablePhantomPool multiplied token balances by cached external rates, but its configured providers were the linear-pool BPT contracts themselves. Those providers were mutable because their getRate() result depended on current linear-pool balances, and those balances could be changed by the attacker through normal Balancer swaps. During a swap, StablePhantomPool refreshed cached token rates only if the cache had expired, then reused the cached values for all later pricing in the same profitable path. As a result, once the attacker forced the USDC linear-pool rate to an extreme value and induced a cache refresh, subsequent boosted-pool pricing treated stale manipulated value as if it were trustworthy. The economically relevant invariant was that a boosted pool must not price constituent BPTs from stale attacker-manipulable rates. The concrete breakpoint sits in _onSwapGivenIn and _onSwapGivenOut, which call _cacheTokenRatesIfNecessary(), after which pricing reads go through getTokenRate() and _scalingFactors().
4. Detailed Root Cause Analysis
The collected StablePhantomPool source shows both the configuration and the stale-cache behavior:
for (uint256 i = 0; i < params.tokens.length; i++) {
if (params.rateProviders[i] != IRateProvider(0)) {
_updateTokenRateCache(params.tokens[i], params.rateProviders[i], params.tokenRateCacheDurations[i]);
emit TokenRateProviderSet(params.tokens[i], params.rateProviders[i], params.tokenRateCacheDurations[i]);
}
}
function _onSwapGivenIn(...) internal override returns (uint256 amountOut) {
_cacheTokenRatesIfNecessary();
...
}
function getTokenRate(IERC20 token) public view returns (uint256) {
bytes32 tokenRateCache = _tokenRateCaches[token];
return tokenRateCache == bytes32(0) ? FixedPoint.ONE : tokenRateCache.getRate();
}
The same contract later prices balances through cached token rates:
function _scalingFactors() internal view override returns (uint256[] memory scalingFactors) {
scalingFactors = super._scalingFactors();
if (totalTokens > 0) { scalingFactors[0] = scalingFactors[0].mulDown(getTokenRate(_token0)); }
if (totalTokens > 1) { scalingFactors[1] = scalingFactors[1].mulDown(getTokenRate(_token1)); }
if (totalTokens > 2) { scalingFactors[2] = scalingFactors[2].mulDown(getTokenRate(_token2)); }
}
The USDC linear-pool source shows why the provider was manipulable. getRate() reads current Vault balances and then feeds the main-token balance through LinearMath._toNominal, which depends on how far the live balance sits from the configured target range:
function getRate() external view override returns (uint256) {
(, uint256[] memory balances, ) = getVault().getPoolTokens(poolId);
_upscaleArray(balances, _scalingFactors());
(uint256 lowerTarget, uint256 upperTarget) = getTargets();
...
uint256 totalBalance = LinearMath._calcInvariant(
LinearMath._toNominal(balances[_mainIndex], params),
balances[_wrappedIndex]
);
}
function _toNominal(uint256 real, Params memory params) internal pure returns (uint256) {
if (real < params.lowerTarget) {
uint256 fees = (params.lowerTarget - real).mulDown(params.fee);
return real.sub(fees);
} else if (real <= params.upperTarget) {
return real;
} else {
uint256 fees = (real - params.upperTarget).mulDown(params.fee);
return real.sub(fees);
}
}
The seed trace for tx 0x2a027c8b... shows the end-to-end exploit path on-chain. The helper contract first receives the Aave USDC flash loan, then performs direct swaps through the USDC linear pool, then executes the bb-a-USD boosted-pool batch swap path, then exits the resulting linear-pool BPT into DAI, USDC, and USDT, swaps enough DAI back to USDC on Uniswap for repayment, and finally repays Aave:
emit FlashLoan(... amount: 300000000000, premium: 150000000)
...
emit Swap(poolId: 0x7b50775383d3d6f0215a8f290f2c9e2eebbeceb20000000000000000000000fe, ...)
...
emit Transfer(src: Vault, dst: 0x2100dCd8758aB8B89b9b545A43A1E47e8e2944f0, wad: 141127441135443677639857)
emit Transfer(src: Vault, dst: 0x2100dCd8758aB8B89b9b545A43A1E47e8e2944f0, wad: 253461006245)
...
emit FlashLoan(target: 0x2100dCd8758aB8B89b9b545A43A1E47e8e2944f0, ... amount: 300000000000, premium: 150000000)
The final asset deltas match the reported impact. The trace shows transfers of 114324735265344932856250 DAI and 253461006245 USDT to the adversary EOA, and the balance-diff artifact confirms a 150000000 USDC flash-loan premium repayment and no retained USDC debt at transaction end.
5. Adversary Flow Analysis
The adversary cluster consisted of EOA 0xed187f37e5ad87d5b3b2624c01de56c5862b7a9b and helper contract 0x2100dcd8758ab8b89b9b545a43a1e47e8e2944f0. The full execution was completed inside a single transaction.
- The helper borrowed
300000000000USDC from Aave V3. - It swapped a small amount of USDC into
stataUSDC, then performed the direct USDC linear-pool trades that distorted the linear-pool state and therefore itsgetRate(). - It entered the
bb-a-USDpath, causingStablePhantomPoolto refresh its token-rate cache from the manipulated provider values and then price follow-on swaps using the stale cache. - It redeemed the received DAI, USDC, and USDT linear-pool BPT through the relevant Balancer linear pools.
- It swapped part of the extracted DAI back to USDC on Uniswap V2, repaid
300150000000USDC to Aave, and transferred the residual DAI and USDT profit to the EOA.
This is a valid ACT realization because no privileged key, allowlist, or attacker-only infrastructure was required. Every relevant call path was public and synchronously executable from a fresh helper contract.
6. Impact & Losses
The stale-rate path extracted value from the boosted-pool complex into DAI and USDT. The reported and validated losses are:
- DAI:
114324735265344932856250raw units (114324.735265344932856250DAI,18decimals) - USDT:
253461006245raw units (253461.006245USDT,6decimals)
The affected public protocol components were the bb-a-USD StablePhantomPool at 0x7b50775383d3d6f0215a8f290f2c9e2eebbeceb2 and the linked Aave linear pools for USDC, DAI, and USDT.
7. References
- Exploit transaction:
0x2a027c8b915c3737942f512fc5d26fd15752d0332353b3059de771a35a606c2d - Victim boosted pool:
0x7b50775383d3d6f0215a8f290f2c9e2eebbeceb2 - USDC linear pool:
0x9210f1204b5a24742eba12f710636d76240df3d0 - DAI linear pool:
0x804cdb9116a10bb78768d3252355a1b18067bf8f - USDT linear pool:
0x2bbf681cc4eb09218bee85ea2a5d3d13fa40fc0c - Seed trace and metadata collected for the exploit tx
- Verified
StablePhantomPool.sol,LinearPool.sol, andLinearMath.solsource artifacts used above