Beanstalk TokenFacet transferTokenFrom() EXTERNAL-Allowance Omission / Approval-Drain Near Miss (2022-11-14)

  • Name: Beanstalk TokenFacet transferTokenFrom() EXTERNAL-allowance omission / approval-drain near miss
  • Date: 2022-11-14
  • Protocol: Beanstalk
  • Chain: Ethereum
  • Category: approval drain near miss / exposed token-pull helper / allowance-omission logic error / emergency removal
  • Loss: no reported user loss; Beanstalk estimated about $3.09M of approved wallet assets were at risk when the bug was disclosed and patched
  • Summary: Beanstalk’s diamond proxy exposed TokenFacet.transferTokenFrom(...), a helper that could move either internal Farm balances or externally held ERC-20 balances depending on fromMode. The function correctly spent Beanstalk-internal allowances only for the amount drawn from a user’s INTERNAL balance, but it did not require any Beanstalk-layer allowance when callers chose EXTERNAL mode. Because many users had already approved the Beanstalk contract as an ERC-20 spender, any caller could name an approved victim as sender, set fromMode = EXTERNAL, set themselves as recipient, and make Beanstalk call token.safeTransferFrom(victim, attacker, amount) from its own approved-spender identity. The whitehat report turned this into a near miss rather than a live drain: the Beanstalk Community Multisig removed transferTokenFrom(...) before public exploitation.
  • Trigger / entry point:
    • The vulnerable surface was TokenFacet.transferTokenFrom(IERC20 token, address sender, address recipient, uint256 amount, LibTransfer.From fromMode, LibTransfer.To toMode) on Beanstalk’s Ethereum diamond.
    • transferTokenFrom(...) supported both INTERNAL and EXTERNAL source modes through LibTransfer.transferToken(...).
    • When the source was internal Farm balance, the function measured the actual internal-balance delta and spent Beanstalk’s own allowance mapping for msg.sender.
    • When the caller instead chose fromMode = EXTERNAL, Beanstalk still executed the ERC-20 pull from sender, but no corresponding Beanstalk-layer allowance check protected that path.
    • Any wallet or contract that had approved the Beanstalk diamond as ERC-20 spender for BEAN, USDC, WETH, or other supported assets was exposed.
  • Root cause:
    • Beanstalk treated has approved the Beanstalk contract at the ERC-20 layer as if it were enough authorization for any caller to trigger the external transfer path.
    • transferTokenFrom(...) mixed two different authority systems: Beanstalk’s own per-user internal allowance model and the ERC-20 allowance model already granted to the Beanstalk contract.
    • The function only consumed Beanstalk-layer allowance when value left INTERNAL balances; it forgot that EXTERNAL mode still needed a who may make Beanstalk exercise this approval? check.
    • That collapsed two separate questions into one: has the wallet approved Beanstalk as spender? and is this caller authorized inside Beanstalk to make that spend happen right now?
  • Exploit path:
    1. The attacker identifies a victim wallet that has granted ERC-20 approval to the Beanstalk diamond for a supported token.
    2. They call transferTokenFrom(...) directly on the Beanstalk diamond.
    3. They set the approved victim as sender, set an attacker-controlled address as recipient, choose the victim’s approved token, and set fromMode = EXTERNAL.
    4. Because the function only spends Beanstalk-layer allowance for internal-balance deltas, the external path does not reject the unauthorized caller.
    5. LibTransfer.transferToken(...) reaches the ERC-20 transfer path and Beanstalk calls safeTransferFrom(victim, attacker, amount) as the already-approved spender.
    6. The token contract honors the pull because the caller is Beanstalk itself, not the attacker directly.
    7. The victim loses externally held tokens even though they never approved the attacker and never granted that caller Beanstalk-layer permission.
  • What the attacker needed to be true:
    • The victim had to have granted ERC-20 approval to the Beanstalk diamond for the target token.
    • The token needed to implement ordinary transferFrom() semantics that honored the Beanstalk contract’s existing allowance.
    • transferTokenFrom(...) had to remain publicly reachable with attacker-chosen sender, recipient, and fromMode parameters.
    • The attacker had to move before Beanstalk removed the function or victims revoked their ERC-20 approvals.
  • Technical anatomy worth remembering:
    • This was not a generic arbitrary-call router bug. The dangerous primitive was the approval-consuming helper itself.
    • The clean lesson is the same one Bancor preserved in 2020: ERC-20 approval to the protocol does not automatically answer which caller may cause the protocol to consume that approval.
    • The bug was specific to the mismatch between Beanstalk’s own internal allowance accounting and the external token-transfer branch. Internal-balance spends were gated; the external-spend branch inherited spender authority without inheriting caller authorization.
    • The exploit surface lived inside Beanstalk’s diamond / facet architecture, but the root issue was not diamond-specific upgradeability. It was an authorization omission in a shared transfer helper exposed on a contract users already trusted as spender.

