# Users can cast their votes multiple times for the proposal by transferring their nfts and then voting again

Blackhole Governor counts votes per address, not per NFT, so transferring a locked smNFT to a fresh address lets the same voting power be cast repeatedly.

- Payout: Contest reward
- Severity: medium
- Program: Blackhole
- Credited: hakunamatata
- Disclosed: 2025-07-03
- Write-up quality: 6/10
- Original: https://code4rena.com/reports/2025-05-blackhole#m-17
- Library page: https://bugbountylibrary.com/r/c4%3A2025-05-blackhole%3AM-17
- Tags: domain:web3 impact:integrity kind:contest platform:code4rena sev:medium tech:evm tech:solidity technique:source-review vuln:business-logic vuln:governance

**Target:** Governor / VotingEscrow governance contracts
**Root cause:** _castVote only checks whether msg.sender has voted and derives weight from the NFT balance at voteStart; it never records which tokenIds were used, so ownership changes mid-vote reset the per-address voted flag.
**Steps:**
1. Create a lock via `votingEscrow.create_lock(1_000_000e18, 4*365 days, true)` from userA.
2. Submit proposal and advance past `votingDelay()`.
3. Call `blackGovernor.castVote(proposalId, 1)` from userA.
4. Transfer the smNFT: `votingEscrow.transferFrom(userA, userB, 1)`.
5. Call `blackGovernor.castVote(proposalId, 1)` again from userB — same weight counted twice.
6. Repeat transfer/castVote loop across attacker-controlled addresses until gas runs out.
**How found:** Noticed castVote only tracks per-address voted state and derives weight from NFT balance at voteStart, then reasoned that transferring the NFT to a fresh address resets the voted flag while preserving weight; confirmed with a Foundry PoC.
**Exploitation:** Attacker with a single locked position votes, transfers the smNFT to another controlled address, and votes again with identical weight, repeating to inflate forVotes and swing or disrupt the proposal outcome.
**Insight:** In ve/NFT governance, weight is bound to the token, not the address — check whether the voted flag is keyed on tokenId (or ownership-change block) rather than msg.sender, and test transfer-then-vote loops.
