Reproduced Exploit

Beanstalk Wells — `removeLiquidity` is wrong for generalized (non-linear) Well functions

1. addLiquidity mints LP using the Well function (calcLpTokenSupply), so the invariant holds on the way in. 2. removeLiquidity does not invert the Well function — it pays out a flat proportional share: lpAmountIn * reserves[i] / lpTokenSupply.

Jun 2023Otherlogic5 min read

Chain

Other

Category

logic

Date

Jun 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, 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: 18433-removeliquidity-logic-is-not-correct-for-generalized-well-fu. Standalone Foundry PoC and full write-up: 18433-removeliquidity-logic-is-not-correct-for-generalized-well-fu_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/wrong-condition · vuln/math/invariant-violation · impact/loss-of-funds/lp-value-transfer

Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only forge-std — no fork, no RPC, no anvil_state. Full trace: output.txt. PoC: test/18433-removeliquidity-logic-is-not-correct-for-generalized-well-fu_exp.sol.


Key info#

ImpactHIGHremoveLiquidity withdraws a fixed proportional share of reserves; for a non-linear Well function this breaks the Well invariant so one LP extracts value from another (direct LP loss of funds)
ProtocolBeanstalk Wells / Basin — generalized constant-function AMM
Vulnerable codeWell.removeLiquiditytokenAmountsOut[i] = lpAmountIn * reserves[i] / lpTokenSupply (does not invert the Well function)
Bug classWrong condition / linearity assumption / invariant violation
FindingCyfrin — Beanstalk Wells, 2023-06 · #18433 · reporter Hans
Report2023-06-16-Beanstalk wells.md
SourceAuditVault
StatusAudit finding — caught in review. Beanstalk added IWellFunction.calcLPTokenUnderlying so the Well function decides removeLiquidity outputs.
Compiler^0.8.17 (PoC)

This is an audit finding, not a historical on-chain incident. The Well.addLiquidity / removeLiquidity bodies are copied verbatim from BeanstalkFarms/Wells commit e5441fc; the QuadraticWell Well function is copied verbatim from the finding's PoC (a Numoen-style quadratic curve).


TL;DR#

  1. addLiquidity mints LP using the Well function (calcLpTokenSupply), so the invariant holds on the way in.
  2. removeLiquidity does not invert the Well function — it pays out a flat proportional share: lpAmountIn * reserves[i] / lpTokenSupply.
  3. Proportional withdrawal is value-preserving only if the Well function is linear. On the quadratic Well it is not: the withdrawer is over- or under-paid, the invariant breaks, and value moves between LPs.
  4. Honest LP deposits [1, 1], recovers only [0.75, 0.5]. The first withdrawer deposits [2, 1], recovers [2.25, 1.5] — pocketing exactly the honest LP's [0.25, 0.5].

The vulnerable code#

Well.removeLiquidity (verbatim, src/Well.sol L453):

SOLIDITY
_burn(msg.sender, lpAmountIn);
for (uint i; i < _tokens.length; ++i) {
    tokenAmountsOut[i] = (lpAmountIn * reserves[i]) / lpTokenSupply; // @> flat proportional split
    ...
    _tokens[i].safeTransfer(recipient, tokenAmountsOut[i]);
    reserves[i] = reserves[i] - tokenAmountsOut[i];
}

Contrast addLiquidity, which does use the Well function (verbatim, src/Well.sol L416):

SOLIDITY
lpAmountOut = _calcLpTokenSupply(wellFunction(), reserves) - totalSupply();

The finding's QuadraticWell (verbatim): s = b_0 - (PRICE_BOUND - b_1/2)² / PRECISION.


Root cause#

removeLiquidity hard-codes the constant-product intuition that "burning fraction f of LP returns fraction f of every reserve". That inverse is only correct for a linear/constant-product curve. Because addLiquidity mints via the Well function but removeLiquidity ignores it, the two operations are inconsistent for any non-linear Well function — the exact "generalized Well function" case the protocol advertises support for. The mismatch breaks totalSupply == calcLpTokenSupply(reserves) and lets whoever withdraws in the wrong direction extract value from the other LPs.