Three approval-exercise promotions this incident collapsed

  1. The Beanstalk diamond still had a real ERC-20 spender lane

    • The victim had approved the Beanstalk contract for some token, so there really was a live protocol may call transferFrom(...) capability sitting at the token layer.
    • That fact is only this contract is an approved spender, not yet this caller may exercise that spender right now.
  2. That spender lane still counted as the right Beanstalk-side authorization object for this caller and this branch

    • This is where the protocol needed a second proof: that the current msg.sender, on the EXTERNAL branch, was allowed to make Beanstalk spend from this source wallet toward this recipient for this amount.
    • Beanstalk preserved that kind of caller-bound authorization story for INTERNAL balances, but silently dropped it for EXTERNAL pulls.
    • The exploit lived exactly in that promotion: raw ERC-20 approval to Beanstalk was over-read as if it also authenticated the attacker as an authorized Beanstalk-side spender trigger.
  3. That accepted authorization still deserved live wallet-release authority

    • Once the token contract saw Beanstalk as a truthful approved spender, the external transfer path inherited ordinary wallet-moving power over the victim’s approved balance.
    • This is why the bug was wallet-critical even though Beanstalk did not custody those funds locally: one mistaken promotion at the protocol layer still cashed out as immediate token release authority at the ERC-20 layer.

The healthy review habit is to keep those three promotions separate: protocol is approved as spender, this caller and this branch may exercise that spender identity, and that accepted exercise still deserves live release authority over this wallet and recipient.

  • Representative transactions and contracts:
    • Beanstalk diamond: 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5
    • Vulnerable TokenFacet: 0x8D00eF08775872374a327355FE0FdbDece1106cF
    • Vulnerable function selector removed by EBIP-5: transferTokenFrom(...) / 0x7006e387
    • Emergency removal transaction cited by EBIP-5: 0x74f540497e7ee213ae0e3025d2c5dd5567372fdfebfd55adc7c55d8455318b5a
    • Successor safer entry introduced in EBIP-6: transferInternalTokenFrom(...), which routes transfers through the internal-balance path instead of a caller-selectable external pull
  • Response / mitigation:
    • A whitehat reported the issue through Immunefi.
    • The Beanstalk Community Multisig used its emergency process to pass EBIP-5 and immediately remove transferTokenFrom(...).
    • Beanstalk later shipped EBIP-6, replacing the removed surface with transferInternalTokenFrom(...) so callers could no longer use the helper to pull from external wallet balances.
    • Beanstalk’s published estimate put funds at risk around $3,087,655, with USDC making up the majority of the exposure at the time of disclosure.

Pattern tags

  • exposed-token-pull-helper-approval-rights-exposure
  • Beanstalk preserved a real spender boundary at the ERC-20 layer but failed to preserve the narrower caller boundary for the EXTERNAL branch that exercised it
  • transferTokenFrom(...) mixed internal-ledger allowance spending and external wallet pulls, then only made the internal branch prove who was authorized to trigger the spend
  • The decisive bug was not arbitrary downstream execution but branch-local caller authorization drift on a helper that already sat on a widely approved spender identity
  • EBIP-5 and EBIP-6 together preserve the healthy-side lesson: if the safe product requirement is move internal balances on behalf of another user, the helper should already fail closed against any caller-selectable external wallet pull path

