← Back to Blog

Governance Attack Patterns: Flash Loan Voting, Timelock Bypass, and Proposal Manipulation

2026-04-18 governance flash loan voting timelock proposal dao solidity security

On April 17, 2022, an attacker drained $182 million from Beanstalk Farms in a single transaction. No zero-day, no memory corruption, no compiler bug. The attacker borrowed governance tokens via flash loan, used majority voting power to pass a malicious proposal, executed it in the same transaction, and repaid the loan. The governance contract performed exactly as written.

Beanstalk is not an outlier. As of 2026, DeFi protocols govern billions in on-chain assets through voting mechanisms that many teams deploy without thorough security review. This post covers six exploitable patterns — with vulnerable code, attack mechanics, and concrete fixes for each.


1. Flash Loan Governance Attack

The canonical attack. An attacker borrows a large position of governance tokens, acquires temporary majority voting power within a single block, and uses that power to pass and immediately execute a malicious proposal before repaying the loan.

Vulnerable code — voting weight from current balance:

// VULNERABLE: no historical snapshot; reads live balance at vote time
contract VulnerableGovernor {
    IERC20 public token;
    mapping(uint256 => Proposal) public proposals;

    struct Proposal {
        address[] targets;
        bytes[]   calldatas;
        uint256   forVotes;
        uint256   againstVotes;
        bool      executed;
    }

    function propose(
        address[] calldata targets,
        bytes[]   calldata calldatas
    ) external returns (uint256 proposalId) {
        proposalId = uint256(keccak256(abi.encode(targets, calldatas, block.number)));
        proposals[proposalId].targets   = targets;
        proposals[proposalId].calldatas = calldatas;
    }

    function castVote(uint256 proposalId, bool support) external {
        // VULNERABILITY: live balance — flash loan inflates this to any amount
        uint256 weight = token.balanceOf(msg.sender);
        if (support) {
            proposals[proposalId].forVotes += weight;
        } else {
            proposals[proposalId].againstVotes += weight;
        }
    }

    function execute(uint256 proposalId) external {
        Proposal storage p = proposals[proposalId];
        require(p.forVotes > p.againstVotes, "Did not pass");
        p.executed = true;
        for (uint256 i = 0; i < p.targets.length; i++) {
            (bool ok,) = p.targets[i].call(p.calldatas[i]);
            require(ok, "Call failed");
        }
    }
}

Attack in one transaction:

1. flashLoan(governanceToken, 67% of supply)
2. propose([treasury], [transfer(attacker, allFunds)])
3. castVote(proposalId, true)   // 67% weight — passes immediately
4. execute(proposalId)           // drains treasury
5. repay flash loan

Fix — snapshot voting power at proposal creation:

import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";

contract SafeGovernor is Governor, GovernorVotes, GovernorTimelockControl {
    constructor(IVotes _token, TimelockController _timelock)
        Governor("SafeGovernor")
        GovernorVotes(_token)
        GovernorTimelockControl(_timelock)
    {}

    // Voting delay: 1 block minimum; attacker cannot flash-acquire votes
    // that exist at the snapshot block before the proposal was created
    function votingDelay() public pure override returns (uint256) {
        return 7200; // ~24 hours at 12s/block
    }

    function votingPeriod() public pure override returns (uint256) {
        return 50400; // ~7 days
    }

    // GovernorVotes calls token.getPastVotes(account, proposalSnapshot)
    // Flash loan tokens acquired after snapshot carry zero weight
}

ERC20Votes.getPastVotes reads from historical checkpoints. Flash loan tokens acquired after the snapshot block carry zero voting weight.


2. No Timelock on Critical Actions

A governance proposal that executes immediately after passing gives token holders no opportunity to exit before funds move. A malicious supermajority can act faster than any response team.

Vulnerable pattern — direct execution on proposal pass:

// VULNERABLE: execution happens inside the vote count, no delay enforced
contract NoTimelockGovernor {
    IVotes public token;
    uint256 public quorumVotes;

    function proposeAndVoteAndExecute(
        address target,
        bytes calldata data,
        uint256 deadline
    ) external {
        require(block.timestamp < deadline, "Expired");
        uint256 snapshot = block.number - 1;
        uint256 weight = token.getPastVotes(msg.sender, snapshot);
        require(weight >= quorumVotes, "Below quorum");

        // Executes immediately — no delay, no queue
        (bool ok,) = target.call(data);
        require(ok, "Execution failed");
    }
}

A governance majority could upgrade the proxy implementation, transfer the entire treasury, or replace the admin key — and users have zero blocks to react.

Fix — mandatory TimelockController:

import "@openzeppelin/contracts/governance/TimelockController.sol";

// Deploy timelock with 48-hour minimum delay
TimelockController timelock = new TimelockController(
    172800,          // minDelay: 48 hours in seconds
    proposers,       // only Governor contract can queue
    executors,       // anyone can execute after delay
    address(0)       // no admin after deployment
);

// Governor routes all executions through timelock
// Users have 48 hours to observe queued actions and exit if needed

