← Back to Blog

Stop Governance Attack Flash Loan Voting in Solidity

2026-07-11 solidity smart-contract-security governance-attack flash-loan defi-security openzeppelin timelock

On April 17, 2022, Beanstalk Farms lost $182 million in a flash loan governance exploit. The attacker borrowed over $1 billion in assets from Aave and Uniswap, used the capital to accumulate a dominant governance share, passed a malicious proposal to drain the protocol's liquidity pools, executed the proposal immediately, and repaid the flash loans—all within a single Ethereum transaction.

This incident illustrates the vulnerability of decentralized governance systems to capital-backed attacks. If a governance contract queries token balances directly to determine voting power, any entity with temporary access to capital can manipulate protocol outcomes.


The Mechanics of Flash-Loan Governance Attacks

In decentralized protocols, governance models determine system parameters, treasury allocations, and smart contract upgrades. Traditional systems weigh votes proportionally based on token ownership.

A flash-loan governance attack exploits the instantaneous nature of flash loans. Because a flash loan allows an actor to borrow millions of dollars in tokens without collateral, provided they return the capital in the same transaction, the attacker can:

  1. Borrow a massive volume of governance tokens (or assets to mint/acquire them) via a flash loan.
  2. Submit or vote on a malicious proposal.
  3. Execute the proposal immediately if there is no delay mechanism.
  4. Liquidate the assets back to the borrowed tokens.
  5. Repay the flash loan, pocketing the stolen funds.

The core vulnerability is the temporal overlap of voting power calculation and execution. When voting power is determined by the current balance at the execution time of the transaction, the protocol assumes that capital ownership represents long-term alignment with the protocol. Flash loans invalidate this assumption by separating capital access from economic risk.


Vulnerable Governance Implementation

The following Solidity contract shows a simplified but realistic governance system. It is vulnerable to flash-loan voting because it queries the token's current balance at the time of voting.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}

/**
 * @title VulnerableGovernance
 * @dev This contract demonstrates a flash-loan vulnerable voting mechanism.
 */
contract VulnerableGovernance {
    struct Proposal {
        address target;
        bytes data;
        uint256 votesFor;
        bool executed;
    }

    IERC20 public immutable governanceToken;
    Proposal[] public proposals;

    // proposalId => voter => hasVoted
    mapping(uint256 => mapping(address => bool)) public hasVoted;
    uint256 public constant VOTING_THRESHOLD = 1_000_000 * 10**18; // 1 Million tokens

    constructor(address _token) {
        require(_token != address(0), "Invalid token address");
        governanceToken = IERC20(_token);
    }

    function propose(address target, bytes calldata data) external returns (uint256) {
        proposals.push(Proposal({
            target: target,
            data: data,
            votesFor: 0,
            executed: false
        }));
        return proposals.length - 1;
    }

    /**
     * @notice Cast a vote for a proposal.
     * @dev VULNERABLE: Reads current token balance. Anyone can flash loan the token
     * and vote within the same block transaction.
     */
    function vote(uint256 proposalId) external {
        Proposal storage proposal = proposals[proposalId];
        require(!proposal.executed, "Proposal already executed");
        require(!hasVoted[proposalId][msg.sender], "Voter already cast vote");

        // @audit VULNERABLE LINE: Queries current token balance in the current execution frame.
        uint256 votingPower = governanceToken.balanceOf(msg.sender);
        require(votingPower > 0, "Zero voting power");

        proposal.votesFor += votingPower;
        hasVoted[proposalId][msg.sender] = true;
    }

    /**
     * @notice Execute a passed proposal.
     * @dev If the threshold is met, the target is called with execution data.
     */
    function execute(uint256 proposalId) external {
        Proposal storage proposal = proposals[proposalId];
        require(!proposal.executed, "Proposal already executed");
        require(proposal.votesFor >= VOTING_THRESHOLD, "Insufficient voting threshold");

        proposal.executed = true;
        (bool success, ) = proposal.target.call(proposal.data);
        require(success, "Governance execution failed");
    }
}

Vulnerability Analysis

The security flaw lies in the vote function:

uint256 votingPower = governanceToken.balanceOf(msg.sender);

Since balanceOf returns the real-time balance of the caller at that exact moment of execution, an attacker can invoke a flash loan provider, borrow millions of governance tokens, call vote(), trigger execute(), and then return the tokens, all in the same block.


Exploit Scenario Walkthrough

