Remediating Governance Attacks
How to protect DAO governance mechanisms against flash loan voting, timelock bypass, quorum manipulation, and treasury drain attacks.
Remediating Governance Attacks
Overview
Related Detector: Governance Attacks
Governance attack remediation requires hardening multiple attack surfaces simultaneously. A flash loan voting fix alone does not protect against timelock bypass; a timelock alone does not prevent treasury drain via a passing proposal. The recommended approach is to use OpenZeppelin’s modular Governor framework, which addresses all six attack vectors through composable extensions, and to configure each extension conservatively.
Recommended Fix
Use OpenZeppelin Governor with All Protective Extensions
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import "@openzeppelin/contracts/governance/TimelockController.sol";
contract SecureDAO is
Governor,
GovernorSettings,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(IVotes _token, TimelockController _timelock)
Governor("SecureDAO")
GovernorSettings(
7200, // 1 day voting delay before voting opens (prevents proposal + vote in one block)
50400, // 1 week voting period
10e18 // minimum tokens to propose
)
GovernorVotes(_token)
GovernorVotesQuorumFraction(10) // 10% quorum (adjust for community size)
GovernorTimelockControl(_timelock)
{}
// Required overrides
function votingDelay() public view override(IGovernor, GovernorSettings)
returns (uint256)
{
return super.votingDelay();
}
function votingPeriod() public view override(IGovernor, GovernorSettings)
returns (uint256)
{
return super.votingPeriod();
}
function quorum(uint256 blockNumber)
public
view
override(IGovernor, GovernorVotesQuorumFraction)
returns (uint256)
{
return super.quorum(blockNumber); // Reads from snapshot, not live totalSupply
}
}
Why each extension matters:
| Extension | Attack Mitigated |
|---|---|
GovernorVotes | Flash loan voting — votes from getPastVotes(account, proposalSnapshot), not current balanceOf |
GovernorVotesQuorumFraction | Quorum manipulation — quorum computed from getPastTotalSupply(snapshot) |
GovernorTimelockControl | Timelock bypass — all proposals queue in TimelockController with mandatory delay |
GovernorSettings with voting delay | Flash proposal — delay between proposal and voting prevents same-block attacks |
GovernorSettings with proposal threshold | Sybil proposals — minimum stake to create proposals |
Configure TimelockController with Conservative Delays
// Deploy TimelockController with multi-sig proposers and executors
address[] memory proposers = new address[](1);
proposers[0] = address(governor);
address[] memory executors = new address[](1);
executors[0] = address(0); // address(0) allows anyone to execute after delay
TimelockController timelock = new TimelockController(
172800, // 2 day minimum delay
proposers,
executors,
address(0) // no admin after deployment — timelock is self-governed
);
The minimum delay should be at least 24 hours for parameter changes and 48 hours for fund transfers, giving the community time to react before execution.
Alternative Mitigations
Token Checkpoint Requirements (for Custom Governance)
If you cannot use OZ Governor directly, add EIP-5805 checkpoint support to your governance token and require votes from past checkpoints:
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
// Governance token with built-in checkpoints
contract GovernanceToken is ERC20, ERC20Votes {
constructor() ERC20("GovToken", "GOV") ERC20Permit("GovToken") {}
// Overrides required by ERC20Votes
function _afterTokenTransfer(address from, address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._afterTokenTransfer(from, to, amount);
}
}
// In your governance contract: always use getPastVotes
function _getVotingWeight(address voter, uint256 proposalId) internal view returns (uint256) {
uint256 snapshotBlock = proposals[proposalId].snapshotBlock;
require(snapshotBlock < block.number, "Snapshot not yet taken");
// Past votes are immutable — flash loans have no effect
return IVotes(govToken).getPastVotes(voter, snapshotBlock);
}
Treasury Spending Limits
For treasury transactions, add a separate spending limit contract that caps single-proposal withdrawals:
contract TreasuryGuard {
address public governance;
uint256 public maxSingleTransfer; // Governed parameter
uint256 public dailyTransferred;
uint256 public lastTransferDay;
modifier onlyGovernance() {
require(msg.sender == governance, "Not governance");
_;
}
function transfer(address token, address to, uint256 amount) external onlyGovernance {
require(amount <= maxSingleTransfer, "Exceeds single-transfer limit");
uint256 today = block.timestamp / 1 days;
if (today > lastTransferDay) {
dailyTransferred = 0;
lastTransferDay = today;
}
dailyTransferred += amount;
require(dailyTransferred <= maxSingleTransfer * 3, "Exceeds daily limit");
IERC20(token).transfer(to, amount);
}
}
Common Mistakes
Mistake: Timelock Without Snapshot Voting
// INCOMPLETE: timelock delay helps, but votes are still flash-loanable
function castVote(uint256 proposalId, bool support) external {
// WRONG: current balance — still flash-loanable even with timelock
uint256 weight = govToken.balanceOf(msg.sender);
_vote(proposalId, weight, support);
}
Timelock and snapshot voting are complementary, not interchangeable. Both are required.
Mistake: Quorum from Live totalSupply
// WRONG: quorum based on live totalSupply is manipulable
function hasPassedQuorum(uint256 proposalId) public view returns (bool) {
uint256 quorum = govToken.totalSupply() * 4 / 100;
return proposals[proposalId].yesVotes >= quorum;
}
Use getPastTotalSupply(snapshotBlock) to fix the quorum at the proposal snapshot.
Mistake: Emergency Function Without Multi-Sig
// DANGEROUS: single address can bypass all governance
address public guardian;
function emergencyPause() external {
require(msg.sender == guardian, "Not guardian");
// Guardian can drain funds without any vote or timelock
_pause();
payable(guardian).transfer(address(this).balance);
}
Emergency functions must be limited to pausing, not asset movement. Asset recovery must go through governance with a timelock.