56798 sc critical flash vote exploit drains all funds via alchemistallocator

Submitted on Oct 20th 2025 at 18:59:31 UTC by @pirex for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56798

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistAllocator.sol

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

The PerpetualGauge contract contains a critical accounting flaw in its vote weight management. When users clear or update their votes, the contract subtracts weight based on their current token balance rather than the balance they held when voting. An attacker can exploit this by borrowing governance tokens via flash loan, voting to redirect 100% of vault assets to a malicious strategy, repaying the loan, and then clearing their vote. Because the subtraction uses a zero balance, the inflated weight persists until vote expiration (up to 365 days). On the next allocation cycle, all idle liquidity flows to the attacker-controlled adapter via AlchemistAllocator.executeAllocation(), enabling complete theft of vault funds in a single transaction.


Vulnerability Details

Location: src/Governance/PerpetualGauge.sol Functions: vote() and clearVote()

The root cause lies in how the contract removes voting weight. When a user calls clearVote(), the contract attempts to subtract their previous vote weight using this logic:

aggStrategyWeight[strategyId] -= existing.weights[i] * votingToken.balanceOf(msg.sender);

The problem is straightforward: balanceOf(msg.sender) returns the user's current balance, not the balance they held when they originally voted. If tokens have been transferred away or repaid after a flash loan, this value becomes zero. The subtraction becomes weight * 0 = 0, leaving the original inflated weight completely intact in the aggregated strategy weights.

The contract never stores a snapshot of the voting power used when casting votes. Without this historical record, there's no way to correctly reverse the weight addition during vote updates or deletions.

Attack Flow

  1. Attacker borrows a large amount of governance tokens through an ERC-3156 flash loan

  2. While holding the borrowed tokens, they call vote() to assign 100% weight to a malicious strategy

  3. The contract calculates weight as borrowedBalance * weight and adds it to aggStrategyWeight

  4. Attacker repays the flash loan in the same transaction, reducing their balance to zero

  5. Attacker calls clearVote(), but the subtraction becomes weight * 0, leaving the inflated weight unchanged

  6. The vote record is deleted from storage, but aggStrategyWeight still reflects the borrowed balance

  7. When executeAllocation() runs, getCurrentAllocations() normalizes weights and shows 100% allocation to the attacker's strategy

  8. All idle vault assets are transferred to the malicious adapter via allocatorProxy.allocate()

  9. The adapter's onAllocate() callback drains the funds to the attacker's address

This entire sequence happens atomically within one block. The attacker never needs to hold governance tokens beyond the flash loan callback.

Why Standard Defenses Don't Help

  • Flash loan detection: The exploit completes before the loan is repaid, making detection ineffective

  • Balance checks: The contract does check balances, but at the wrong time (during clearing rather than storing)

  • Vote expiry: Votes last up to 365 days, giving attackers a full year to execute the allocation

  • Reentrancy guards: Already present but irrelevant since the issue is logical, not related to reentrancy


Impact Details

This vulnerability allows complete theft of all vault assets managed by the PerpetualGauge allocator. The specific impacts include:

Financial Loss

  • 100% of idle liquidity in the vault can be redirected to attacker-controlled strategies

  • No minimum balance requirement for the attacker beyond flash loan access

  • Works on any vault size, from thousands to millions of dollars in TVL

Attack Characteristics

  • Privilege Required: None – uses only public functions

  • Exploitation Complexity: Low – standard flash loan providers (Aave, Balancer) make this trivial

  • User Interaction: None – victims don't need to do anything

  • Persistence: Weight remains inflated for up to 365 days (MAX_VOTE_DURATION) or until manual correction

  • Detectability: Very low – vote records appear cleared in storage while weights remain manipulated

Real-World Scenario

An Alchemix V3 vault holds $5M in idle DAI awaiting allocation. An attacker executes the flash vote exploit with borrowed governance tokens, setting their malicious strategy to receive 100% allocation. When the next keeper calls executeAllocation(), the entire $5M flows to the attacker's adapter, which immediately withdraws the funds. Total loss: $5M. Time required: one transaction.

Cascading Effects

  • Users cannot withdraw their deposits as the underlying assets are gone

  • Legitimate strategies receive zero allocation, breaking expected yield generation

  • Vault reputation destroyed, impacting all Alchemix products

  • Potential regulatory scrutiny due to fund mismanagement

This meets Immunefi's "Critical" definition: direct theft of user funds, no privileged access required, reproducible, and requires no user interaction.


References

  • PerpetualGauge.sol: src/Governance/PerpetualGauge.sol

  • AlchemistAllocator.sol: src/AlchemistAllocator.sol

  • ERC-3156 Flash Loan Standard: https://eips.ethereum.org/EIPS/eip-3156


Proof of Concept

Proof of Concept

v3-poc/src/test/Governance/PerpetualGaugeFlashVotePoC.t.sol

Running the PoC

Environment Setup:

Execute Test:


Essential Test Logs Demonstrating the Vulnerability

Critical Observations:

  • Attacker borrowed 1000 ether of governance tokens via flash loan

  • Vote was cast with full borrowed balance: balanceOf = 1e21

  • Flash loan repaid in same transaction: balanceOf = 0

  • After clearVote(), weight calculation uses zero balance: weight * 0 = 0

  • Inflated weight persists: getCurrentAllocations() = [111], [1e18] (100%)

  • Vote storage is empty but weight remains unchanged

  • Entire vault drained atomically: Looted(amount: 2e21)

  • Allocator balance after attack: 0 (complete theft)

  • Malicious strategy received: 2000 ether (100% of vault funds)

This demonstrates the vulnerability allows an attacker with zero governance tokens to permanently manipulate allocation weights and drain 100% of vault funds in a single atomic transaction.

Was this helpful?