Why the exploit worked

  • Beanstalk answered the wrong authorization question on the EXTERNAL path. User wallets had approved the Beanstalk diamond as ERC-20 spender for ordinary flows, but the protocol stopped short of separately proving which caller may make Beanstalk consume that approval right now?
  • transferTokenFrom(...) still had enough context to protect one branch correctly. It measured internal-balance deltas and spent Beanstalk-layer allowance when value came from INTERNAL balances, which shows the protocol already understood that delegated transfer rights needed their own local authorization object.
  • The exploit existed because that same caller-boundary logic did not travel with the EXTERNAL branch. Once fromMode = EXTERNAL, attacker-shaped sender, recipient, token, and amount inputs reached safeTransferFrom(...) while the ERC-20 layer still saw a truthful approved spender.
  • The bug therefore sat exactly at the mixed-accounting seam. INTERNAL and EXTERNAL were presented as parallel source modes, but only one branch preserved Beanstalk’s intended owner approved caller story above raw token allowance.
  • EBIP-6’s replacement is revealing for the design lesson: the safe fix was not to bolt another generic public external-pull helper onto the same spender boundary, but to narrow the product surface to transferInternalTokenFrom(...) so the caller could no longer choose a wallet-pull mode at all.

Detection and audit pivots

  • Diff authorization branch-by-branch whenever one helper supports both protocol-internal balances and externally held wallet assets. If the same selector can reach both, reviewers should prove each branch carries an equally explicit caller-right object.
  • Treat attacker-shaped sender, recipient, token, amount, and mode flags on already-approved spender contracts as a combined wallet-drain surface, not as routine convenience parameters.
  • Ask whether the contract is relying on ERC-20 approval to answer a question it was previously answering locally. In Beanstalk, internal allowance exists was explicit, while who may trigger external pull quietly degraded into spender is approved.
  • Review emergency upgrades and successor entrypoints for whether they actually narrow authority. Replacing transferTokenFrom(...) with transferInternalTokenFrom(...) is strong precisely because it removes caller choice over the external wallet-pull branch rather than merely rearranging checks.
  • Quantify allowance blast radius per spender contract, not just per protocol brand. Beanstalk’s own estimate that about $3.09M sat at risk made clear that a non-custodial helper on an approved spender boundary can carry real wallet-loss exposure even without protocol TVL sitting inside the vulnerable function.

Why this incident matters

  • It is a crisp near-miss example of the approved protocol becomes wallet-drain primitive family without needing a router, callback spoof, or arbitrary low-level call.
  • It also says more plainly what the preserved healthy boundary should have been: one approval-exercise object should have bound spender identity, source wallet, authorized trigger, branch meaning, and spend scope before either INTERNAL or EXTERNAL mode was allowed to move value.
  • The case is especially useful for account-abstraction and diamond-style systems because once a protocol supports both internal ledger balances and external wallet pulls, every branch needs an equally explicit caller-authorization story instead of inheriting raw ERC-20 allowance as ambient authority.
  • The incident is valuable even as a near miss because it shows how much latent wallet risk can sit inside routine convenience helpers even when no funds are custodied in the vulnerable function itself.

Failure-prevention checklist

  • Do not expose a public helper that can exercise a protocol-held ERC-20 approval unless the same entrypoint also proves one explicit caller-right object for who may trigger this spend, from which source wallet, on which branch, for which token, toward which recipient, and up to what amount.
  • If one selector supports both protocol-internal balances and external wallet pulls, make the authorization logic branch-equivalent. INTERNAL versus EXTERNAL should change settlement mechanics, not whether caller authorization exists at all.
  • Prefer product surfaces that remove dangerous mode choice entirely. Beanstalk’s healthy follow-up was transferInternalTokenFrom(...), not another generic public wallet-pull helper with slightly different checks.
  • Treat spender contracts with live user approvals as wallet-custody surfaces even if they hold no pooled TVL themselves. Inventory every externally callable path that can reach transferFrom() or equivalent pull authority.
  • Bind recipient selection as tightly as source-wallet selection. A helper that authenticates the owner but still leaves recipient fully attacker-shaped often remains an approval-drain primitive.
  • Keep protocol-local approvals and token-layer approvals conceptually separate in code, naming, and review. A branch that consumes ERC-20 allowance still needs its own local who may invoke it? decision.
  • Add rapid kill capability for approval-consuming helpers: facet removal, selector pausing, or route-specific shutdown should be rehearsed before a report arrives.
  • Publish spender-exposure inventories that let teams quantify approved but externally reachable blast radius quickly during triage.

