Beanstalk Basin Wells getReserves() Read-Only Reentrancy Near Miss (2023-05-31)

  • Name: Beanstalk Basin Wells getReserves() read-only reentrancy near miss
  • Date: 2023-05-31
  • Protocol: Basin / Beanstalk
  • Chain: Ethereum
  • Category: read-only reentrancy near miss / transient reserve desynchronization / callback-window oracle hazard / AMM integration risk
  • Loss: no reported onchain loss; the issue was fixed before any public exploit was preserved
  • Summary: Cyfrin found that Basin’s generic Well AMM implementation exposed getReserves() as a readable view even while removeLiquidity() and related state-changing flows were still mid-execution under nonReentrant. In the vulnerable sequencing, pumps could update before outbound token transfers, but the Well’s stored reserves were only committed after those external transfers completed. If a Well used a callback-capable asset such as an ERC-777-style token, a re-entrant callback could call getReserves() during that window and observe stale reserve state. Basin’s own write paths were still protected from ordinary state-mutating reentry, but downstream pumps, price functions, or integrators that trusted getReserves() inside the callback frame could turn that transient mismatch into false oracle or solvency truth. The preserved healthy-side lesson is unusually clean: Beanstalk fixed the issue not by asking integrators to remember folklore, but by making getReserves() itself fail closed under active lock state via require(!_reentrancyGuardEntered(), "ReentrancyGuard: reentrant call").
  • Trigger / entry point:
    • The vulnerable surface was Basin’s shared Well.sol implementation, especially liquidity-removal flows that performed external token transfers before final reserve commitment.
    • removeLiquidity() was the audit’s main example, though the broader issue was that getReserves() stayed callable while a nonReentrant Well function was still in flight.
    • Callback-capable assets, especially ERC-777-like tokens or malicious tokens with transfer hooks, could regain control during the outbound transfer.
    • During that callback, attacker-controlled code or an integrating protocol could call IWell(well).getReserves() and receive the pre-settlement reserve snapshot.
  • Root cause:
    • Basin treated view accessibility as harmless even though getReserves() represented security-critical accounting for pumps and downstream integrators.
    • The relevant Well flows did not fully follow Checks-Effects-Interactions ordering for reserve publication: reserves were not committed before the external transfer completed.
    • nonReentrant blocked direct state-mutating reentry into the same write functions, but it did not stop third parties from monetizing a stale view answer during the same callback frame.
    • That left a gap between write paths are locked and read paths are safe, which is exactly the read-only-reentrancy seam later exploited repeatedly across Curve, Balancer, and SyncSwap integrations.
  • Exploit path:
    1. The attacker deploys or targets a Basin Well that includes a callback-capable token surface, such as an ERC-777-like token.
    2. They build a position in the Well and enter a callback-capable path such as removeLiquidity().
    3. The Well updates pump-related state and begins transferring tokens out, but has not yet committed the new reserve values to storage.
    4. The token transfer hands execution back to attacker-controlled callback code.
    5. Inside that callback window, the attacker or a downstream consumer calls getReserves() on the Well.
    6. getReserves() returns a stale reserve vector that looks like ordinary finalized state even though the Well is mid-transition.
    7. Any pump, oracle consumer, or integrating protocol that trusts that read can convert the transient reserve mismatch into a false price, false LP value, or other incorrect solvency signal.
    8. After control returns, the Well finishes and stored reserves catch up, but any downstream protocol that already acted on the stale read may have released real value against a state that only existed inside the callback frame.
  • What the attacker needed to be true:
    • The relevant Well needed a callback-capable token or other external control-transfer edge during settlement.
    • A downstream consumer needed to trust getReserves() during the same transaction instead of requiring a settled post-callback state.
    • The transient reserve mismatch needed to feed a monetizable path such as an oracle update, collateral valuation, routing decision, or liquidation check.
    • The attacker needed enough capital or downstream liquidity for the false read to matter economically.
  • Technical anatomy worth remembering:
    • This was not a classical reenter and mutate the same storage twice bug. The sharper lesson is that a view like getReserves() can itself be the dangerous publication surface when other protocols will spend against it immediately.
    • The Basin case is valuable because it preserves the upstream-protocol side of the read-only-reentrancy family. Many incident notes in this corpus show downstream lenders and vaults trusting unsafe reads from Curve or Balancer; Basin is the clean example of an AMM team recognizing that the upstream protocol should make those reads fail while the lock is active.
    • Cyfrin explicitly noted that the immediate Well implementation was not necessarily insolvent on its own because mutating reentry was blocked, but custom pumps and external integrations remained exposed. That distinction matters: the loss surface can sit one protocol hop away from the accounting bug.
    • The mitigation was correspondingly narrow and instructive. Beanstalk did not need a full redesign to close this seam; it simply made the sensitive view revert under active reentrancy, turning a misleading read surface into a fail-closed one.
    • That makes this incident a strong positive-design exemplar, not just a near miss: unsafe publication is itself the bug, and a protocol-authored read surface can often fix it locally before downstream lenders, routers, or keepers are forced to improvise their own lock-probing rules.
  • Representative contracts and artifacts:
    • Basin Well.sol shared implementation: src/Well.sol
    • Cyfrin audit issue: Read-only reentrancy in the 2023-06-16 Beanstalk Wells report
    • Fix pull request: Basin PR #85 (Read Only Reentrancy Protection)
    • Fix commit: fcbf04a99b00807891fb2a9791ba18ed425479ab (fail on getReserves reentry)
  • Response / mitigation:
    • Cyfrin documented the issue in its June 2023 Beanstalk Wells report and rated it High risk.
    • Beanstalk had already merged a mitigation on 2023-05-31 through Basin PR #85.
    • The fix added require(!_reentrancyGuardEntered(), "ReentrancyGuard: reentrant call") to getReserves(), preventing reserve reads while the Well is already inside a guarded state-changing function.
    • Cyfrin acknowledged the mitigation in the published report.
  • Key transaction hashes:
    • No public exploit transaction hashes are known because this issue was preserved as an audit-disclosed near miss rather than a realized drain.
    • The key preservation artifacts are the Cyfrin report, Basin PR #85, and commit fcbf04a99b00807891fb2a9791ba18ed425479ab.

