Sturdy Finance Balancer wstETH/WETH Read-Only Reentrancy / Collateral-Toggle Bypass (2023-06-12)

  • Name: Sturdy Finance Balancer wstETH/WETH read-only reentrancy / collateral-toggle bypass
  • Date: 2023-06-12
  • Protocol: Sturdy Finance
  • Chain: Ethereum
  • Category: read-only reentrancy / LP-token oracle mispricing / collateral-eligibility toggle bypass / undercollateralized withdrawal
  • Loss: 442 ETH stolen, with protocol loss commonly described as about 504 ETH after bad debt and liquidation effects; widely reported as roughly $800k at the time
  • Summary: The attacker used a huge Aave flash loan to build a temporary position in Balancer’s wstETH/WETH stable pool, deposited a small slice of that Balancer LP plus steCRV as collateral in Sturdy, and borrowed WETH. They then entered Balancer’s vulnerable exitPool path, where BPT supply had already been burned but underlying Vault balances had not yet updated. During that callback window, Sturdy’s oracle overvalued the remaining Balancer LP collateral by roughly 3x. Sturdy had already recognized Balancer getRate() as callback-sensitive and added _validateOracle() to most user-facing lending paths, but it never turned that guard into one canonical safe-read surface for all solvency-changing entrypoints. setUserUseReserveAsCollateral() still consumed the same Balancer LP price without the reentrancy-context check, so the attacker used that unguarded collateral-toggle path during the inflated-price window to disable steCRV as collateral, withdrew the steCRV, let the LP price normalize, then liquidated their own position at normal prices to reclaim the Balancer LP and repeat the process across fresh helper contracts.
  • Trigger / entry point:
    • The attacker targeted Sturdy’s Ethereum lending pool 0x9f72DC67ceC672bB99e3d02CbEA0a21536a2b657 and oracle 0xe5d78eB340627B8D5bcFf63590Ebec1EF9118C89.
    • The manipulated collateral asset was Sturdy’s cToken for the Balancer wstETH/WETH LP at 0x10aA9eea35A3102Cc47d4d93Bc0BA9aE45557746, backed by Balancer pool token 0x32296969Ef14EB0c6d29669C550D4a0449130230.
    • The attacker used helper contracts spawned from attack contract 0x0b09c86260c12294e3b967f0d523b4b2bcdfbeab, funded by EOA 0x1e8419e724d51e87f78e222d935fbbdeb631a08b.
    • The decisive unguarded entrypoint was Sturdy’s deprecated setUserUseReserveAsCollateral() path: unlike deposit, withdraw, borrow, and repay, it did not run _validateOracle() before trusting health-factor math.
  • Root cause:
    • This was a read-only reentrancy exploit whose profitable state read came from Balancer’s callback window: BPT supply had already fallen, while the Vault balances feeding getRate() still looked large.
    • Sturdy’s Balancer LP oracle treated that transient BPT overvaluation as real collateral value.
    • Sturdy had already recognized this attack family and added a defense, but only to some health-sensitive functions. The protocol failed because setUserUseReserveAsCollateral() was left outside the oracle-validation perimeter, so which entrypoint asked for the price quietly became the safe-read boundary.
    • That omission broke a crucial invariant: any path that can change whether an asset counts toward solvency must enforce the same oracle-safety assumptions as borrow, withdraw, deposit, and repay paths, or better yet consume one canonical guarded valuation surface instead of remembering to bolt the guard onto each function separately.
  • Exploit path:
    1. In exploit transaction 0xeb87ebc0a18aca7d2a9ffcabf61aa69c9e8d3c6efade9e2303f8857717fb9eb7, the attacker flash-borrowed roughly 50,000 wstETH and 60,000 WETH from Aave v3.
    2. They converted about 1,100 ETH through Curve’s stETH pool into about 1,023.797 steCRV, creating a second collateral asset that could later be toggled off and withdrawn.
    3. They supplied about 50,000 wstETH and 57,000 WETH into Balancer’s wstETH/WETH stable pool and minted about 109,517 BPT.
    4. Through one helper contract, they deposited about 1,000 steCRV into Sturdy’s ConvexCurveLP vault and about 233.348773557117598739 Balancer LP tokens into Sturdy’s AuraBalancerLP vault.
    5. Against that mixed collateral set, they borrowed about 513.367301825658717226 WETH from Sturdy.
    6. They then called Balancer exitPool on the large temporary BPT position. During the ETH callback, Balancer had already burned the exiting BPT but had not yet finalized Vault balances, so Sturdy’s oracle temporarily marked the remaining Balancer LP collateral at roughly 3x its normal price.
    7. Inside that callback window, the attacker called setUserUseReserveAsCollateral(csteCRV, false). Because this path skipped _validateOracle(), Sturdy accepted the inflated Balancer LP price and concluded the account remained safely collateralized without steCRV.
    8. After Balancer finished and the LP price normalized, the attacker withdrew the full 1,000 steCRV from Sturdy.
    9. The account was now genuinely undercollateralized, so the attacker approved WETH and called liquidationCall() against their own position, spending about 236 WETH to reclaim the 233.348... Balancer LP collateral.
    10. They exited that recovered LP for roughly 106 wstETH and 120 WETH, repaid the flash loan, and repeated the same pattern through five freshly created helper contracts until Sturdy’s Ether market was nearly drained.
  • What the attacker needed to be true:
    • Sturdy had to accept the Balancer LP as collateral and value it onchain from Balancer state that could be read during an exitPool callback.
    • Balancer’s pool had to expose the known read-only reentrancy window where BPT supply and Vault balances were temporarily inconsistent.
    • Sturdy’s _validateOracle() protection had to be missing from at least one solvency-changing path; setUserUseReserveAsCollateral() provided exactly that opening.
    • The attacker needed another collateral asset (steCRV) whose collateral status could be disabled and later withdrawn while the inflated LP mark still made the account appear solvent.
    • Sturdy’s WETH market needed enough liquidity that repeating the maneuver across multiple helper contracts would keep paying.
  • Technical anatomy worth remembering:
    • This is a strong reminder that read-only reentrancy defenses fail if they are attached only to the obvious borrow() or withdraw() paths. Any function that changes collateral composition, reserve eligibility, or health-factor inputs belongs inside the same defense boundary.
    • Balancer’s own integration guidance says to check Vault reentrancy context on all onchain BPT price evaluations. Sturdy effectively reimplemented that rule as a per-entrypoint patch, so the same LP price was treated as safe or unsafe depending on which LendingPool function touched it.
    • The exploit did not monetize the inflated Balancer LP price directly by borrowing far more WETH in the manipulated moment. Instead, it used the fake price to delete another collateral requirement, then cashed out that freed collateral after the oracle returned to normal.
    • Sturdy’s own postmortem makes the control failure unusually crisp: the team knew about Balancer’s getRate() reentrancy risk, implemented a workaround, shared it with the auditor, and still missed one deprecated function that touched solvency.
    • This makes Sturdy a useful complement to Sentiment and Conic. Sentiment shows unsafe view pricing becomes fake borrow power; Conic shows asset-type metadata misrouted the safe-read guard; Sturdy shows the safe-read guard existed, but one collateral-changing entrypoint was left outside it, so the protocol granted false permission to remove different collateral.
  • Representative transactions and addresses:
    • Main exploit transaction: 0xeb87ebc0a18aca7d2a9ffcabf61aa69c9e8d3c6efade9e2303f8857717fb9eb7
    • Funding transaction into attacker EOA: 0x2e8767269feaa14c0de59bbb29ecd1b375a511ccac57c9b01a471326f1af9bb8
    • Attacker EOA: 0x1e8419e724d51e87f78e222d935fbbdeb631a08b
    • Attack contract: 0x0b09c86260c12294e3b967f0d523b4b2bcdfbeab
    • Example first helper contract: 0x555003433D6A51E9Cc7798752DBDd4ED28D61DE5
    • Sturdy lending pool: 0x9f72DC67ceC672bB99e3d02CbEA0a21536a2b657
    • Sturdy oracle: 0xe5d78eB340627B8D5bcFf63590Ebec1EF9118C89
    • Balancer pool token used as collateral: 0x32296969Ef14EB0c6d29669C550D4a0449130230
  • Response / mitigation:
    • Sturdy paused all markets after the exploit and later confirmed no other pools were affected.
    • The official postmortem says the protocol had already added _validateOracle() to deposit, withdraw, borrow, and repay, but not to setUserUseReserveAsCollateral(); remediation therefore centered on extending the protection uniformly instead of leaving solvency-critical paths on a manual allowlist.
    • Sturdy also described a technical workaround based on forcing a Balancer internal-withdraw path to trip Balancer’s reentrancy lock before trusting the oracle.
    • The incident reinforced Balancer’s broader integration guidance: downstream protocols cannot safely consume same-transaction BPT pricing for solvency-critical decisions without an explicit reentrancy-context check on all onchain price evaluations.

