Reproduced Exploit
Canto (veRWA) — removing a gauge permanently strands a voter's voting power
1. A voter commits 100% (10,000 bps) of their voting power to gauge1 via vote_for_gauge_weights(gauge1, 10000). 2. Governance later calls remove_gauge(gauge1) — for any legitimate reason (the pool was faulty, became illiquid, or its incentive period expired).
Chain
Other
Category
access-control
Date
Aug 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th. Standalone Foundry PoC and full write-up: 26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/access-control/missing-state-reset · vuln/governance/stranded-voting-power · vuln/logic/incomplete-invalidation
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th_exp.sol.
AuditVault taxonomy: lang/solidity · lang/vyper · platform/code4rena · has/github · has/poc · severity/high · sector/governance · genome: frozen-funds · locked-funds · access-roles
Key info#
| Impact | HIGH — a voter who allocates voting power to a gauge that governance later removes can never move OR reclaim that power; it is permanently stranded |
| Protocol | Canto veRWA — GaugeController.sol, a Solidity port of Curve DAO's gauge weight voting system |
| Vulnerable code | GaugeController.vote_for_gauge_weights() requiring isValidGauge[_gauge_addr] unconditionally, combined with remove_gauge() never clearing the voter's own allocation |
| Bug class | Incomplete invalidation: removing a gauge invalidates the gauge but not outstanding per-voter state that references it |
| Finding | Code4rena — Canto veRWA, 2023-08 · #26976 (H-08) · reporter popular00 |
| Report | 2023-08-verwa |
| Source | AuditVault |
| Status | Audit finding — caught in review (not exploited on-chain). Reproduced here as a standalone local PoC. |
| Compiler | ^0.8.24 (PoC, via-ir) |
This is an audit finding, not a historical on-chain incident. The real
GaugeController depends on a VotingEscrow contract for a voter's lock/slope
data; the PoC keeps GaugeController essentially verbatim (only the
VotingEscrow type is swapped for a minimal configurable mock — none of the
vulnerable control-flow is touched) so the blamed checks execute exactly as in
the audited code.
TL;DR#
- A voter commits 100% (10,000 bps) of their voting power to
gauge1viavote_for_gauge_weights(gauge1, 10000). - Governance later calls
remove_gauge(gauge1)— for any legitimate reason (the pool was faulty, became illiquid, or its incentive period expired). remove_gauge()setsisValidGauge[gauge1] = falseand zeroes the gauge's total weight, but it never touchesvote_user_power[voter]orvote_user_slopes[voter][gauge1]— the voter's own bookkeeping is untouched.- The voter tries to redirect their power to
gauge2: blocked — the stale 10,000 bps allocated to the removedgauge1is still counted against the 10,000 bps cap invote_for_gauge_weights. - The voter tries to vote
0ongauge1just to release the power: blocked too —vote_for_gauge_weightsrequiresisValidGauge[_gauge_addr]even for a zero-weight vote, andgauge1no longer validates. - HARM in the PoC: after removal,
vote_user_power[voter]still reads10,000and BOTH escape attempts revert. The voter's power is permanently and irrecoverably stranded — a portion of the protocol's whole voting supply is now dead weight forever.
The vulnerable code#
remove_gauge never resets the remover's own vote (verbatim,
GaugeController.sol):
function remove_gauge(address _gauge) external onlyGovernance {
require(isValidGauge[_gauge], "Invalid gauge address");
isValidGauge[_gauge] = false;
_change_gauge_weight(_gauge, 0); // @> VULN: never clears vote_user_slopes/vote_user_power for _gauge
emit GaugeRemoved(_gauge);
}
vote_for_gauge_weights then shuts the door on recovering that power (verbatim):
function vote_for_gauge_weights(address _gauge_addr, uint256 _user_weight) external {
require(_user_weight >= 0 && _user_weight <= 10_000, "Invalid user weight");
require(isValidGauge[_gauge_addr], "Invalid gauge address"); // @> VULN: blocks even a 0-weight "give my power back" vote
...
uint256 power_used = vote_user_power[msg.sender];
power_used = power_used + new_slope.power - old_slope.power;
require(power_used >= 0 && power_used <= 10_000, "Used too much power"); // @> stale allocation still counted here
...
}
Since _gauge_addr must satisfy isValidGauge[_gauge_addr] no matter what
_user_weight is, a removed gauge can never again appear in a
vote_for_gauge_weights call — not even with weight 0.
Root cause#
remove_gauge treats "invalidate the gauge" and "invalidate everyone's votes
on the gauge" as the same operation, but only does the first. The gauge's
global weight is reset, yet every voter's individual vote_user_power /
vote_user_slopes entry for that gauge survives untouched. Because
vote_for_gauge_weights gates on gauge validity for every call including a
0-weight release, there is no code path left that can zero out a stale
allocation once its gauge is gone — the voter is stuck with a permanently
"used" chunk of their 10,000 bps budget.
Preconditions#
- The voter has voted a nonzero weight for some gauge.
- Governance removes that gauge for any reason (this is explicitly an anticipated, ordinary admin action — not a malicious or unusual one).
Attack walkthrough#
From output.txt:
- The voter votes
10,000bps (100%) ongauge1. - Governance calls
remove_gauge(gauge1)—isValidGauge[gauge1]becomesfalse,gauge1's weight is zeroed, butvote_user_power[voter] == 10,000is untouched. vote_for_gauge_weights(gauge2, 10000)reverts"Used too much power"— the stale 10,000 bps ongauge1is still on the books.vote_for_gauge_weights(gauge1, 0)reverts"Invalid gauge address"— the only function that could release the power refuses to run on an invalid gauge.- HARM:
vote_user_power[voter]still reads10,000with no way left to reduce it. The control testtest_removingAnUnusedGauge_isHarmlessconfirms the defect is specific to voters who had an outstanding allocation — removing an unvoted gauge is harmless, isolating the missing state-reset as the root cause.
Diagrams#
Remediation#
Per the report, the simplest fix is to allow zero-weight votes on invalid gauges purely to release the allocation:
require(_user_weight == 0 || isValidGauge[_gauge_addr], "Can only vote 0 on non-gauges");
Alternatively (or additionally), remove_gauge could walk/void outstanding
votes itself, or an external "force-release" function could be added for
voters stuck on a removed gauge.
How to reproduce#
cd ~/RustroverProjects/audits/evm-hack-registry/26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: all three tests PASS:
# test_exploit (self-contained Exploit: power stranded)
# test_removedGaugeStrandsVotingPower (standalone rebuild mirroring the finding's PoC)
# test_removingAnUnusedGauge_isHarmless (control: removing an unvoted gauge is safe)
PoC source: test/26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th_exp.sol
— drives the cheatcode-free Exploit, plus a standalone rebuild and a control test.
Note:
GaugeControllerhere is a faithful, near-verbatim reduction ofsrc/GaugeController.solfrom the audited repo (code-423n4/2023-08-verwa@a693b4d) — only theVotingEscrowdependency is replaced by a minimal mock exposing the two view functionsvote_for_gauge_weightsreads (getLastUserPoint,lockEnd); it does not affect the bug. Every blamed line and the voting/removal control-flow are unchanged from the audited source.
Reference: finding #26976 (H-08) by popular00 in the Code4rena Canto veRWA review (Aug 2023) · curated by AuditVault
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th_exp (evm-hack-registry mirror).
- AuditVault finding: 26976-h-08-if-governance-removes-a-gauge-users-voting-power-for-th.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.