To exploit VulnerableGovernance, an attacker creates an exploit contract designed to interact with a Uniswap V2 Pair or a balancer vault offering flash loans.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVulnerableGovernance {
    function propose(address target, bytes calldata data) external returns (uint256);
    function vote(uint256 proposalId) external;
    function execute(uint256 proposalId) external;
}

interface IFlashLender {
    function flashLoan(address recipient, address token, uint256 amount, bytes calldata data) external;
}

contract GovernanceExploiter {
    IVulnerableGovernance public immutable targetGov;
    address public immutable govToken;
    address public immutable victimTreasury;

    constructor(address _targetGov, address _govToken, address _victimTreasury) {
        targetGov = IVulnerableGovernance(_targetGov);
        govToken = _govToken;
        victimTreasury = _victimTreasury;
    }

    // Fallback or callback invoked by the flash lender
    function executeExploit(address lender, uint256 loanAmount) external {
        // 1. Submit a malicious proposal to transfer treasury funds to the attacker
        bytes memory payload = abi.encodeWithSignature("drainTreasury(address)", address(this));
        uint256 proposalId = targetGov.propose(victimTreasury, payload);

        // 2. Vote on the proposal using the borrowed flash loan balance
        targetGov.vote(proposalId);

        // 3. Execute the proposal immediately in the same transaction
        targetGov.execute(proposalId);

        // 4. Repay the lender (including fees, if any)
        IERC20(govToken).transfer(lender, loanAmount);
    }
}

The attack flow follows a strict execution path:
1. The attacker deploys GovernanceExploiter.
2. The attacker triggers the flash loan on the lender contract, requesting $1,500,000 worth of governance tokens.
3. The lender transfers the tokens to GovernanceExploiter and calls the contract's callback function.
4. Inside the callback (executeExploit), the contract uses the tokens to vote on the newly created proposal.
5. Because the proposal meets the VOTING_THRESHOLD of 1,000,000 tokens immediately, the exploit contract calls execute().
6. The victimTreasury executes the proposal, sending its assets to GovernanceExploiter.
7. GovernanceExploiter pays back the flash loan to the lender and transfers the stolen assets to the attacker's wallet.


Mitigating Governance Manipulation

Securing smart-contract governance requires decoupling the voting calculation from the current transaction state. Two primary design patterns achieve this: checkpointed voting power (historical voting weights) and execution delays (timelocks).

1. Historical Voting Checkpoints (ERC20Votes)

Instead of querying the current balance, governance contracts should query the token balance at a historical point in time. OpenZeppelin's ERC20Votes (implementing ERC-5805 and ERC-6372) keeps a history of balances and delegations over time using "checkpoints."

When a vote is cast on a proposal, the contract looks up the voting weight at the block number of the proposal's creation. Because a flash loan is borrowed and repaid within the same block, the attacker's balance in any historical block remains zero.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title SecureGovernance
 * @dev Implements secure voting weights by querying historical checkpoints.
 */
contract SecureGovernance is ReentrancyGuard {
    struct Proposal {
        address target;
        bytes data;
        uint256 votesFor;
        uint256 startBlock;
        bool executed;
    }

    ERC20Votes public immutable governanceToken;
    Proposal[] public proposals;

    mapping(uint256 => mapping(address => bool)) public hasVoted;
    uint256 public constant VOTING_THRESHOLD = 1_000_000 * 10**18;

    constructor(ERC20Votes _token) {
        require(address(_token) != address(0), "Invalid token address");
        governanceToken = _token;
    }

    function propose(address target, bytes calldata data) external returns (uint256) {
        proposals.push(Proposal({
            target: target,
            data: data,
            votesFor: 0,
            startBlock: block.number, // Pin the voting power to this block
            executed: false
        }));
        return proposals.length - 1;
    }

    /**
     * @notice Cast a vote for a proposal using historical snapshots.
     * @dev SECURE: Queries past votes at the proposal's start block.
     */
    function vote(uint256 proposalId) external nonReentrant {
        Proposal storage proposal = proposals[proposalId];
        require(!proposal.executed, "Proposal already executed");
        require(!hasVoted[proposalId][msg.sender], "Voter already cast vote");

        // Prevent voting on a proposal created in the current block to deny same-block manipulation
        require(proposal.startBlock < block.number, "Voting not active in creation block");

        // SECURE: Retrieves the caller's delegated voting weight at the proposal's startBlock
        uint256 votingPower = governanceToken.getPastVotes(msg.sender, proposal.startBlock);
        require(votingPower > 0, "No historical voting power");

        proposal.votesFor += votingPower;
        hasVoted[proposalId][msg.sender] = true;
    }

    function execute(uint256 proposalId) external nonReentrant {
        Proposal storage proposal = proposals[proposalId];
        require(!proposal.executed, "Proposal already executed");
        require(proposal.votesFor >= VOTING_THRESHOLD, "Insufficient voting threshold");

        proposal.executed = true;
        (bool success, ) = proposal.target.call(proposal.data);
        require(success, "Governance execution failed");
    }
}