Pattern tags

  • partial oracle hardening still left one solvency-changing path outside the guard
  • Balancer callback-window BPT overvaluation was spent to delete different collateral, not only to overborrow directly
  • deprecated collateral-toggle helper kept live authority after the protocol already knew the bug class
  • self-liquidation monetized a transient markup after collateral-membership changes were already accepted

Why the exploit worked

  • Sturdy had already recognized Balancer getRate() as callback-sensitive and had built _validateOracle() around most major lending paths, but it never turned that protection into one canonical safe-read surface for all solvency-changing entrypoints.
  • That left setUserUseReserveAsCollateral() as a weaker side door. During the Balancer callback window, the function trusted the inflated LP mark long enough to decide that steCRV no longer needed to count toward solvency.
  • The attacker therefore did not need the fake Balancer price to persist or even to maximize direct borrowing in that exact moment. They only needed Sturdy to accept a false collateral-membership change while the LP mark was overstated, then withdraw the freed steCRV after prices normalized.
  • Once the account became genuinely undercollateralized, Sturdy’s own liquidation machinery let the attacker recycle the Balancer LP collateral and repeat the pattern. The real broken object was not only the oracle formula, but the protocol’s failure to make safe to read this LP right now one invariant reused everywhere.

Detection and audit pivots

  • Map every path that changes health-factor inputs — borrow, withdraw, collateral toggle, liquidation, deleverage, migration, deprecated helpers — to the exact oracle-safety primitive it uses. If one entrypoint can still bypass the guarded valuation surface, assume the bug class is still alive.
  • Search for deprecated or low-traffic helpers like setUserUseReserveAsCollateral() that survived later hardening passes. A function being legacy or rarely used does not reduce its authority if it can still rewrite collateral membership.
  • Test callback-window scenarios where a transient markup is used to disable or withdraw different collateral rather than to maximize the marked-up asset’s own borrow power. Sturdy’s profit path was collateral substitution first, direct monetization second.
  • Treat self-liquidation and closeout logic as part of the exploit surface. If a temporary markup can make the protocol accept a weaker collateral set, liquidation may become the rail that recovers the marked asset cheaply after the price snaps back.
  • Ask the sharper design question: does the integration expose one canonical safe-read surface for Balancer LP valuation, or is oracle safety still implemented as a checklist of entrypoints that engineers must remember to patch individually?

