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.
Related Posts
- Token Economic Attacks: Governance Inflation and Supply Manipulation
- Access Control Vulnerabilities in Smart Contracts
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.