On-chain governance is DeFi's most powerful primitive — and one of its most exploited. In April 2022, Beanstalk Farms lost $182M in a single transaction. The attacker didn't hack the code in the traditional sense. They used the protocol's own governance mechanism against it.
This post covers the governance attack surface from a smart contract security perspective: what the vulnerabilities look like in code, why traditional static analysis fails to detect them, and what defensive patterns actually work.
How On-Chain Governance Works (and Where It Breaks)
Most DAO governance follows a pattern:
// Simplified governance flow
1. Holder creates proposal(calldata)
2. Holders vote(proposalId, support) using token balance at snapshot
3. After votingPeriod, if quorum met and for > against: proposal passes
4. After timelock delay: execute(proposalId) // runs calldata
The attack surface exists at every stage of this flow. Governance security is fundamentally different from contract security: the exploits are often economically valid (not bugs in the narrow sense), just catastrophically harmful.
Attack Vector 1: Flash Loan Governance Attack
The Beanstalk exploit is the defining example. A flash loan can temporarily grant enormous voting power within a single transaction.
The vulnerability — snapshot at current block:
// VULNERABLE: voting power based on CURRENT balance (not historical snapshot)
contract VulnerableGovernor {
IERC20 public governanceToken;
function propose(address[] calldata targets, bytes[] calldata calldatas)
external returns (uint256 proposalId)
{
// No minimum proposer voting power check
proposalId = _createProposal(targets, calldatas);
}
function castVote(uint256 proposalId, uint8 support) external {
// VULNERABLE: reads current balance, not a historical snapshot
uint256 weight = governanceToken.balanceOf(msg.sender);
_castVote(proposalId, support, weight);
}
}
Attack sequence:
Block N:
1. Flash borrow 100M governance tokens (67% of supply)
2. propose(malicious_calldata) ← creates proposal
3. castVote(proposalId, FOR) ← instant 67% majority
4. execute(proposalId) ← runs malicious calldata
→ drain all protocol funds
5. Repay flash loan
The entire attack happens in one transaction. The protocol's timelock — if any — is bypassed if the governance contract executes immediately after a successful vote.
Fix: ERC-20 vote snapshots
// SAFE: use ERC-20Votes (checkpointed) for historical balance lookups
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
contract SafeGovernor is Governor, GovernorVotes {
// GovernorVotes uses token.getPastVotes(account, proposalSnapshot)
// Flash loans can't affect past snapshots
function _getVotes(
address account,
uint256 blockNumber,
bytes memory
) internal view override returns (uint256) {
return token.getPastVotes(account, blockNumber);
}
}
Using OpenZeppelin's Governor + GovernorVotes with ERC20Votes ensures voting power is snapshotted at the block before the proposal was created. A flash loan acquired after that block has zero voting weight.
Attack Vector 2: Timelock Bypass via Emergency Functions
Many protocols have emergency functions that bypass the timelock. These create a second governance path that's typically less protected.
// VULNERABLE: guardian can bypass timelock entirely
contract ProtocolWithGuardian {
address public guardian;
TimelockController public timelock;
// Normal path: proposal → timelock (2 days) → execute
function normalUpgrade(address newImpl) external {
require(msg.sender == address(timelock), "Timelock only");
_upgrade(newImpl);
}
// Emergency path: BYPASSES timelock — single address can act immediately
function emergencyPause() external {
require(msg.sender == guardian, "Guardian only");
_pause(); // OK, pause is usually safe
}
// BUG: this shouldn't be emergency-capable
function emergencyWithdraw(address token, address to) external {
require(msg.sender == guardian, "Guardian only");
IERC20(token).transfer(to, IERC20(token).balanceOf(address(this)));
}
}
If the guardian key is compromised — or if the guardian is itself a contract with a vulnerability — the entire timelock is irrelevant.
Pattern: separate pause from drain
// SAFE: emergency functions limited to non-destructive actions
contract SafeProtocol {
// Guardian can only pause (non-destructive, reversible)
function emergencyPause() external {
require(msg.sender == guardian, "Guardian only");
_pause();
}
// All fund movements require timelock, even in "emergency"
function withdraw(address token, address to) external {
require(msg.sender == address(timelock), "Timelock only");
IERC20(token).transfer(to, IERC20(token).balanceOf(address(this)));
}
}
Attack Vector 3: Quorum Manipulation via Token Supply
If quorum is defined as a percentage of current total supply, an attacker who can reduce total supply can lower the quorum threshold.
// VULNERABLE: quorum based on current totalSupply
function quorum(uint256 blockNumber) public view override returns (uint256) {
// If 10% quorum and attacker burns tokens, quorum drops
return (token.totalSupply() * QUORUM_PERCENT) / 100;
}
An attacker with burn capability (or if the token has a deflationary mechanism) can reduce totalSupply before voting, making it easier to reach quorum.
Fix: snapshot totalSupply too
// SAFE: quorum based on past total supply (same snapshot as votes)
function quorum(uint256 blockNumber) public view override returns (uint256) {
return (token.getPastTotalSupply(blockNumber) * QUORUM_PERCENT) / 100;
}
OpenZeppelin's GovernorVotesQuorumFraction extension does this correctly.
Attack Vector 4: Proposal Overwrite / Collision
Some governance implementations allow a proposal to be overwritten if its ID can be predicted and re-created with different calldata.
// VULNERABLE: proposalId is predictable and reusable
contract CollisionVulnerable {
mapping(uint256 => Proposal) public proposals;
uint256 public nextProposalId;
function propose(bytes calldata calldata_) external returns (uint256 id) {
id = nextProposalId++;
proposals[id] = Proposal({
calldata_: calldata_,
voteEnd: block.timestamp + VOTING_PERIOD,
executed: false
});
}
// Missing: check that proposal isn't already passing/executed
function repropose(uint256 id, bytes calldata newCalldata) external {
proposals[id].calldata_ = newCalldata; // overwrites active proposal!
}
}
Fix: Use content-addressed proposal IDs (hash of all parameters) and never allow overwriting non-cancelled proposals.
// SAFE: hash-based ID, no overwrite possible
function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description
) public virtual override returns (uint256) {
uint256 proposalId = hashProposal(targets, values, calldatas, keccak256(bytes(description)));
require(proposals[proposalId].voteEnd == 0, "Proposal exists");
// ...
}
Attack Vector 5: Delegate Manipulation Before Snapshot
With ERC20Votes, voting power must be delegated to be counted. An attacker who holds tokens but never delegated has zero voting power — but they can delegate strategically just before a snapshot.
// Attacker's strategy:
// 1. Hold tokens with no delegation (no on-chain voting power)
// 2. Monitor mempool for a proposal transaction
// 3. Front-run the proposal: call delegate(self) before proposal tx mines
// 4. Now has voting power at proposal snapshot
// 5. Vote against a legitimate upgrade
This isn't a bug — it's working as designed — but protocols often fail to account for it in their governance design. The mitigation is requiring a minimum holding period before votes count:
// Require tokens held for at least 1 epoch before voting
function _getVotes(address account, uint256 blockNumber, bytes memory)
internal view override returns (uint256)
{
// Only count tokens held since before last epoch boundary
uint256 epochStart = _lastEpochStart();
if (blockNumber < epochStart) {
return token.getPastVotes(account, blockNumber);
}
// New tokens (delegated after epoch start) don't count yet
uint256 epochVotes = token.getPastVotes(account, epochStart);
return epochVotes;
}
What Scanners Detect
| Vulnerability | Slither | Mythril | Semgrep | AI |
|---|---|---|---|---|
| Voting with current balance (not snapshot) | ⚠️ Partial | ❌ | ❌ | ✅ |
| Missing timelock on fund-moving functions | ❌ | ❌ | ❌ | ✅ |
| Quorum based on current supply | ❌ | ❌ | ❌ | ✅ |
| Flash loan attack surface (economic context) | ❌ | ❌ | ❌ | ✅ |
| Proposal overwrite vulnerability | ❌ | ⚠️ | ❌ | ✅ |
Governance vulnerabilities are almost entirely economic and architectural rather than syntactic. Slither can flag some patterns (e.g., detecting missing getPastVotes) but cannot reason about the economic incentives that make flash loan attacks viable. Understanding that a 2-hour voting period with no snapshot is exploitable requires knowing what flash loans cost and how governance quorums work — context that only AI-based analysis can bring to bear.
Governance Security Audit Checklist
Before deploying a governance system:
Voting Power
- [ ] Voting weight uses getPastVotes(account, proposalSnapshot) — not current balance
- [ ] ERC20Votes or equivalent checkpointing is in use
- [ ] Flash loan attack is considered: can an attacker acquire majority in one tx?
Proposal Flow
- [ ] Minimum proposer threshold prevents spam proposals
- [ ] Proposal IDs are content-addressed (hash of parameters) — no overwrite
- [ ] Cancelled proposals cannot be re-executed
Timelock
- [ ] All fund-moving operations route through timelock
- [ ] Emergency functions are limited to pause/unpause only (not drain)
- [ ] Timelock delay is appropriate for token liquidity (longer = harder to flash attack)
Quorum
- [ ] Quorum uses getPastTotalSupply — not current supply
- [ ] Quorum percentage is set with flash loan attack in mind
Token Design
- [ ] Delegation is documented — non-delegated tokens have no voting power
- [ ] Token holders are informed about delegation requirement
The Broader Picture
The Beanstalk hack wasn't a bug in the code — the code did exactly what it was written to do. The attacker executed a valid governance proposal with valid votes. The failure was architectural: the protocol assumed that governance would only be exercised over a multi-block window, but a flash loan collapsed that assumption to a single block.
Governance security requires thinking about mechanism design, not just code correctness. That's why tools that only read syntax — Slither, Semgrep, Aderyn — have near-zero coverage for this class of vulnerability.
Scan your governance contracts with ContractScan — the AI engine is specifically designed to reason about architectural vulnerabilities, including flash loan attack surfaces, timelock gaps, and snapshot correctness, that static analysis tools cannot detect.
Related: Flash Loan Attack Deep Dive — the mechanics behind single-transaction economic exploits.
Related: ERC-4337 Account Abstraction Security — another domain where AI analysis covers what Slither misses.