Failure-prevention checklist

  • Route every solvency-changing path through one canonical Balancer-safe valuation surface. Borrow, withdraw, collateral toggle, liquidation-precheck, migration, deleverage, and any deprecated helper should all consume the same safe to read this BPT right now primitive instead of each remembering to call _validateOracle() manually.
  • Treat collateral-membership changes as borrow-authority changes. Turning an asset off as collateral is economically equivalent to increasing leverage, so it deserves the same oracle-safety and post-check rigor as a borrow or withdrawal.
  • If the upstream venue cannot publish a machine-checkable settled read surface, fail closed or use delayed settled snapshots for LP-backed collateral. A live getRate() answer inside a callback window is not acceptable merely because it is convenient or usually monotonic.
  • Make deprecated helpers either unreachable or fully hardened. Rarely used is not a defense when the helper can still rewrite whether an account is solvent.
  • Test the full exploit loop, not only the first borrow step: inflate LP mark during callback, disable other collateral, withdraw it, let the mark normalize, then self-liquidate back into the manipulated asset. If that loop stays profitable in simulation, the protocol has not actually contained the oracle boundary.
  • Add market-level blast-radius controls around callback-sensitive LP collateral: conservative collateral factors, borrow caps, liquidation throttles, and fast pause paths should assume one missed helper or adapter branch can still turn transient read state into real debt.
  • Keep upstream integration assumptions executable. If the defense relies on tripping Balancer’s lock via a cheap state-changing call, test that invariant continuously across pool versions and adapters rather than leaving it as architecture folklore.
  • Monitor for same-block patterns where one collateral asset is temporarily marked up, a second asset is toggled off or withdrawn, and liquidation follows shortly after normalization. That sequence is a production signature for collateral-substitution abuse, not ordinary user behavior.

