Sentiment Balancer Oracle Overborrow
Exploit Transactions
0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74dVictim Addresses
0x62c5aa8277e49b3ead43dc67453ec91dc6826403Arbitrum0xc0ac97A0eA320Aa1E32e9DEd16fb580Ef3C078DaArbitrum0x08F81E1637230d25b4ea6d4a69D74373E433Efb3ArbitrumLoss Breakdown
Similar Incidents
dForce Oracle Reentrancy Liquidation
36%Paribus Redeem Reentrancy
31%DEI burnFrom Allowance Inversion
27%Aave AMM LP Oracle Manipulation Through Delegated Recursive LP Looping
26%0VIX ovGHST Oracle Inflation
26%Midas LP Oracle Read-Only Reentrancy via Curve stMATIC/WPOL
24%Root Cause Analysis
Sentiment Balancer Oracle Overborrow
1. Incident Overview TL;DR
On Arbitrum transaction 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d in block 77026913, an unprivileged attacker used Aave V3 flash liquidity to open a Sentiment account, mint Balancer BPT collateral, inflate the BPT valuation seen by Sentiment, borrow more than the account could truly support, move the borrowed assets out, repay the flashloan, and keep the residual portfolio. The resulting bad debt hit Sentiment pools for USDC, USDT, FRAX, and WETH.
The root cause is a compound protocol design failure. Sentiment valued the Balancer 33 WETH / 33 WBTC / 33 USDC BPT 0x64541216bafffeec8ea535bb71fbc927831d0595 with a same-transaction manipulable spot oracle, and its controller layer allowed borrowed value to be redirected out of the account through public Aave and Balancer entrypoints whose recipient-style parameters were not bound to the Sentiment account.
2. Key Background
Sentiment isolates each user position inside an account contract and routes privileged actions through AccountManager at 0x62c5aa8277e49b3ead43dc67453ec91dc6826403. After each borrow or exec, it asks RiskEngine at 0xc0ac97A0eA320Aa1E32e9DEd16fb580Ef3C078Da whether the account is still healthy.
During the incident, Sentiment allowed the Balancer weighted-pool BPT 0x64541216bafffeec8ea535bb71fbc927831d0595 as collateral and priced it through OracleFacade at 0x08F81E1637230d25b4ea6d4a69D74373E433Efb3, which routed that token to WeightedBalancerLPOracle at 0x16f3ae9c1727ee38c98417ca08ba785bb7641b5b.
The critical architectural detail is that Balancer weighted-pool LP pricing is derived from live Balancer Vault balances. That means a borrower who can temporarily change pool balances inside the same transaction can also temporarily change the price that Sentiment uses for solvency checks.
Sentiment also used a controller facade to approve external protocol calls. The relevant controllers here were BalancerController at 0xC243f6e368ac321Ccce360E8BAaa7525e4b1BDD9 and AaveV3Controller at 0xC44f3ae3950efb7735C179714D133BA62bE7BDec. Those controllers classify allowed assets, but they do not bind Balancer or Aave recipient parameters to the Sentiment account that is being protected.
3. Vulnerability Analysis & Root Cause Summary
This was an attack, not a pure arbitrage. Sentiment treated a same-transaction Balancer weighted-pool spot state as trustworthy collateral value, so the borrower could manufacture temporary collateral strength with a flashloan-backed Balancer join. RiskEngine converted account balances and borrows into ETH value using oracle.getPrice(token), and WeightedBalancerLPOracle computed BPT price from vault.getPoolTokens(...) and current token balances. Because those balances were borrower-controlled within the same transaction, the collateral check was manipulable at the exact moment the borrower invoked borrow.
That pricing flaw alone already enabled overborrowing, but the damage was amplified by the controller layer. AccountManager.exec only asked the controller whether the target call shape was acceptable, executed the external call, updated the account asset list, and then performed a final health check. AaveV3Controller.canCall validated only the asset parameter for supply(address,uint256,address,uint16) and withdraw(address,uint256,address), so the attacker could supply account-owned assets to Aave and withdraw them directly to the attacker helper contract.
The net effect was an end-to-end path from temporary oracle inflation to durable asset theft: inflated collateral enabled the borrows, permissive controller logic enabled immediate extraction, and once the Balancer state normalized the account was left unhealthy with bad debt.
4. Detailed Root Cause Analysis
The solvency path is explicit in the verified Sentiment code:
function borrow(address account, address token, uint256 amt) external {
if (ILToken(registry.LTokenFor(token)).lendTo(account, amt))
IAccount(account).addBorrow(token);
if (!riskEngine.isAccountHealthy(account))
revert Errors.RiskThresholdBreached();
}
function exec(address account, address target, uint256 amt, bytes calldata data) external {
(bool isAllowed, address[] memory tokensIn, address[] memory tokensOut) =
controller.canCall(target, (amt > 0), data);
if (!isAllowed) revert Errors.FunctionCallRestricted();
(bool success,) = IAccount(account).exec(target, amt, data);
if (!success) revert Errors.AccountInteractionFailure(account, target, amt, data);
_updateTokensIn(account, tokensIn);
_updateTokensOut(account, tokensOut);
if (!riskEngine.isAccountHealthy(account))
revert Errors.RiskThresholdBreached();
}
RiskEngine in turn valued both assets and borrows through the live oracle path:
function isAccountHealthy(address account) external view returns (bool) {
return _isAccountHealthy(_getBalance(account), _getBorrows(account));
}
function _valueInWei(address token, uint amt) internal view returns (uint) {
return oracle.getPrice(token).mulDivDown(
amt,
10 ** ((token == address(0)) ? 18 : IERC20(token).decimals())
);
}
The BPT oracle path was the exploitable breakpoint:
function getPrice(address token) external view returns (uint) {
(address[] memory poolTokens, uint256[] memory balances,) =
vault.getPoolTokens(IPool(token).getPoolId());
uint256[] memory weights = IPool(token).getNormalizedWeights();
...
invariant = invariant.mulDown(
(balances[i] * 10 ** (18 - IERC20(poolTokens[i]).decimals())).powDown(weights[i])
);
return invariant.mulDown(temp).divDown(IPool(token).totalSupply());
}
That implementation prices the BPT from the instantaneous Balancer Vault balances, not from a time-weighted or manipulation-resistant state. The attacker therefore used public flashloans and public Balancer joins to distort those balances before calling back into Sentiment.
The collector trace shows the exact ordering. During the Balancer exitPool, the vault unwraps WETH into ETH and calls back into the attacker helper; from that callback the attacker performs Sentiment borrows before exitPool returns:
0xBA12222222228d8Ba445958a75a0704d566BF2C8::exitPool(...)
aeWETH::withdraw(10004998167081932212940)
0x9f626F5941FAfe0A5b839907d77fbBD5d0deA9D0::fallback{value: 10004998167081932212940}()
AccountManager::borrow(... USDC, 461000000000)
AccountManager::borrow(... USDT, 361000000000)
AccountManager::borrow(... WETH, 81000000000000000000)
AccountManager::borrow(... FRAX, 125000000000000000000000)
The controller-side extraction breakpoint is equally direct. AaveV3Controller accepts Aave supply and withdraw by inspecting only the asset field:
function canCall(address target, bool, bytes calldata data)
external
view
returns (bool, address[] memory, address[] memory)
{
bytes4 sig = bytes4(data);
if (sig == SUPPLY) {
address asset = abi.decode(data[4:], (address));
...
return (true, tokensIn, tokensOut);
}
if (sig == WITHDRAW) {
address asset = abi.decode(data[4:], (address));
...
return (true, tokensIn, tokensOut);
}
return (false, new address[](0), new address[](0));
}
Because onBehalfOf and to are not constrained, the attacker used AccountManager.exec to:
- supply account-owned USDC, USDT, and WETH into Aave on behalf of the Sentiment account,
- immediately withdraw those Aave positions to attacker-controlled address
0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0, - finish the Balancer unwind and repay the flashloan.
The balance diff confirms the protocol loss and attacker gain after the unwind normalized:
{
"usdc_pool_delta": "-461000000000",
"usdt_pool_delta": "-361000000000",
"frax_pool_delta": "-125000000000000000000000",
"weth_pool_delta": "-81000000000000000000",
"attacker_eoa_profit": {
"weth": "29973117081932212940",
"wbtc": "51695721",
"usdc": "538399328226",
"usdt": "360000000000"
}
}
That combination of same-tx oracle manipulation and permissive value extraction is the full root cause.
5. Adversary Flow Analysis
The adversary cluster was:
- EOA
0xdd0cdb4c3b887bc533957bc32463977e432e49c3, the transaction sender and final profit recipient. - Helper contract
0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0, which received the flashloan, executed the exploit, and later self-destructed. - Sentiment account
0xdf346f8d160424c79cb8e8b49b13dd0ca61c3b8c, created during the same transaction and used as the temporary collateral/debt container.
The execution flow was:
- Flashloan bootstrap: Aave V3 lent
606 WBTC,10050.1 WETH, and18,000,000 USDCto the helper contract. - Sentiment account setup: the attacker opened account
0xdf346..., deposited50 WETH, and usedAccountManager.execto join the Balancer pool so the account held221.214516056534546564BPT as collateral. - Oracle inflation: the helper performed a direct Balancer join with the flashloaned inventory, temporarily increasing the BPT price that Sentiment saw through
WeightedBalancerLPOracle. - Borrow during exit callback: the helper started a Balancer
exitPool; while the exit was in progress and the manipulated state was still visible, the helper’s ETH callback invoked Sentiment borrows for USDC, USDT, WETH, and FRAX. - Externalization: the helper swapped part of FRAX to USDC, then used the permissive Aave controller path to supply account-owned assets into Aave and withdraw them directly to the helper contract.
- Unwind and realization: after the Balancer exit finished, the helper repaid Aave flashloan principals and fees, then transferred the residual WETH, WBTC, USDC, and USDT to the attacker EOA.
Every step used public contracts and public calldata surfaces. No privileged key, whitelisted role, or attacker-only contract right was required.
6. Impact & Losses
Sentiment ended the transaction with an unhealthy account and bad debt across four lending pools:
- USDC:
461000000000raw units (461,000 USDC, 6 decimals) - USDT:
361000000000raw units (361,000 USDT, 6 decimals) - FRAX:
125000000000000000000000raw units (125,000 FRAX, 18 decimals) - WETH:
81000000000000000000raw units (81 WETH, 18 decimals)
The attacker EOA finished with a positive residual portfolio after flashloan repayment:
29.97311708193221294 WETH0.51695721 WBTC538399.328226 USDC360000 USDT
The measurable protocol impact was depletion of isolated Sentiment lending pools plus an undercollateralized account whose post-manipulation collateral value no longer covered the debt.
7. References
- Seed transaction:
0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d - Collector metadata:
/workspace/session/artifacts/collector/seed/42161/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d/metadata.json - Collector trace:
/workspace/session/artifacts/collector/seed/42161/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d/trace.cast.log - Collector balance diff:
/workspace/session/artifacts/collector/seed/42161/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d/balance_diff.json - Sentiment AccountManager proxy:
0x62c5aa8277e49b3ead43dc67453ec91dc6826403 - Sentiment RiskEngine:
0xc0ac97A0eA320Aa1E32e9DEd16fb580Ef3C078Da - Sentiment OracleFacade:
0x08F81E1637230d25b4ea6d4a69D74373E433Efb3 - WeightedBalancerLPOracle:
0x16f3ae9c1727ee38c98417ca08ba785bb7641b5b - AaveV3Controller:
0xC44f3ae3950efb7735C179714D133BA62bE7BDec - BalancerController:
0xC243f6e368ac321Ccce360E8BAaa7525e4b1BDD9