Technical Trade-offs of Checkpointing


Timelock Architecture and Delay Controls

Even with historical checkpointing, an attacker with structural capital (e.g., a large whale or a multi-day loan) can still acquire enough voting power to manipulate a vote. To prevent immediate execution of malicious transactions, protocols deploy a Timelock contract as the owner of the target system.

A timelock forces proposed actions to wait for a minimum period (such as 48 hours or 7 days) between the time a proposal passes and the time it is executed.

Proposal Passed -> [Queue Transaction in Timelock] -> [Delay Period: 48 Hours] -> Execute

During this delay period, the protocol's security team, multisig signers, or community members can:
- Inspect the transaction details.
- Withdraw their assets from the protocol if the proposal is hostile.
- Trigger a veto or pause mechanism if a guardian multisig exists.

Implementing OpenZeppelin TimelockController

The standard implementation uses OpenZeppelin's TimelockController. This contract accepts transactions proposed by the governance contract, queues them, and allows execution only after the delay expires.


Case Study: Beanstalk Farms BIP-12 Exploit

The Beanstalk Farms hack on April 17, 2022, remains a clear example of flash loan governance failure. The protocol did not have a timelock that delayed the immediate execution of proposals.

The Attack Vector

Beanstalk's governance relied on a mechanism where the voting power was checked in real-time. Additionally, the protocol allowed proposals to be executed immediately upon reaching a supermajority (two-thirds of the voting power).

The attacker executed the following steps:
1. Flash Loan Accumulation: The attacker took out flash loans including $350 million in DAI, $500 million in USDC, $150 million in USDT, and millions in other protocol-native assets.
2. Liquidity Provisioning: They deposited these assets into the Beanstalk Curve pools to obtain massive quantities of Beanstalk's governance token (Stalk).
3. Proposal Submission & Voting: The attacker proposed BIP-12 (which targeted the treasury) and voted on it with their newly minted Stalk tokens.
4. Immediate Execution: Because they held more than 70% of the voting power, the proposal passed the supermajority requirement instantly. The protocol lacked a delay mechanism for this scenario, allowing the attacker to trigger execution in the same block.
5. Draining and Repayment: The execution transferred $182 million in protocol assets to the attacker. The attacker removed the liquidity, repaid the flash loans, and washed the profits ($77 million net) through Tornado Cash.

This attack succeeded because the protocol allowed same-block proposal execution combined with real-time balance calculations.


Pre-deployment Security Checklist

Before launching a governance system, review these configurations:


Frequently Asked Questions

Can an attacker bypass ERC20Votes by holding tokens over multiple blocks?

Yes. If an attacker uses a multi-block loan (such as borrowing funds via a money market like Aave over several blocks rather than using a single-transaction flash loan), historical checkpoints will register their balance. However, this raises the cost and risk of the attack, as they must pay interest over time and expose their capital to market price fluctuations and liquidation risk. Combining checkpoints with a long Timelock delay ensures that even if an attacker borrows capital over several blocks, the community has ample time to react.

Why is block.number preferred over block.timestamp for historical checkpoints?

block.timestamp can be manipulated slightly by validators who construct blocks, whereas block.number increments monotonically and is deterministic. While L2 networks have different block generation dynamics, querying historical states based on block numbers provides a reliable ledger of when actions occurred.

Does OpenZeppelin Governor prevent flash loan attacks out of the box?

Yes. The standard OpenZeppelin Governor contract requires the governance token to inherit from ERC20Votes. When initializing a proposal, the protocol records the snapshot block (typically the current block minus one) and queries the user's voting power at that past block. This native integration mitigates single-transaction flash loan voting.


Ensure your DAO and protocol assets are secure before going to mainnet. Audit your smart contracts and detect critical governance vulnerabilities automatically with ContractScan.

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