Audit prompts

  • Which public or externally reachable functions can make this contract call transferFrom() / safeTransferFrom() against user wallets because the protocol is already approved as spender?
  • For each such function, what exact local authorization object proves that the caller may consume the protocol’s spender identity right now?
  • If the function supports multiple source modes, do all branches preserve the same caller, owner, token, amount, and recipient constraints, or does one path quietly inherit raw token approval as ambient authority?
  • Can an attacker choose sender, recipient, token, or mode flags independently while the protocol still presents one truthful approved-spender identity to the token contract?
  • Does any convenience helper mix internal-ledger transfers and external wallet pulls behind one selector, causing reviewers to verify internal accounting carefully while under-reviewing the external approval-consumption branch?
  • If the intended product need is only internal-balance movement or protocol-custodied asset movement, why does the entrypoint still retain a wallet-pull branch at all?
  • During incident response, can the team disable just the approval-consuming selector or facet quickly enough to protect approved wallets before mass revocation catches up?
  • Has the protocol measured how much user exposure sits on each approved spender contract, rather than only how much TVL the protocol itself escrows?

Lessons that should already be standard

  • User approved the protocol contract never answers any caller may make the protocol spend now. Those are different authority questions and they must stay different in both code and review.
  • Mixed-mode helpers are dangerous because they encourage false symmetry: one branch can carry a real local authorization object while its sibling branch quietly downgrades to ambient ERC-20 allowance.
  • Convenience is not neutral on approved spender boundaries. A generic public helper that saves integrators a step can become a direct wallet-drain primitive the moment it can originate external pulls on caller-supplied wallets.
  • The safest fix is often to remove an authority class, not to keep polishing it. Beanstalk’s move from transferTokenFrom(...) toward transferInternalTokenFrom(...) is a good example of narrowing the product surface until the dangerous approval-consumption branch no longer exists.
  • Non-custodial code can still be custody-critical. If a contract can exercise user approvals, it deserves the same threat model as a wallet-moving or reserve-moving system even when the vulnerable function holds no assets between calls.
  • Approval blast radius should be an operationally rehearsed metric. Teams should know which selectors, facets, and helper paths can monetize existing approvals before a whitehat or attacker teaches them.

Internal linkages

  • Primary recurring family: exposed-token-pull-helper-approval-rights-exposure — the core failure was that Beanstalk, an already-approved spender, exposed its own wallet-pull helper without preserving caller authorization on the EXTERNAL branch.
  • Positive-design companion: spender authority truth as a single security object — the sharper design question here is what one approval-exercise object should have kept the EXTERNAL branch’s spender identity, source wallet, authorized trigger, branch meaning, and spend scope fused together before wallet-pull authority reached safeTransferFrom().
  • Adjacent recurring family: arbitrary-call-token-approval-drain — same approval-drain outcome and same protocol spender identity becomes the theft primitive lesson, but router / arbitrary-call cases add attacker-routed execution freedom rather than a direct exposed-helper authorization omission.
  • Adjacent recurring family: frontend-compromise-approval-phishing — both families ultimately monetize wallet approvals, but Beanstalk broke after legitimate approvals already existed instead of by eliciting a fresh malicious approval signature.
  • Adjacent recurring family: approval-by-address-mutable-code-execution — both families remind readers that the protocol contract is approved is not the same thing as the right live code path with the right caller context is consuming that approval.

Control surface