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 USDC weighted pool, then exited that position while requesting native ETH back. Balancer’s exitPool path burned the attacker’s large BPT position before updating the Vault’s underlying token balances, and the ETH transfer used a low-level call() that handed execution back to the attacker mid-exit. Inside that callback window, Sentiment’s WeightedBalancerLPOracle.getPrice() read Balancer’s stale getPoolTokens() balances together with the already-reduced BPT totalSupply(), 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 with IPool(token).totalSupply() inside a plain view call.
    • Balancer’s vulnerable exitPool path could burn BPT, send ETH via low-level call(), and only then finalize Vault balances.
  • Root cause:
    • This was a read-only reentrancy exploit, not a classic write-state-twice reentrancy. 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 but getPoolTokens() 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.
  • Exploit path:
    1. The attacker executed the main exploit transaction 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d from attacker contract 0x9f626F5941FAfe0A5b839907d77fbBD5d0deA9D0.
    2. It took an Aave v3 flash loan of about 606 WBTC, 10,050.1 WETH, and 18,000,000 USDC.
    3. It opened Sentiment account 0xdf346f8d160424c79cb8e8b49b13dd0ca61c3b8c and deposited 50 WETH into the account.
    4. It added that 50 WETH as liquidity to Balancer’s 33 WETH / 33 WBTC / 33 USDC pool and received about 221.214516 BPT, which became the collateral seed inside Sentiment.
    5. Separately, outside the Sentiment account, it added the rest of the flash-loaned assets — about 606 WBTC, 10,000 WETH, and 18,000,000 USDC — to the same Balancer pool and received about 130,600.98 BPT.
    6. It then called Balancer exitPool on that huge temporary LP position while requesting the WETH leg as native ETH.
    7. During the ETH transfer callback, Balancer had already burned the 130,600.98 BPT being exited, so pool totalSupply() had collapsed from about 139,234.634 to about 8,633.65333, but vault.getPoolTokens() still reported the pre-exit balances of roughly 646.9350 WBTC, 10,666.3996 WETH, and 19,155,172.1668 USDC.
    8. Sentiment’s oracle read exactly that inconsistent state and repriced the remaining BPT collateral from about 0.220118561 ETH per BPT to about 3.55007307 ETH per BPT — a little over 16x higher.
    9. With the BPT now falsely marked up, the attacker borrowed about 461,000 USDC, 361,000 USDT, 81 WETH, and 125,000 FRAX from Sentiment lending pools.
    10. The attacker swapped 120,000 FRAX for about 119,949.112 USDC, then withdrew about 580,000 USDC, 360,000 USDT, and 80 WETH out of the Sentiment account while the collateral still looked solvent.
    11. 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.
  • 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, while getPoolTokens() 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
  • Response / mitigation:
    • Sentiment replaced its Balancer LP oracle contracts with versions whose getPrice() function is no longer view and 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.

Pattern tags

  • unsafe mid-exit publication of BPT value became live collateral truth
  • Balancer Vault balances + BPT totalSupply ceased to describe the same state during ETH exit callbacks
  • Sentiment priced raw callback-window state instead of a settled-state object
  • read-only reentrancy monetized through borrow and withdrawal authority, not direct storage corruption

Why the exploit worked

  • Balancer’s exitPool sequencing created an observable split state: the attacker’s large temporary BPT position had already been burned, but vault.getPoolTokens() still returned the larger pre-exit reserve set while ETH was being sent out through a low-level call().
  • Sentiment’s WeightedBalancerLPOracle.getPrice() multiplied those stale Vault balances by external token prices and divided by the already-reduced BPT totalSupply(), 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 state with durable backing for the account's collateral.

Detection and audit pivots

  • Flag any oracle that computes Balancer BPT value onchain from vault.getPoolTokens() plus totalSupply() 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 context primitive 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 / supply style 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 view refactor to bypass accidentally?

Lessons that should already be standard

  • view is 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 view code was the attack surface that mattered.
  • It shows why read-only reentrancy is 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

Control surface