Pattern tags

  • unsafe reserve publication during guarded settlement
  • view read became the integration hazard, not a write-path reentry
  • upstream AMM patched the read surface instead of pushing folklore onto integrators
  • callback-capable asset support widened the reserve-truth boundary

Three safe-read authority promotions this incident collapsed

  • Transient reserve artifact → canonical reserve truth: a stale reserve vector existed only because the Well was mid-settlement, but getReserves() still published that callback-window artifact as if it were ordinary finalized pool state.
  • Canonical reserve truth → downstream pricing / solvency truth: once a pump, oracle, router, or lender trusted that answer, the stale vector stopped being a local implementation artifact and started counting as canonical reserve truth for price, LP-value, routing, or liquidation decisions.
  • Downstream pricing / solvency truth → live value-moving authority: the accepted reserve story could then inherit real economic authority, letting downstream systems mint, liquidate, lend, or release assets against a state that only existed inside one callback frame.

Why the exploit worked

  • Basin’s write-side reentrancy guard only protected part of the real security object. The Well still published reserve truth through getReserves() while a guarded settlement path could be halfway through changing what those reserves meant economically.
  • Because reserve commitment happened after outbound token transfers, a callback-capable asset could reopen control flow at exactly the moment when the stored reserve vector was stale but still externally readable. That made view publication, not double-write mutation, the exploitable boundary.
  • Any downstream pump, oracle, router, or lender willing to trust that stale answer could have spent real value against a reserve state that disappeared once the original Well call finished. The near miss is valuable precisely because it shows how an AMM can endanger neighbors even when its own state-mutating reentry is blocked.
  • The fix proved the clean design answer: if reserve truth is unsafe mid-frame, the protocol should stop answering the reserve query during that frame instead of expecting every integrator to remember the timing caveat.