Audit prompts

  • Which exact functions can change an account’s health factor, collateral set, or liquidation eligibility, and do all of them route through the same guarded Balancer valuation surface?
  • Can any deprecated helper, configuration toggle, migration path, or low-traffic endpoint still disable collateral or withdraw value without first proving the Balancer LP read is outside a callback window?
  • Is _validateOracle() or its successor a property of the oracle itself, or merely a convention that some entrypoints remember to invoke? What would force a newly added helper to fail closed instead of silently bypassing the check?
  • Does the protocol distinguish informational Balancer LP pricing from borrow-authoritative pricing, or can any same-transaction getRate()/get_virtual_price() answer immediately rewrite solvency truth?
  • Can a transiently inflated LP mark be spent to remove different collateral rather than only to overborrow the marked asset itself? Sturdy shows that collateral substitution is often the real monetization rail.
  • After the temporary mark disappears, does liquidation let the attacker cheaply reacquire the manipulated asset and replay the loop? If yes, liquidation is part of the exploit design, not merely post-loss cleanup.
  • If the integration uses a lock-probe or internal-withdraw trick to detect Balancer reentrancy, is that probe guaranteed to be side-effect-free, cheap enough to run everywhere, and impossible for one adapter path to skip accidentally?
  • What operational control limits loss if one path still misses the guard: isolated caps, pause rights, reduced collateral factors, or same-block withdrawal throttles? If the answer is nothing until the exploit is noticed, the design still assumes perfect entrypoint coverage.

Lessons that should already be standard

  • Partial oracle hardening is not hardening. Once a protocol knows an upstream LP read is unsafe during callbacks, every solvency-changing path has to inherit one canonical safe-read rule.
  • Collateral toggles are leverage actions. A function that merely says use this asset as collateral = false can be just as dangerous as borrow() if it lets users spend a transiently inflated mark to free other assets.
  • Deprecated functions keep full economic authority until they are removed or fail closed. Nobody should be using this anymore is a red-team invitation, not a safety property.
  • Upstream venue warnings are part of the integration contract. If Balancer contributors or upstream docs say a rate can be wrong during a callback frame, downstream lending code should treat that as treasury-grade truth about what may not back debt.
  • Liquidation logic often completes the profit loop. It is not enough to stop at could the borrower appear solvent for one instant?; reviewers need to ask how the attacker gets back into the manipulated collateral after prices normalize.
  • Canonical safe-read surfaces beat per-entrypoint checklists. The more a protocol expresses oracle safety as remember to call this helper in each relevant function, the more likely one forgotten side door will stay live until production.
  • Same-block LP collateral markets deserve bounded loss by default. When collateral truth depends on complex AMM exit sequencing, caps and fast pause paths are not optional niceties; they are the backup plan for inevitable integration mistakes.

Why this incident matters

  • Sturdy is a high-signal case where the protocol understood the bug class, deployed a partial fix, and still lost because one lower-profile function was left outside the safety perimeter.
  • It preserves an especially useful healthy-side lesson: if an oracle needs a reentrancy-context check before its answer deserves solvency authority, that guarantee has to live in one canonical safe-read surface or one universally reused invariant path rather than in a short list of manually patched entrypoints.
  • It broadens the read-only reentrancy corpus beyond fake collateral -> borrow assets into fake collateral -> disable other collateral -> withdraw it -> self-liquidate for profit.
  • It is also a clean example of why deprecated functions remain part of the live attack surface until they are removed or fully guarded.

Internal linkages

Control surface