Reproduced Exploit

Beanstalk BIP-39 upgrade omits a modified facet, inflating grown Stalk 1,000,000x

Dec 2023Otherdependency4 min read

Chain

Other

Category

dependency

Date

Dec 2023

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 31275-failure-to-add-modified-facets-and-facets-with-modified-depe. Standalone Foundry PoC and full write-up: 31275-failure-to-add-modified-facets-and-facets-with-modified-depe_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dependency/upgradeable-contract · vuln/logic/upgrade-safety · misassumption/external-call-is-safe

Reproduction: the test deploys the REAL Beanstalk EIP-2535 diamond (real Diamond / LibDiamond / DiamondCutFacet / DiamondLoupeFacet / OwnershipFacet), the REAL AppStorage storage layout, and the REAL stem/stalk library math from both audited commits, then performs the BIP-39 diamondCut that omits re-cutting SiloFacet. A pre-upgrade deposit's claimable grown Stalk jumps from 20 to 20,000,000 — an exact 1e6x over-issuance.

Root cause#

Beanstalk is an EIP-2535 Diamond. At upgrade time, modified facets are re-cut by listing them in the relevant function of protocol/scripts/bips.js. BIP-39 (Seed Gauge) changed the stem-tip scale convention inside LibTokenSilo, but the bipSeedGauge upgrade script fails to re-cut SiloFacet, FieldFacet, BDVFacet, ConvertFacet, WhitelistFacet, and every facet whose library dependencies changed.

Because Solidity internal libraries are inlined into each facet's bytecode, a facet that is not re-cut keeps executing the old library code even after the diamond's storage is rescaled by the newly-cut facets. The two conventions are irreconcilable:

  • Pre-upgrade (LibTokenSilo.stemTipForToken @ 76066733) adds s.ss[token].milestoneStem un-divided (truncated scale), and the milestone writer stores milestoneStem truncated.
  • BIP-39 (LibTokenSilo @ dfb418d) introduces stemTipForTokenUntruncated, stores milestoneStem untruncated (~1e6x larger), and divides the whole sum by 1e6.

After the buggy upgrade the diamond still routes stemTipForToken / balanceOfGrownStalk to the stale V1 SiloFacet. Its inlined V1 math reads the freshly rescaled (untruncated) milestoneStem and adds it without dividing, so the returned stem tip — and therefore the grown Stalk of every pre-upgrade deposit — is inflated by ~1e6x. Stalk is Beanstalk's governance-and-yield token; over-issuing it breaks protocol accounting and lets pre-upgrade depositors Mow far more Stalk than intended.

The vulnerable, un-divided add is at SiloExit.stemTipForTokenLibTokenSilo.stemTipForToken; the milestone-writer divergence is in LibWhitelist.updateStalkPerBdvPerSeasonForToken.

What is real in this reproduction#

Deployed unmodified from the audited commits:

  • Real EIP-2535 diamond: Diamond.sol, LibDiamond.sol, DiamondCutFacet.sol, DiamondLoupeFacet.sol, OwnershipFacet.sol (src/beanstalk, src/libraries, src/interfaces).
  • Real AppStorage.sol / ReentrancyGuard.sol / LibAppStorage.sol — the actual Beanstalk storage layout, so s.ss[token].milestoneStem, s.season.current and s.a[account].mowStatuses[token] sit at their real slots.
  • Real library math, byte-identical function bodies (library symbols suffixed V1/V2 only so both commit versions coexist in one compilation unit): LibTokenSilo.stemTipForToken(Untruncated), LibSilo.stalkReward / _balanceOfGrownStalk, LibWhitelist.updateStalkPerBdvPerSeasonForToken.
  • Real SiloExit.stemTipForToken / balanceOfGrownStalk bodies, exposed via SiloViewFacetV1 / SiloViewFacetV2.

SetupFacet (whitelist + pre-upgrade deposit + season) reproduces the state that InitBipNewSilo and a real deposit/mow leave; it contains no vulnerability logic.

Exploit walkthrough (with numbers)#

Whitelisted token BEAN, stalkEarnedPerSeason = 2e6, deposit bdv = 1000e6, milestone at season 100, current season 200.

  1. Pre-upgrade (correct): V1 stemTipForToken = 0 + (2e6·100)/1e6 = 200. Grown Stalk = (200 − 0)·1000e6 = 2e1120 Stalk.
  2. BIP-39 buggy upgrade: diamondCut replaces WhitelistFacet with V2 but does not re-cut SiloFacet.
  3. Post-upgrade milestone write: V2 updateStalkPerBdvPerSeasonForToken stores milestoneStem = stemTipForTokenUntruncated = 2e6·100 = 2e8 (untruncated).
  4. Harm: the stale V1 balanceOfGrownStalk reads milestoneStem = 2e8 and adds it un-divided → stemTip = 2e8. Grown Stalk = (2e8 − 0)·1000e6 = 2e1720,000,000 Stalk.

Backward-compatibility invariant broken by exactly 1e6x. The control run — which re-cuts SiloFacet to V2 — returns 2e11 (20 Stalk), confirming the omission is the sole cause.

sequenceDiagram participant U as Pre-upgrade depositor participant D as Beanstalk Diamond participant SV1 as SiloFacet V1 (stale, NOT re-cut) participant WV2 as WhitelistFacet V2 (re-cut) Note over D,SV1: milestoneStem stored TRUNCATED (V1 convention) U->>D: balanceOfGrownStalk(user, BEAN) D->>SV1: delegatecall SV1-->>U: 2e11 (20 Stalk, correct) Note over D: BIP-39 diamondCut re-cuts WhitelistFacet V2<br/>but OMITS re-cutting SiloFacet D->>WV2: updateStalkPerBdvPerSeasonForToken(BEAN) WV2->>D: milestoneStem = stemTipForTokenUntruncated (2e8, UNTRUNCATED) U->>D: balanceOfGrownStalk(user, BEAN) D->>SV1: delegatecall (still routed to stale V1) SV1-->>U: 2e17 (20,000,000 Stalk, 1e6x over-issued)

Reproduction#

BASH
_shared/run-poc/run_poc.sh 31275-failure-to-add-modified-facets-and-facets-with-modified-depe_exp -vvvvv

Expected result: 1 passed. The assertions in test/31275-failure-to-add-modified-facets-and-facets-with-modified-depe_exp.sol verify the pre-upgrade baseline (2e11), the untruncated milestone rescale (2e8), the inflated post-upgrade value (2e17 = grownStalkBefore·1e6), and that the control upgrade which re-cuts SiloFacet stays backward-compatible (2e11).

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.