The 48-hour minimum is a floor, not a target. Protocols controlling large treasuries should use 72 hours or longer — enough time for the community to observe, coordinate, and respond.


3. Proposal Encoding Attack (Malicious Calldata)

Governance proposals consist of human-readable descriptions and machine-executable calldata. Voters typically read the description. The description and calldata are separate fields — there is no on-chain enforcement that they match.

Vulnerable pattern — description divorced from calldata:

// VULNERABLE: description field has no binding relationship to calldata
contract DescriptionGovernor {
    struct Proposal {
        string  description;   // voters read this
        address target;
        bytes   calldata_;     // blockchain executes this — may differ
        bool    executed;
    }

    mapping(uint256 => Proposal) public proposals;

    function propose(
        string  calldata description,
        address target,
        bytes   calldata data
    ) external returns (uint256) {
        uint256 id = uint256(keccak256(abi.encode(description, block.number)));
        proposals[id] = Proposal(description, target, data, false);
        return id;
    }

    function execute(uint256 id) external {
        // Executes calldata, not what description said
        Proposal storage p = proposals[id];
        require(!p.executed, "Already executed");
        p.executed = true;
        (bool ok,) = p.target.call(p.calldata_);
        require(ok, "Failed");
    }
}

An attacker submits: description = "Update protocol fee from 0.3% to 0.25%", calldata = transfer(attacker, allFunds). Voters approve based on the fee change description.

Fix — calldata verification in proposal tooling and UI:

// Defensive: include calldata hash in proposal ID so description + calldata are bound
function propose(
    address[] memory targets,
    uint256[] memory values,
    bytes[]   memory calldatas,
    string    memory description
) public virtual override returns (uint256) {
    // OpenZeppelin Governor hashes ALL fields together
    // Changing calldata changes proposalId — the original vote does not apply
    uint256 proposalId = hashProposal(
        targets, values, calldatas, keccak256(bytes(description))
    );
    // ...
}

Beyond code: frontends should decode calldata and display human-readable function names and arguments alongside the description. Voters who see transfer(0xAttacker, 1000000e18) in the decoded view will catch what the description hides.


4. Quorum Manipulation via Token Minting

When governance controls its own token's minting capability, a circular dependency emerges: an attacker who can pass any proposal can mint tokens to reach quorum for a better proposal. The attack starts small and escalates.

Vulnerable circular dependency:

// VULNERABLE: governance can mint governance tokens and lower quorum
contract CircularGovernance {
    GovernanceToken public token;  // token.mint() gated to address(this)
    uint256 public quorumBps = 1000; // 10% quorum in basis points

    function quorum(uint256 blockNumber) public view returns (uint256) {
        // Quorum percentage of CURRENT supply — minting raises denominator
        // but attacker mints to themselves, raising numerator faster
        return (token.getPastTotalSupply(blockNumber) * quorumBps) / 10000;
    }

    // Governance can execute arbitrary calls on itself
    function _execute(address target, bytes memory data) internal {
        (bool ok,) = target.call(data);
        require(ok);
    }
}

Attack sequence:

Step 1: Attacker holds 5% of supply. Quorum is 10%.
Step 2: Pass proposal (needs external votes or bribing) to reduce quorumBps to 100 (1%).
Step 3: Attacker now meets 1% quorum alone.
Step 4: Pass proposal to mint 10x tokens to attacker (now has 91% supply).
Step 5: Pass any remaining proposals unilaterally.

Fix — decouple minting from governance, snapshot quorum:

// SAFE: quorum based on past total supply; minting requires separate multisig
contract SafeGovernanceToken is ERC20Votes {
    address public immutable minter; // fixed at deploy, NOT governance

    function mint(address to, uint256 amount) external {
        require(msg.sender == minter, "Not minter");
        _mint(to, amount);
        _delegate(to, to);
    }
}

// In governor:
function quorum(uint256 blockNumber) public view override returns (uint256) {
    // Snapshot — minting after blockNumber does not reduce quorum
    return (token.getPastTotalSupply(blockNumber) * QUORUM_BPS) / 10000;
}

Governance should never control minting of its own voting token without a separate, time-delayed safeguard.


5. Voting Power at Wrong Block (Timestamp Vulnerable Snapshot)

Using block.number - 1 as the snapshot prevents flash loan attacks — no token acquired in the current transaction can influence past blocks. However, on networks with deterministic or predictable block times, an attacker who can predict when a proposal will be submitted can pre-position tokens to maximize voting power before the snapshot.

Partially fixed but still vulnerable:

// Partially fixed: uses block.number - 1, not current block
// Still vulnerable: snapshot block is predictable
contract TimestampVulnerableGovernor {
    IVotes public token;
    uint256 public constant PROPOSAL_THRESHOLD = 100_000e18;

    function propose(
        address[] calldata targets,
        bytes[]   calldata calldatas
    ) external returns (uint256 proposalId) {
        uint256 snapshot = block.number - 1; // prevents flash loans
        uint256 proposerVotes = token.getPastVotes(msg.sender, snapshot);
        require(proposerVotes >= PROPOSAL_THRESHOLD, "Below threshold");

        // snapshot stored — but attacker knew this block was coming
        proposalId = _storeProposal(targets, calldatas, snapshot);
    }
}

