Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This article looks at a governance check that read voting power from a live balance instead of a checkpoint, and the invariant that would have caught the drain without anyone needing to name that pattern in advance. New breakdown every week. Find Kostas at @KFerles.
A flash loan turned a real two-thirds supermajority into a one-transaction rental, and a single governance call drained Beanstalk. Pointed at the 24-hour window between proposal and execution, a fuzzing run against the deployment would have caught the drain before it executed.
Jon Stephens took the reviewer’s angle in this week’s Auditor’s Take [LINK AT PUBLISH]: the instinct that stops on a governance path reading voting power at the wrong moment. This post comes at the same incident from the AuditHub side. Beanstalk lost roughly $182M to a proposal that met its two-thirds supermajority bar and ran through to execution like any other. emergencyCommit reads voting power from the Silo’s current balance rather than a value fixed before the vote opened, and that single line is what let a flash loan forge the number. So what catches a pattern like that even when nobody has thought to write a rule for it yet?
TL;DR
- Beanstalk’s
emergencyCommitread voting power at call time; a $1B flash loan rented a two-thirds supermajority for one transaction. - One governance call drained the pool: $182M lost, about $80M kept by the attacker.
- The root cause is one line: voting power read from a live balance, not a pre-vote checkpoint.
- OrCa exercises the contracts as deployed and returns the violating sequence to replay.
- The 24-hour window between proposal and execution is a full day of chances for a running check.
Findable, but not found
Most of the bugs we write detectors for are visible in the source: an inverted operator, a division that flows into a solvency check, a missing caller guard. You read the function and see the mistake, which is what static analysis is built for and the first thing we reach for.
This one has a shape you can read directly in the source, not one that only shows up once a contract is live. A privileged action here draws its authority number from whatever a live balance currently says, not from a value fixed before the process began, and a detector built specifically to flag that shape would catch it, the same category of check Vanguard, our static analyzer, already runs for other anti-patterns. Nobody had built that detector here. The difference that matters is not source versus state; it is what a rule could flag versus what anyone had thought to write the rule for. And when it goes wrong on a governance path, the loss tends to be the whole pool: the guard was present, the delay was present, the supermajority was real, and the protocol still handed everything to the caller.
Why every check passed
The Auditor’s Take walks the governance mechanics in full; the short version is that Beanstalk had a normal commit that ran only after voting closed, and an emergencyCommit shortcut that ran immediately for anyone holding a two-thirds supermajority when the call was made. Here is the function that executed the drain:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function emergencyCommit(uint32 bip) external { require(isNominated(bip), "Governance: Not nominated."); require( block.timestamp >= timestamp(bip).add(C.getGovernanceEmergencyPeriod()), "Governance: Too early."); require(isActive(bip), "Governance: Ended."); require( bipVotePercent(bip).greaterThanOrEqualTo(C.getGovernanceEmergencyThreshold()), "Governance: Must have super majority." ); _execute(msg.sender, bip, false, true); } |
Four checks, all present, all correct. The one that matters is the last: it reads bipVotePercent(bip) from the current state of the Silo at the moment of the call, so a deposit made seconds earlier counts exactly like one made months earlier. On April 17, 2022, the attacker borrowed around a billion dollars through a flash loan, most of it DAI and USDC from Aave, swapped it on Curve for the LP tokens the Silo whitelists, and deposited enough to hold about 79 percent of staked weight. A single emergencyCommit call ran the malicious proposal, filed a day earlier to clear the time check, and its logic used delegatecall into a contract the attacker controlled to empty the pool. The loan closed inside the same transaction. Nothing reverted, because every require held.
The reviewer’s instinct here, pausing the moment an execution path samples voting weight from present state instead of a pre-proposal snapshot, is what Jon works through in the Auditor’s Take [LINK AT PUBLISH]. Stay here for the version that does not wait for someone to notice.
The property, stated before the attack
A rule built for this exact pattern, a live balance driving a privileged decision, is one way to catch it, but only once someone has thought to write it. A broader check does not need that foresight: it states the one outcome the protocol cannot allow and lets the fuzzer search for whatever path reaches it. Deposits, withdrawals, and routine proposals all move the Silo’s BEAN3CRV-f balance, the Curve pool token the Silo accepts as a deposit asset, so a spec forbidding any decrease would drown in false alarms. Halving that balance in one transaction is different: no ordinary flow does it, though a large enough legitimate withdrawal could, itself worth flagging as a concentration risk. Short of that edge case, a collapse of that size is the signature of a drain, and that is the outcome to forbid.
Here is that property written as a value invariant for OrCa, our fuzzer:
|
1 2 3 |
vars: SiloV2Facet f, Bean3Curve t inv: 2*t.balanceOf(address(f)) >= old(t.balanceOf(address(f))) |
The old(...) term is the balance as the transaction began, and the factor of two puts the alarm at a fifty percent drop; the threshold is tunable per protocol. What matters is that it never mentions flash loans, emergencyCommit, or governance at all. It names the outcome and leaves OrCa to find any path that reaches it, including the paths nobody modeled.
What a fuzzing run settles
OrCa, our specification-guided fuzzer, runs against the contracts as they exist at a chosen block, storage and all, rather than an environment someone assembled by hand, and returns the first transaction sequence that breaks the invariant as a concrete counterexample you can replay. Point it at Beanstalk during the 24-hour window when the malicious proposal sat on-chain, live but not yet executed, and it searches on its own until it finds one: a massive borrowed deposit followed by emergencyCommit. On a run forked from that state, our team found the violation in about twenty minutes.
One practical requirement is worth being honest about. The malicious proposal pointed at an address that stayed empty until the exploit transaction itself put code there, at a precomputed address, and a fuzzer cannot exercise code that does not exist. The fix is generic: assign a malicious stand-in to any proposal target with no code, the same rule for every future case rather than foreknowledge of this one. Caveat aside, what the run answers is whether the invariant holds against the deployment that actually exists rather than a configuration someone assumed, and a pre-deployment run should ask the same question against realistic balances.
What a developer should take from this
The question to carry to your own governance is simple: at the instant a privileged action runs, where does its authority number come from? If the answer is a balance or a vote count read live, rather than one frozen before the action was proposed, borrowed capital can forge it. The durable design fix is snapshot-based voting: eligible weight is fixed at a prior block, so capital acquired afterward counts for nothing. That closes the specific hole in the contract, but it is a fix you have to know to reach for. The invariant is what catches the shape of the problem without naming the attack in advance.
From a launch gate to a schedule
An earlier Pre-Mortem covered a bug that was purely source-shaped: an inverted access-control check a static pass could flag on the SuperRare contract before it ever deployed. This one does not split as cleanly into source versus state. The live-balance read behind emergencyCommit is itself a pattern a detector could be built to flag at commit time, and a fuzzer run locally against a large-deposit scenario would plausibly have caught it before any of this reached mainnet. Nobody had written that detector or run that scenario here.
Even with one written, the governance it protects keeps moving after every deploy: new proposals, new deposits, voting weight shifting by the block. A commit-time check only sees the code as it looked at that commit, not a proposal that goes live long after and sits on-chain for 24 hours before executing. Running the same invariant on a schedule, alongside pre-deployment checking rather than in place of it, covers that. This is what continuous security means once a system is live: the property stays fixed, the state keeps changing, and the same spec runs against whatever is current. An audit certifies the code as it looked on one day; a scheduled check keeps asking every day after, and firms can run the same method to widen coverage without growing the team.
Want this check running on your codebase?
One borrowed billion turned a real supermajority into a drain, and the check that would have caught it never got written. To put a value invariant like this on a schedule against your own governance contracts, try AuditHub for free or book a demo.