Skip to main content
Sigvex

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.

Use OpenZeppelin’s Governor with All Protective Extensions

import "@audited/governance/Governor.sol";
import "@audited/governance/extensions/GovernorSettings.sol";
import "@audited/governance/extensions/GovernorCountingSimple.sol";
import "@audited/governance/extensions/GovernorVotes.sol";
import "@audited/governance/extensions/GovernorVotesQuorumFraction.sol";
import "@audited/governance/extensions/GovernorTimelockControl.sol";
import "@audited/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 "@audited/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.

References