On L2s with predictable sequencers (Arbitrum, Base), an attacker who monitors governance activity can buy tokens just before an anticipated proposal, capture the snapshot, vote, then sell — cheaper than a flash loan and harder to detect.

Fix — minimum token holding period before votes count:

// Require tokens held since before the last epoch boundary to count
uint256 public constant EPOCH_LENGTH = 50400; // ~7 days in blocks

function _getVotes(
    address account,
    uint256 blockNumber,
    bytes memory params
) internal view override returns (uint256) {
    // Only tokens held since the start of the previous epoch count
    uint256 epochBoundary = (blockNumber / EPOCH_LENGTH) * EPOCH_LENGTH;
    uint256 safeSnapshot = epochBoundary > 0 ? epochBoundary - 1 : 0;

    // Tokens acquired after epoch start don't count until next epoch
    return token.getPastVotes(account, safeSnapshot);
}

The epoch boundary forces a minimum holding period. An attacker must hold capital idle for up to one full epoch, paying opportunity cost while their position is visible to the community.


6. Timelock Bypass via Emergency Admin

A common design pattern adds an emergency admin role that can act outside the normal governance flow. When this emergency path includes treasury access or administrative actions — and the admin is a single EOA or a poorly secured multisig — it becomes a high-value target.

Vulnerable emergency admin pattern:

// VULNERABLE: emergencyAdmin is a single EOA that bypasses all timelocks
contract ProtocolWithEmergencyAdmin {
    address public emergencyAdmin;    // single key, no multisig
    TimelockController public timelock;
    address public treasury;

    modifier onlyTimelock() {
        require(msg.sender == address(timelock), "Timelock only");
        _;
    }

    modifier onlyEmergency() {
        require(msg.sender == emergencyAdmin, "Emergency admin only");
        _;
    }

    // Normal path — properly timelocked
    function upgradeImplementation(address newImpl) external onlyTimelock {
        _upgrade(newImpl);
    }

    // Emergency path — BYPASSES timelock, treasury accessible instantly
    function emergencyWithdraw(
        address token,
        address recipient,
        uint256 amount
    ) external onlyEmergency {
        // Attacker compromises emergencyAdmin key → immediate drain
        IERC20(token).transfer(recipient, amount);
    }

    // Emergency pause — this one is actually fine
    function emergencyPause() external onlyEmergency {
        _pause();
    }
}

A compromised emergencyAdmin key allows immediate, unrestricted treasury access with no delay. The attacker does not need to pass any governance vote.

Fix — scope emergency admin to non-destructive actions only:

// SAFE: emergency admin can only pause; treasury access always requires timelock
contract SafeProtocol {
    address public guardian;         // can be 3-of-5 multisig
    TimelockController public timelock;

    // Guardian limited to pause/unpause — reversible, non-destructive
    function emergencyPause() external {
        require(msg.sender == guardian, "Not guardian");
        _pause();
    }

    function emergencyUnpause() external {
        require(msg.sender == guardian, "Not guardian");
        _unpause();
    }

    // ALL fund movements require full timelock — no exceptions
    function withdrawFunds(
        address token,
        address recipient,
        uint256 amount
    ) external {
        require(msg.sender == address(timelock), "Timelock only");
        require(!paused(), "Paused");
        IERC20(token).transfer(recipient, amount);
    }

    // If emergency admin itself needs replacing, route through governance + timelock
    function setGuardian(address newGuardian) external {
        require(msg.sender == address(timelock), "Timelock only");
        guardian = newGuardian;
    }
}

The principle: emergency powers should be limited to actions that stop harm (pause), not actions that cause harm (drain). Fund movement and irreversible state changes must pass through timelock regardless of emergency classification.


What ContractScan Detects

Vulnerability Detection Method Severity
Flash loan voting (live balance snapshot) Detects balanceOf in vote weight calculation; flags missing getPastVotes Critical
No timelock on treasury/admin actions Traces execution paths from governance vote to fund movement; flags missing delay Critical
Proposal encoding mismatch Analyzes calldata binding in proposal construction; flags unbound description fields High
Quorum manipulation via token minting Detects circular minting authority; flags governance-controlled token supply High
Timestamp-vulnerable snapshot Identifies predictable snapshot blocks on L2 deployments; flags missing epoch locking Medium
Timelock bypass via emergency admin Maps all roles with direct execution paths; flags roles that bypass delay for fund ops Critical

These patterns are architectural — they require understanding execution flow, token economics, and cross-contract relationships. Static analysis tools that operate on syntax alone produce false negatives on every row in this table. Scan your governance contracts at ContractScan before deployment.



This post is for educational purposes. It does not constitute financial, legal, or investment advice. Always conduct a professional audit before deploying governance contracts to mainnet.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →