Reproduced Exploit
Yapeswap — Zero-Quorum Governance Capture, Then a Fake Yearn Vault
Yapeswap is a Uniswap V2 fork whose pairs farm a share of reserves into a Yearn vault chosen by YapeFactory.registry. Who may change that registry is feeToSetter, and at the fork that role sits behind TimelockedGovernance 0x463Ee0E8…78D8B, which WorkersUnion drives.
Loss
Reported about $21.9K (SlowMist, 25 Oct 2026), including Yearn positions this PoC does not unwind. This repro…
Chain
Ethereum
Category
governance
Date
Oct 2026
Source
DeFiHackLabs
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. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-10-Yapeswap_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Yapeswap_exp.sol.
Vulnerability classes: vuln/governance/proposal-manipulation · vuln/logic/state-update · vuln/logic/missing-validation
Reproduction: offline PASS.
_shared/run_poc.shloadedanvil_state.json(the runner kills anvil at output.txt:1346).[PASS] testExploit()gas 5,151,095, suite ok in 2.74s (output.txt:355, output.txt:1343). Trace: output.txt. Verified source: sources/YapeFactory_46aDc1, sources/YapePair_23c165, sources/WorkersUnion_5D3580, sources/VoteCounter_b32870. Test: test/Yapeswap_exp.sol.
Key info#
| Loss | Reported about $21.9K (SlowMist, 25 Oct 2026), including Yearn positions this PoC does not unwind. This reproduction’s liquid-reserve drain, priced in the test, is $2,254.32393044 (output.txt:363): 163.743781027207428308 DAI, 148.578005 USDC, 805.879296 USDT, 0.426300217673777164 WETH, 0.00119509 WBTC (output.txt:358–output.txt:362). |
| Vulnerable contracts | YapeFactory 0x46aDc1C052Fafd590F56C42e379d7d16622835a2. WorkersUnion proxy 0x5D35809b5558FbD8eFa18AE1B202db1973b95DdB (impl 0x3b5161078a7218398A6e03Df994DAd8F30367A69). Pairs drained listed below. SquareRootVoteCounter 0xb32870D82BF4E2d1f046d09b599ccB30f8A47A2c. |
| Attacker EOA | 0x6e523866424220b3cb2cab88ba2195cfa5636b12 |
| Attack contract | Live 0x327bf6dfc38a79393f78866c705193c39b04be01 (became operator and feeTo). Live malicious registry 0xC620CA5b2A63cdb792d462898010d2be4421b5A7. PoC Attacker is 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f; its registry is 0x104fBc016F4bb334D775a19E8A6510109AC63E00 |
| Attack tx | Historical capture executed through governance on 25 Oct 2026. This PoC does not replay that tx hash. It submits its own proposal on a fork before the real proposal existed (block 26,202,000) and warps the real pending, voting, and timelock windows. |
| Chain / block / date | Ethereum (chainId 1) / fork block 26,202,000 / incident 25 Oct 2026 |
| Compiler | Factory and pair: Solidity v0.8.6+commit.11564f7e, optimizer on, 999,999 runs. WorkersUnion and vote counter: v0.7.6+commit.7338295f, optimizer on, 1,000 runs. |
| Bug class | WorkersUnion.getVotingStatus rejects a proposal only when totalForVotes < minimumVotes. With the live minimum at 0, one positive sqrt-weighted lock and no opposition is Passed. The passed call moves YapeFactory.feeToSetter. That role points registry and operator at the attacker. YapePair._toYearn then deposits the pair’s reserves into registry.latestVault(token) and adds the departed tokens to farming[token], so keepBalance still holds. |
TL;DR#
Yapeswap is a Uniswap V2 fork whose pairs farm a share of reserves into a Yearn vault chosen by YapeFactory.registry. Who may change that registry is feeToSetter, and at the fork that role sits behind TimelockedGovernance 0x463Ee0E8…78D8B, which WorkersUnion drives.
WorkersUnion’s passing rule is totalForVotes > totalAgainstVotes once totalForVotes >= minimumVotes. The configured minimum is zero: a proposal passes on any strictly positive for-vote if nobody votes against. Voting power is the square root of a veYAPE lock. YAPE is swappable. 0.001 WETH buys 364,156,621.096224367726737573 YAPE from the YAPE/WETH pair (output.txt:415), and that lock is enough.
The PoC buys YAPE, locks it, proposes setFeeToSetter(attacker), warps, votes, warps, schedules, warps, and executes. factory.feeToSetter() is then the attacker contract (output.txt:357). It calls setOperator and setRegistry, installs a vault whose deposit() transferFroms the tokens to itself, sets the farming ratio to 100%, and rebalances both tokens of six pairs. keepBalance does not save the reserves: _toYearn credits farming[token] for tokens that now sit in the attacker’s vault.
Measured haul is $2,254.32393044, not the full ~$21.9K. The historical attacker first pulled capital already sitting in real Yearn vaults back into the pairs. That Yearn v3 withdraw does not replay on this pre-execution fork, so the PoC takes only the liquid reserves. It still asserts every reserve token moved and the USD figure is above $2,000 (output.txt:1326–output.txt:1336).
Background#
YapeFactory extends UniswapV2Factory with registry and operator. Both setters require msg.sender == feeToSetter. setFeeToSetter is the stock Uniswap V2 function, same check. The timelock holds feeToSetter, so a WorkersUnion proposal that the timelock executes can hand the role away. The proposal payload in this run is selector 0xa2e74af6, setFeeToSetter(address), targeting the factory, salt 0x9a9e (output.txt:521).
A YapePair keeps farming[token] beside its ERC-20 balance. _balanceOf is token.balanceOf(this) + farming[token]. keepBalance requires _balanceOf unchanged across a Yearn deposit or withdraw. _toYearn deposits amount through YapeWrapper._deposit and then adds the deposited amount to farming[token]. The invariant holds if and only if the vault actually took the tokens. It does not check that the pair received vault shares.
YapeWrapper.bestVault is registry().latestVault(token). registry() on the pair is a cached copy, refreshed by updateRegistry(), which reads IYapeFactory(factory).registry(). setFarmingRatio is onlyOperator. rebalance is permissionless for token0 or token1 (onlyReserved), and with force = true it farms toward the midpoint of the min and max ratios.
SquareRootVoteCounter.getVotes returns super.getVotes(veLockId, timestamp).sqrt(). getTotalVotes is totalSupply().sqrt(). A small lock is a small root, but a zero quorum does not care.
Governance timing used here, read from the live rule by the test: pending at least 1 day, voting at least 7 days, timelock delay 1 day. The PoC warps those windows. It does not prank the timelock and does not write feeToSetter by storage.
The six pairs at the fork:
| Pair | Tokens |
|---|---|
0x23c1653E…0483b6 | DAI / USDC |
0x670120d4…5B33B97 | YAPE / WETH |
0x8e5b9D85…A897805 | USDC / WETH |
0xE3b43bE5…787Ed25 | WETH / USDT |
0xEC5ED926…2216B6 | DAI / WETH |
0x0415Af9F…417c0AA | WBTC / WETH |
At the YAPE/WETH swap the reserves are 1,101,582,046.019660539255569637 YAPE and 0.002018947634222457 WETH (output.txt:408). The pool is thin, which is why 0.001 WETH clears most of the YAPE side.
The vulnerable code#
1. A zero minimum is a passing vote#
Verified WorkersUnion.sol in sources/WorkersUnion_5D3580. The zero is storage, not a literal in this function. The comparisons are what make a stored 0 fatal.
function getVotingStatus(bytes32 txHash) public view override returns (VotingState) {
Proposal storage proposal = _proposals[txHash];
require(proposal.start != 0, "Not an existing proposal");
if (block.timestamp < proposal.start) return VotingState.Pending;
else if (block.timestamp <= proposal.end) return VotingState.Voting;
else if (_timelock().isOperationDone(txHash)) return VotingState.Executed;
else if (proposal.totalForVotes < _votingRule.minimumVotes)
return VotingState.Rejected;
else if (proposal.totalForVotes > proposal.totalAgainstVotes)
return VotingState.Passed;
else return VotingState.Rejected;
}
schedule and execute both require getVotingStatus(txHash) == VotingState.Passed. _beforePropose only checks minimumVotesForProposing <= getVotesAt(msg.sender). With both minima at 0, proposing and passing are the same dust lock. SquareRootVoteCounter:
function getVotes(uint256 veLockId, uint256 timestamp) public view override returns (uint256) {
uint256 votes = super.getVotes(veLockId, timestamp);
return votes.sqrt();
}
2. feeToSetter retargets the vault registry#
function setFeeToSetter(address _feeToSetter) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN");
feeToSetter = _feeToSetter;
}
function setRegistry(address registry_) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN");
registry = registry_;
}
function setOperator(address operator_) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN");
operator = operator_;
}
3. The pair books a deposit that the vault stole#
modifier keepBalance(address token) {
uint256 prevBal = _balanceOf(token);
_;
require(_balanceOf(token) == prevBal, "Yapeswap: BALANCE CHANGED");
}
function _balanceOf(address token) internal view override returns (uint256) {
return IERC20(token).balanceOf(address(this)).add(farming[token]);
}
function _toYearn(address token, uint256 amount) internal keepBalance(token) {
uint256 deposited = _deposit(token, address(this), address(this), amount, false);
farming[token] = farming[token].add(deposited);
}
_deposit approves bestVault(token) and calls vault.deposit(amount). deposited is the pair’s token-balance decrease, not shares minted. The PoC vault’s deposit is transferFrom(pair, attacker, amount) and returns amount. Token balance falls, farming rises by the same number, keepBalance passes, and the tokens are gone.
setFarmingRatio(10000, 10000) with DENOMINATOR = 10000 means farm 100%. rebalance uses force = true, so the pair deposits up to the midpoint, which is the entire _balanceOf.
Root cause#
-
Quorum is a configuration the code treats as a floor of zero.
totalForVotes < 0is impossible for auint256. The next branch passes the proposal whenever for-votes strictly beat against-votes. Nobody has to show up to defeat a dust vote. SlowMist measured the historical for-vote at about 0.000055% of voting power. This reconstruction does not need a coalition. One lock from 0.001 WETH is the whole electorate. -
What a passed proposal may call includes
setFeeToSetter. That single storage word is the admin ofregistry,operator, andfeeTo. Governance scope and treasury admin are the same key. -
farming[token]is an IOU the pair writes to itself. The Yearn integration assumeslatestVaultis a vault that custodies assets and issues shares.keepBalanceenforces the IOU, not custody. A vault that pulls tokens and returns no shares satisfies the modifier and breaks the reserve. -
rebalanceis public. Afteroperatoris the attacker,setFarmingRatio(100%, 100%)plusrebalanceon both tokens empties every pair that has a liquid balance. No further vote is required.
The PoC does not reproduce the preliminary “pull real Yearn back to the pair” leg (setFarmingRatio(0, 0) then rebalance). That is why $2,254.32 is the right number for this trace and ~$21.9K is the right number for the incident.
Preconditions#
minimumVotesandminimumVotesForProposingare 0 on the live WorkersUnion. The code path is verified. The stored zero is demonstrated by this dust proposal reachingscheduleandexecutewithout a second voter.- YAPE can be bought. Here the seed is 0.001 WETH, dealt in the test (
VM::storeon WETH at output.txt:400). That is seed capital, not a write of protocol roles. - The attacker can wait out pending (≥ 1 day), voting (≥ 7 days), and the timelock (1 day), or warp them in a fork. No vote can be cast against the proposal during the voting window.
feeToSetteris the timelock, and the timelock executes WorkersUnion payloads that target the factory.- Pairs hold liquid token0/token1. Farmed Yearn balances are extra and are not taken by this PoC.
updateRegistryis permissionless, so each pair picks up the new registry as soon as someone calls it.
Attack walkthrough#
Offline fork of block 26,202,000. One test, gas 5,151,095.
-
Buy votes.
dealgives the attacker 0.001 WETH (output.txt:400–output.txt:406).acquireVotesreads reserves (output.txt:408), transfers the 0.001 WETH in (output.txt:409), andswaps out 364,156,621.096224367726737573 YAPE (output.txt:415, output.txt:420).createLockUntil(364156621096224367726737573, 1918260479)locks it forMAXTIME(output.txt:456). The lock id iskeccak256(abi.encodePacked(block.number, attacker)). The ve lock NFT is received by the attacker contract. -
Propose.
WorkersUnion.proposeTx(factory, 0, setFeeToSetter(attacker), predecessor 0, salt 0x9a9e, startsIn 86400, votingPeriod 604800)(output.txt:521). Delegatecall to impl0x3b516107…(output.txt:522). -
Vote. Warp
startsIn + 1.manualVote(txHash, [lockId], true)(output.txt:562). The tx hash is0xd545c70c82f16ea98d23a15966a5613eb65ee0f75486f478f00928276e3b4086. Sqrt weighting means any positive lock beats a zero against-total. -
Schedule. Warp
votingPeriod + 1.schedule(output.txt:591) requiresVotingState.Passedand callsforceSchedulewithgetMinDelay(). -
Execute. Warp
1 days + 1.execute(output.txt:619) calls the timelock0x463Ee0E8…, which delegatecalls0xCc343673…(output.txt:629–output.txt:630) and runssetFeeToSetter.feeToSetter()is now0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f(output.txt:649). The assertion in the test is that equality. -
Point the pairs at the fake vault.
setOperator(attacker)(output.txt:651).setRegistry(MaliciousRegistry)(output.txt:655). The registry’slatestVaultis aMaliciousVaultper token.depositpulls the approved tokens to the attacker. -
Rebalance all six pairs at 100%. For the DAI/USDC pair:
updateRegistry(output.txt:695),setFarmingRatio(10000, 10000)(output.txt:701),rebalance(DAI)(output.txt:709),rebalance(USDC)(output.txt:748). The same four calls hit YAPE/WETH (output.txt:806–output.txt:882), USDC/WETH (output.txt:919–output.txt:993), WETH/USDT (output.txt:1030–output.txt:1083), DAI/WETH (output.txt:1120–output.txt:1173), and WBTC/WETH (output.txt:1210–output.txt:1264). -
Count the tokens. Logged at output.txt:1318–output.txt:1325 and in the summary at output.txt:358–output.txt:363:
| Asset | Captured |
|---|---|
| DAI | 163.743781027207428308 |
| USDC | 148.578005 |
| USDT | 805.879296 |
| WETH | 0.426300217673777164 |
| WBTC | 0.00119509 |
| USD (8 dp, Chainlink ETH/USD plus WBTC via the Uniswap WBTC/WETH spot) | 2,254.32393044 |
assertGt on each balance and on usd8 > 2000e8 all pass (output.txt:1336). Suite ok, 1 passed (output.txt:1343).
Diagrams#
Remediation#
- Set
minimumVotesandminimumVotesForProposingto a real fraction ofgetTotalVotes(), and reject a proposal whose for-votes are under that floor even if against-votes are zero. A stored zero should be rejected atchangeVotingRuletime. The constructor already capsminimumVotesattotalVotes/2andminimumVotesForProposingattotalVotes/10, but it allows 0. - Do not let a governance proposal replace
feeToSetterin one step, or split registry admin from the fee recipient. Changingregistryshould be a dedicated operation with a higher quorum than a fee change. keepBalancemust require vault shares, not a self-creditedfarmingbalance._toYearnshould increasefarmingonly byshares * pricePerShare, andlatestVaultshould be pinned to a factory-approved codehash or a fixed registry the operator cannot swap.setFarmingRatioshould be bounded so one transaction cannot move the ratio from the current value to 100% andrebalancein the same breath. A per-block cap on assets sent to the vault would have limited this drain to a slice of reserves.rebalancecan stay permissionless only if the vault cannot be changed out from under it.
How to reproduce#
Offline, from the registry root. Anvil loads anvil_state.json and the test forks http://127.0.0.1:8545 at block 26,202,000:
_shared/run_poc.sh 2026-10-Yapeswap_exp -vvvvv
Expect [PASS] testExploit(), factory.feeToSetter now: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, and Total value stolen (USD): 2254.32393044. The run in output.txt finished in 2.74s with gas 5,151,095. No archive RPC is required once anvil_state.json is present.
Reference: https://x.com/SlowMist_Team/status/2114290073546219799
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-10-Yapeswap_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Yapeswap_exp.sol.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Yapeswap".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.