Sentiment Balancer BPT Read-Only Reentrancy Exploit (2023-04-04)
- Name: Sentiment Balancer BPT read-only reentrancy exploit
- Date: 2023-04-04
- Protocol: Sentiment
- Chain: Arbitrum
- Category: reentrancy / read-only reentrancy / LP-token oracle mispricing / undercollateralized borrow
- Loss: about $1M
- Summary: The attacker used an Aave v3 flash loan to temporarily become a huge LP in Balancer’s
33 WETH / 33 WBTC / 33 USDCweighted pool, then exited that position while requesting native ETH back. Balancer’sexitPoolpath burned the attacker’s large BPT position before updating the Vault’s underlying token balances, and the ETH transfer used a low-levelcall()that handed execution back to the attacker mid-exit. Inside that callback window, Sentiment’sWeightedBalancerLPOracle.getPrice()read Balancer’s stalegetPoolTokens()balances together with the already-reduced BPTtotalSupply(), making the remaining small BPT position inside the attacker’s Sentiment account appear worth more than 16x its real value. The attacker then borrowed and withdrew real assets against that printed collateral value, repaid the flash loan, and kept the difference. - Trigger / entry point:
- Sentiment accepted Balancer BPT as collateral and priced it onchain through
WeightedBalancerLPOracle.getPrice(). - The oracle combined
vault.getPoolTokens()balances withIPool(token).totalSupply()inside a plainviewcall. - Balancer’s vulnerable
exitPoolpath could burn BPT, send ETH via low-levelcall(), and only then finalize Vault balances.
- Sentiment accepted Balancer BPT as collateral and priced it onchain through
- Root cause:
- This was a read-only reentrancy exploit, not a classic
write-state-twicereentrancy. The profitable action was making another protocol read inconsistent state during a callback window. - Sentiment’s oracle trusted Balancer Vault state at a moment when
totalSupply()had already gone down butgetPoolTokens()still returned pre-withdrawal balances. - Because the oracle entry point itself was
view, Sentiment could not use an ordinary state-mutating reentrancy guard there. - The resulting BPT price inflation turned a small real Balancer LP position into fake collateral, and Sentiment extended real borrowing power against that fake mark.
- This was a read-only reentrancy exploit, not a classic
- Exploit path:
- The attacker executed the main exploit transaction
0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74dfrom attacker contract0x9f626F5941FAfe0A5b839907d77fbBD5d0deA9D0. - It took an Aave v3 flash loan of about
606 WBTC,10,050.1 WETH, and18,000,000 USDC. - It opened Sentiment account
0xdf346f8d160424c79cb8e8b49b13dd0ca61c3b8cand deposited50 WETHinto the account. - It added that
50 WETHas liquidity to Balancer’s33 WETH / 33 WBTC / 33 USDCpool and received about221.214516BPT, which became the collateral seed inside Sentiment. - Separately, outside the Sentiment account, it added the rest of the flash-loaned assets — about
606 WBTC,10,000 WETH, and18,000,000 USDC— to the same Balancer pool and received about130,600.98BPT. - It then called Balancer
exitPoolon that huge temporary LP position while requesting the WETH leg as native ETH. - During the ETH transfer callback, Balancer had already burned the
130,600.98BPT being exited, so pooltotalSupply()had collapsed from about139,234.634to about8,633.65333, butvault.getPoolTokens()still reported the pre-exit balances of roughly646.9350 WBTC,10,666.3996 WETH, and19,155,172.1668 USDC. - Sentiment’s oracle read exactly that inconsistent state and repriced the remaining BPT collateral from about
0.220118561 ETHper BPT to about3.55007307 ETHper BPT — a little over16xhigher. - With the BPT now falsely marked up, the attacker borrowed about
461,000 USDC,361,000 USDT,81 WETH, and125,000 FRAXfrom Sentiment lending pools. - The attacker swapped
120,000 FRAXfor about119,949.112 USDC, then withdrew about580,000 USDC,360,000 USDT, and80 WETHout of the Sentiment account while the collateral still looked solvent. - After the Balancer exit completed and the temporary pool state normalized, the attacker used the borrowed and withdrawn assets to repay the flash loan and sent the remainder to EOA
0xdd0cdb4c3b887bc533957bc32463977e432e49c3.
- The attacker executed the main exploit transaction
- What the attacker needed to be true:
- Sentiment had to accept Balancer BPT as collateral and let its oracle be queried during a Balancer pool exit.
- The relevant Balancer pool exit had to return ETH via a callback-capable low-level
call(). - Balancer had to expose a moment where BPT supply was already burned but Vault balances still reflected the larger pre-exit pool.
- Sentiment had to trust that transient BPT valuation immediately for borrow and withdrawal decisions.
- Sentiment’s borrowable assets had to be deeper and more valuable than the small real BPT collateral the attacker actually owned.
- Technical anatomy worth remembering:
- This is the cleanest kind of read-only reentrancy: no one re-entered Sentiment to mutate its storage directly first; they re-entered Balancer, then forced Sentiment to read a false-but-valid-looking intermediate state.
- The printed price came from a denominator/numerator mismatch:
totalSupply()had already shrunk, whilegetPoolTokens()balances still described the pre-exit reserve set. - The attack was therefore both a reentrancy bug and an LP-token oracle bug. The callback created the stale read window, and the oracle design converted that stale read into fake solvency.
- Balancer’s own docs later made this integration lesson explicit: mission-critical onchain BPT pricing must first prove the Vault is not in a re-entrant context.
- Representative transactions and addresses:
- Main exploit transaction:
0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d - Attacker contract:
0x9f626F5941FAfe0A5b839907d77fbBD5d0deA9D0 - Attacker EOA:
0xdd0cdb4c3b887bc533957bc32463977e432e49c3 - Sentiment account used in the exploit:
0xdf346f8d160424c79cb8e8b49b13dd0ca61c3b8c - Mitigation transaction:
0xbeedfeb88f2d83eb9e26f586bf6001c29627202cd539ca93e99f1bd11d61ac25 - Resolution transaction:
0xfa324fb23cdac4b94f3dfb0071bc3075cde29917178753a720ef7905a21cc0e7
- Main exploit transaction:
- Response / mitigation:
- Sentiment replaced its Balancer LP oracle contracts with versions whose
getPrice()function is no longerviewand first performs a no-op Vault call to ensure Balancer is not currently in a Vault re-entrancy context. - Concretely, the fix uses
manageUserBalance(new IVault.UserBalanceOp[](0))as a no-op reentrancy check before reading pool state. - This incident also reinforced Balancer’s own integration guidance that onchain BPT pricing for lending or liquidation logic must explicitly defend against Vault read-only reentrancy.
- Sentiment replaced its Balancer LP oracle contracts with versions whose
Pattern tags
unsafe mid-exit publication of BPT value became live collateral truthBalancer Vault balances + BPT totalSupply ceased to describe the same state during ETH exit callbacksSentiment priced raw callback-window state instead of a settled-state objectread-only reentrancy monetized through borrow and withdrawal authority, not direct storage corruption
Why the exploit worked
- Balancer’s
exitPoolsequencing created an observable split state: the attacker’s large temporary BPT position had already been burned, butvault.getPoolTokens()still returned the larger pre-exit reserve set while ETH was being sent out through a low-levelcall(). - Sentiment’s
WeightedBalancerLPOracle.getPrice()multiplied those stale Vault balances by external token prices and divided by the already-reduced BPTtotalSupply(), so the oracle published a BPT price that only existed inside the callback frame. - Sentiment then let that callback-window mark immediately drive both borrowing and withdrawals. The protocol did not require a Balancer Vault context check, delayed settled snapshot, or any other canonical safe-read surface before treating the BPT price as collateral truth.
- Because the attacker kept a small real BPT seed inside the Sentiment account and only used the flash-loaned mega-position outside it to distort Balancer’s intermediate state, the protocol confused
temporary share of an in-flight pool statewithdurable backing for the account's collateral.
Detection and audit pivots
- Flag any oracle that computes Balancer BPT value onchain from
vault.getPoolTokens()plustotalSupply()or similar raw live-state reads without first proving the Vault is not already in a re-entrant context. - Treat pool exits that unwrap WETH to native ETH, or otherwise deliver assets through callback-capable external calls, as high-risk valuation windows even when the eventual reserve math is correct after settlement.
- Audit whether every solvency-changing path — borrow, withdraw, collateral toggle, deleverage, liquidation — uses the same guarded Balancer read surface rather than letting one helper path query pool state directly.
- Monitor for impossible same-transaction patterns: BPT marks that spike during a pool exit and normalize by transaction end, or borrow / withdraw sequences whose collateral ratio only looks valid during a transient exit frame.
- Ask the sharper interface question: if Balancer state is not safe to price mid-exit, where exactly is the protocol’s machine-checkable settled-state object, and do all integrations consume that object instead of raw Vault reads?
Failure-prevention checklist
- Route all Balancer BPT solvency reads through one canonical safe-read surface. Borrows, withdrawals, collateral changes, leverage helpers, liquidations, and account-health previews should all consume the same
Vault not in re-entrant contextprimitive instead of each integrating Balancer pool math directly. - Separate informational pricing from debt-authoritative pricing. Balancer’s own docs are explicit that the simple
balances / supplystyle calculation is not safe for mission-critical onchain use; a UI estimate and a borrow-authoritative collateral mark should not share the same trust assumptions. - Require a machine-checkable Vault-context guard before reading live Balancer balances for collateral purposes. Sentiment’s postmortem fix — a no-op state-changing Vault call that fails inside the callback window — is the minimum pattern when same-transaction pricing must exist at all.
- Prefer delayed settled snapshots or same-block quarantine when the integration cannot prove a safe live read. If collateral truth depends on AMM exit sequencing, refusing same-transaction solvency authority is often healthier than layering more arithmetic onto unsafe state.
- Treat LP-collateral markets as bounded-loss surfaces. Conservative collateral factors, borrow caps, isolated listings, and fast pause paths should assume one missed read-only-reentrancy branch can still turn transient marks into real debt.
- Make wrapper and adapter reuse explicit. If one oracle pattern prices Balancer LPs for multiple pools or markets, hardening needs to happen in the shared oracle boundary, not by hoping each downstream market remembers the same Balancer-specific caveats.
- Test the real exploit shape end to end: small seed collateral kept inside the lending account, huge temporary LP minted outside it, pool exit with ETH callback, callback-window BPT price spike, then borrow and withdraw while the false mark is live.
- Monitor for solvency decisions that are only valid intra-transaction. If health checks pass during one callback frame and fail immediately after settlement, the protocol should treat that as a broken valuation surface, not normal market movement.
Audit prompts
- Which exact functions can turn a Balancer BPT mark into money movement or solvency authority today: borrow, withdraw, collateral enablement, deleverage, liquidation, refinance, or health-check helpers?
- Does every one of those paths use the same Balancer-safe oracle boundary, or can any helper still read raw
getPoolTokens()/ supply state without a Vault-context check? - Is the protocol using a price formula that Balancer documents as informational-only for an onchain collateral decision? If yes, what additional manipulation-resistance or reentrancy-context proof makes that safe?
- Can a user keep only a small real BPT position inside the lending account while using a much larger external temporary LP position to distort the denominator / numerator mix the oracle sees?
- What exact artifact proves the read happened outside the Balancer callback window: an explicit Vault reentrancy probe, a delayed snapshot, block-delay rules, or nothing at all?
- If the oracle answer spikes during a pool exit and normalizes before transaction end, which downstream actions can still borrow, withdraw, or otherwise crystallize the temporary overvaluation into durable bad debt?
- Are there any pool types or adapters where supply-selection details (
totalSupply,getActualSupply,getVirtualSupply) or ETH-unwrapping behavior differ enough that one Balancer integration could silently be weaker than another? - If the safe-read rule depends on a no-op state-changing call, is that call universally reused, cheap enough to run everywhere, and impossible for a future
viewrefactor to bypass accidentally?
Lessons that should already be standard
viewis not a safety property. A price read can still be counterfeit if it observes someone else’s AMM state in the middle of an unfinished exit.- LP-collateral pricing is a settlement-boundary problem, not only an oracle-formula problem. Even a mathematically reasonable formula becomes treasury-dangerous when its inputs no longer describe one coherent pool state.
- Informational pricing and borrow-authoritative pricing are different products. Balancer’s own docs warn against using the simple live-balance calculation onchain for mission-critical decisions; protocols that ignore that distinction are choosing convenience over solvency.
- A tiny real collateral seed plus a huge external temporary LP position is a recurring exploit shape. Reviewers should assume attackers will manipulate the pool around the account rather than only inside it.
- Safe-read boundaries belong in the shared oracle surface, not in institutional memory. The moment one integration learns
check Vault re-entrancy on all onchain price evaluations, that rule should become executable code everywhere the price can move money. - Same-transaction solvency authority is often the real risk amplifier. If a transient mark can immediately unlock borrowing and withdrawal, then normalization a few stack frames later does not save the protocol.
- Bounded-loss controls are part of oracle design. Caps, isolated markets, and pause levers matter because no team should assume perfect coverage of every callback-sensitive valuation path forever.
Why this incident matters
- Sentiment is one of the clearest real exploits where
viewcode was the attack surface that mattered. - It shows why
read-only reentrancyis not a harmless academic footnote: a protocol can lose money even when the inconsistent read happens in someone else’s state machine. - It is also a strong bridge between two failure families that often get discussed separately: reentrancy and oracle manipulation. Here the callback bug created the stale oracle input, and the oracle turned that stale input into real debt.
- Just as importantly, it preserves the healthy-side lesson that Balancer-style LP integrations needed one canonical settled-state read surface before any same-transaction BPT mark deserved collateral authority.
Internal linkages
- Core recurring pattern: read-only-reentrancy.
- Broader parent pattern: reentrancy.
- Sharper collateral-pricing angle: lp-token-oracle-mispricing.
- Positive-design companion: safe-read oracle interfaces.
- Timing-boundary companion: delayed snapshots and same-block quarantine.
- Useful comparisons: sturdy-finance-balancer-wsteth-weth-read-only-reentrancy-collateral-toggle-bypass-2023-06-12 and conic-finance-eth-omnipool-read-only-reentrancy-2023-07-21.
Control surface
-
The leverage sat in Balancer exit sequencing, ETH callback behavior, Sentiment’s Balancer-oracle implementation, and the protocol’s willingness to treat onchain BPT marks as immediate collateral truth.
-
Sentiment did not fail because Balancer had
a weird callback. It failed because a lending protocol had no canonical safe-read surface between Balancer’s in-flight Vault state and Sentiment’s borrow-authoritative collateral mark. -
Sources:
- https://hackmd.io/@sentimentxyz/SJCySo1z2
- https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d
- https://docs-v2.balancer.fi/concepts/advanced/valuing-bpt/valuing-bpt.html
- https://github.com/balancer/balancer-v2-monorepo/blob/master/pkg/pool-utils/contracts/lib/VaultReentrancyLib.sol
-
Last reviewed: 2026-07-19 UTC