Preconditions#

  • A Well deployed with a non-linear Well function (the protocol explicitly intends to support these; the finding uses Numoen's quadratic curve) — permissionless.
  • Two or more LPs with liquidity in the Well (ordinary usage; no privileged role).

Attack walkthrough#

From output.txt:

  1. Honest LP adds [1, 1] → receives 0.75e18 LP; reserves (1, 1).
  2. First LP adds [2, 1] → reserves (3, 2), total LP 3e18; it holds 2.25e18 LP.
  3. First LP removes all 2.25e18 LP. Proportional payout = [2.25, 1.5], so reserves fall to (0.75, 0.5), supply to 0.75e18.
  4. Honest LP removes its 0.75e18 LP. Proportional payout = [0.75, 0.5].
  5. HARM: the honest LP deposited [1, 1] and recovered [0.75, 0.5] — a loss of 0.25 token0 + 0.5 token1. The first withdrawer deposited [2, 1] and recovered [2.25, 1.5] — a gain of exactly [0.25, 0.5]. Value was transferred between LPs by the buggy proportional math.

Diagrams#

flowchart TD A[Honest LP adds 1,1 -> 0.75e18 LP, invariant holds] --> B[First LP adds 2,1 -> reserves 3,2 supply 3e18] B --> C[First LP removes 2.25e18 LP] C --> D[proportional payout 2.25, 1.5 -- ignores Well function] D --> E[reserves left 0.75, 0.5 for 0.75e18 LP -- invariant broken] E --> F[Honest LP removes 0.75e18 LP -> only 0.75, 0.5 back] F --> G[Honest LP lost 0.25 token0 + 0.5 token1 -> first withdrawer gained it]
sequenceDiagram participant H as Honest LP participant W as Well (quadratic function) participant A as First LP (beneficiary) H->>W: addLiquidity [1,1] (mint via Well function -> 0.75e18 LP) A->>W: addLiquidity [2,1] (reserves 3,2 supply 3e18 -> 2.25e18 LP) A->>W: removeLiquidity 2.25e18 (proportional -> [2.25, 1.5]) Note over W: proportional split ignores the Well function -> invariant broken H->>W: removeLiquidity 0.75e18 (proportional -> only [0.75, 0.5]) Note over H,A: honest LP -0.25/-0.5 #59; first LP +0.25/+0.5

Remediation#

Compute removeLiquidity outputs via the Well function instead of a fixed proportional split. Beanstalk added IWellFunction.calcLPTokenUnderlying, which returns the reserve amounts underlying a given LP quantity, so the Well function itself decides how much to return and can preserve its own invariant (commit 5271e9a).

How to reproduce#

BASH
cd ~/RustroverProjects/audits/evm-hack-registry/18433-removeliquidity-logic-is-not-correct-for-generalized-well-fu_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: PASS — honest LP deposits [1,1] but recovers [0.75, 0.5]; the first
# withdrawer deposits [2,1] and recovers [2.25, 1.5], pocketing the honest LP's
# [0.25, 0.5].

PoC source: test/18433-removeliquidity-logic-is-not-correct-for-generalized-well-fu_exp.sol — verbatim Well.addLiquidity/removeLiquidity and the finding's verbatim QuadraticWell.

Note: the quadratic curve and the [1,1]/[2,1] amounts are the finding's own PoC parameters; they fix the exact [0.25, 0.5] magnitude. The bug class (proportional removeLiquidity that ignores the Well function → invariant broken → LP value transferred) and the verbatim vulnerable line are faithful.


Reference: finding #18433 by Hans in the Cyfrin Beanstalk Wells review (Jun 2023) · curated by AuditVault


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

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.