Governance Attack Remediation
Overview
Governance attacks exploit protocols where voting power is determined at proposal execution time rather than at proposal creation time. An attacker who can borrow a massive amount of governance tokens via flash loan within a single transaction can cast a decisive vote and immediately execute the proposal — all before repaying the loan. The Beanstalk DAO flash loan governance attack (April 2022) demonstrated this at scale: the attacker borrowed $1 billion in stablecoins, converted them to governance tokens, voted in favor of a malicious proposal that transferred all protocol funds to the attacker’s address, executed the proposal immediately (no timelock), and repaid the flash loan — netting $182M in profit within a single transaction.
The root causes are consistently the same: absence of a voting snapshot (tokens counted at execution rather than proposal time), no mandatory timelock between proposal and execution, and insufficient quorum requirements.
Related Detector: Access Control Detector
Recommended Fix
Before (Vulnerable)
contract VulnerableGovernance {
IERC20 public token;
uint256 public constant QUORUM = 1_000_000e18; // 1M tokens
struct Proposal {
address target;
bytes callData;
uint256 forVotes;
bool executed;
}
mapping(uint256 => Proposal) public proposals;
function vote(uint256 proposalId) external {
// VULNERABLE: vote weight is the current balance — not a historical snapshot.
// An attacker can borrow tokens, vote, and repay in a single transaction.
uint256 weight = token.balanceOf(msg.sender);
proposals[proposalId].forVotes += weight;
}
function execute(uint256 proposalId) external {
Proposal storage p = proposals[proposalId];
// VULNERABLE: no timelock — execute runs immediately after quorum
require(p.forVotes >= QUORUM, "Quorum not reached");
require(!p.executed, "Already executed");
p.executed = true;
(bool ok,) = p.target.call(p.callData);
require(ok, "Execution failed");
}
}
After (Fixed)
import "@audited/governance/Governor.sol";
import "@audited/governance/extensions/GovernorSettings.sol";
import "@audited/governance/extensions/GovernorVotes.sol";
import "@audited/governance/extensions/GovernorTimelockControl.sol";
import "@audited/governance/extensions/GovernorVotesQuorumFraction.sol";
// OpenZeppelin's Governor provides voting snapshots, timelocks, and quorum out of the box.
// This is the recommended starting point for any new governance system.
contract SafeGovernor is
Governor,
GovernorSettings,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(IVotes token, TimelockController timelock)
Governor("SafeGovernor")
GovernorSettings(
7200, // 1-day voting delay (gives whales time to delegate before voting opens)
50400, // 1-week voting period (at 12s/block)
100_000e18 // 100k tokens to propose
)
GovernorVotes(token)
GovernorVotesQuorumFraction(10) // 10% of circulating supply must participate
GovernorTimelockControl(timelock)
{}
// Votes are counted using token.getPastVotes(account, proposalSnapshot)
// which reads historical balances — flash loan tokens acquired AFTER
// the snapshot block have zero voting weight.
}
Alternative Mitigations
Voting snapshots with a non-transferable voting record — even without OpenZeppelin’s Governor, a snapshot mechanism can be added to any token:
import "@audited/token/ERC20/extensions/ERC20Votes.sol";
// ERC20Votes tracks historical balances via checkpoints.
// token.getPastVotes(account, blockNumber) returns the balance at that block.
contract GovernanceToken is ERC20Votes {
constructor() ERC20("GovToken", "GOV") EIP712("GovToken", "1") {}
}
contract SafeGovernance {
ERC20Votes public token;
mapping(uint256 => uint256) public proposalSnapshot; // proposalId → block number
function propose(...) external returns (uint256 proposalId) {
// Record current block as the snapshot point
proposalSnapshot[proposalId] = block.number;
// ...
}
function vote(uint256 proposalId) external {
// READ PAST VOTES — not current balance
// Flash-loan tokens borrowed after the snapshot have zero weight here
uint256 weight = token.getPastVotes(msg.sender, proposalSnapshot[proposalId]);
require(weight > 0, "No voting power at snapshot");
// ...
}
}
Mandatory timelock between proposal and execution — a 48-hour minimum timelock gives the community time to identify malicious proposals and coordinate an emergency response:
import "@audited/governance/TimelockController.sol";
// Deploy the timelock with a minimum delay matching your security requirements.
// For high-TVL protocols: 72+ hours. For critical parameter changes: 7 days.
TimelockController timelock = new TimelockController(
2 days, // minimum delay
proposers, // multi-sig or Governor contract
executors, // anyone can execute once the delay passes
address(0)
);
Quorum and proposal threshold requirements — set quorum high enough that a single flash loan cannot meet it, and require proposals to have economic skin in the game:
contract StrictGovernance {
uint256 public constant QUORUM_PERCENT = 10; // 10% of total supply
uint256 public constant PROPOSAL_THRESHOLD = 500_000e18; // 500k tokens to propose
// Quorum check uses historical total supply at the snapshot block
function _quorumReached(uint256 proposalId) internal view returns (bool) {
uint256 snapshotBlock = proposalSnapshot[proposalId];
uint256 totalSupplyAtSnapshot = token.getPastTotalSupply(snapshotBlock);
uint256 requiredVotes = totalSupplyAtSnapshot * QUORUM_PERCENT / 100;
return proposals[proposalId].forVotes >= requiredVotes;
}
}
Proposal fees and spam protection — prevent governance DoS from proposal flooding:
contract FeeGovernance {
uint256 public constant PROPOSAL_FEE = 1000e18; // 1000 tokens burned per proposal
function propose(...) external returns (uint256 proposalId) {
// Burn the proposal fee — disincentivises spam proposals
token.transferFrom(msg.sender, address(0xdead), PROPOSAL_FEE);
// ...
}
}
Common Mistakes
Using balanceOf instead of getPastVotes for vote weight — this is the direct enabler of flash loan governance attacks. The only safe pattern is to read historical balances at a block prior to the vote period opening.
No voting delay before the voting period opens — if voting begins in the same block as the proposal, an attacker can borrow tokens and vote before the snapshot is taken. A voting delay of at least 1 block (and preferably 1 day) closes this window.
Setting the timelock too short — a 10-minute timelock is insufficient for users to notice a malicious proposal and exit. A 48-hour minimum is a reasonable baseline; increase to 7 days for upgrade operations.
Allowing the timelock to be bypassed via an emergency admin — emergency pause mechanisms are acceptable (they restrict, not grant, capabilities), but any “emergency execute” path that skips the timelock is a critical centralization risk.
Low quorum combined with low total participation — a 5% quorum with 2% typical participation means a flash-loan attacker may not even need to meet the quorum if the calculation uses total supply. Always calculate quorum as a fraction of circulating supply, not total supply.