Skip to main content
Sigvex

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

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.

References