Detection and audit pivots

  • Treat getReserves(), totalAssets(), exchangeRate(), get_virtual_price(), and similar view helpers as security-critical publication surfaces whenever downstream protocols will spend, liquidate, or mint against them in the same transaction.
  • Ask whether any guarded state-changing path can transfer control outward before the contract commits the reserve/accounting values later returned by its views. If yes, assume the view can publish transient falsehood unless it explicitly fails closed under the active lock.
  • Audit callback-capable token support as part of the read-safety boundary, not just the write-safety boundary. ERC-777-style hooks, bespoke transfer callbacks, native-asset sends, and adapter hooks all create places where third parties may query state mid-transition.
  • Prefer upstream machine-checkable defenses over integrator folklore. A single require(!_reentrancyGuardEntered()) or public lock/context probe on the reserve view is often more robust than asking every downstream lender, router, or oracle to remember when not to call it.
  • Review every pump, oracle, routing, and liquidation integration that consumes the reserve view and ask the blunt question: if the protocol is already inside a guarded write path, should this read answer at all?

Why this incident matters

  • It preserves one of the cleanest examples of view surface must fail closed under callback as an upstream AMM design rule rather than a downstream integrator patch.
  • It helps connect later losses back to a simple architectural question: if reserve truth is unsafe mid-callback, why is the protocol still willing to answer it?
  • It also shows that reentrancy hardening is not complete when only write paths are locked. If downstream protocols monetize view answers, those reads are part of the security boundary too.
  • Just as importantly, it preserves the healthier implementation move in compact form: publish one machine-checkable safe-read boundary in code, so integrators do not have to guess whether the current reserve vector is quiescent or only temporarily true inside the same stack frame.

Failure-prevention checklist

  • Treat reserve-publishing view functions as part of the settlement surface whenever downstream pumps, routers, lenders, or liquidators can spend against them immediately. nonReentrant on writes is not enough if getReserves() still exports half-finished truth.
  • Commit reserve/accounting state before any outbound transfer when the design allows it. If external delivery must happen before final commitment, make the affected read surfaces fail closed until settlement is finished.
  • Expose one canonical machine-checkable safe-read boundary in code: a reverting getReserves() under active lock, or an explicit context probe every integration can call before trusting live reserve state.
  • Keep callback-capable token admission tied to reserve-publication review. ERC-777-style hooks, bespoke transfer callbacks, and any adapter-mediated external send expand the read-only-reentrancy boundary even when write-side reentry is blocked.
  • Document which views are safe only in quiescent state and make unsafe ones hard to misuse. A buried audit note or folklore warning is weaker than a reverting interface.
  • Test pumps, oracle consumers, and integrators against callback-window reserve reads, not just direct state-mutating reentry. The question is whether any downstream system can still monetize the stale read.
  • Prefer delayed settled snapshots when no trustworthy same-transaction reserve surface can exist. Some integrations should consume only post-settlement state rather than raw live reserves.

Audit prompts

  • Which view functions export balances, reserves, supply, or ratios that downstream code may monetize inside the same transaction?
  • Can any guarded state-changing path transfer control outward before the values returned by those views are fully committed to storage?
  • If a callback fires during removeLiquidity(), removeLiquidityOneToken(), or a similar exit path, what exact reserve story does getReserves() publish before settlement completes?
  • Does the protocol expose one canonical lock/context probe or reverting safe-read boundary, or are integrators expected to remember unsafe timing rules from docs and audits?
  • Which admitted token standards or adapter paths can regain control during settlement, and have they been reviewed as reserve-publication hazards rather than only write-path reentrancy hazards?
  • Can any downstream pump, oracle, router, or lender turn a stale reserve vector into immediate spendable value before the original frame unwinds?
  • If a consumer must read reserves during live execution, what proves the answer is settled economic truth rather than a transient implementation artifact?

Lessons that should already be standard

  • View is not a safety property. If another protocol can borrow, liquidate, or mint against the answer right now, the read surface is already part of the exploit boundary.
  • Reentrancy defense is incomplete when it protects writes but still publishes stale accounting during callback windows.
  • Upstream protocols should not outsource read safety to downstream folklore. If a reserve answer is unsafe mid-frame, the protocol should either revert or expose an explicit safe-read primitive.
  • Callback-capable asset support changes more than transfer semantics; it changes when reserve truth can safely be published.
  • A small fail-closed interface change can be more valuable than a large redesign. Basin’s preserved lesson is that sometimes the healthiest fix is simply refusing to answer a dangerous question during an unsafe frame.

Internal linkages

Control surface