Governance Attack Remediation
How to protect DAO governance systems against flash loan vote acquisition, low quorum exploitation, and timelock bypass using voting snapshots, mandatory delays, and flash loan-resistant token designs.
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 "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
// OpenZeppelin 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 Governor, a snapshot mechanism can be added to any token:
import "@openzeppelin/contracts/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 "@openzeppelin/contracts/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.