C-1: Insufficient input validation allows anyone to steal other user's voting escrow position
Summary:
Attacker can sacrifice his own NFT and steal other user's position due to a missing check in VotingEscrow.transferFrom() and VotingEscrow.safeTransferFrom().
Description:
VotingEscrow.create_lock() mints user an NFT, the token_id is uint256 format of user_address: user address is soulbound to user NFT id.
Inside the transfers functions the contract uses erc721._is_approved_or_owner(msg.sender, token_id) for ownership check. The check verifies if msg.sender is the owner of token_id, or if msg.sender has sufficient allowance for token_id. But there is no check to verify token_id == uint256 format of owner.
Say max lock time is reached, and attacker calls transferFrom(victim_address, attacker_address, attacker_token_id). The first check will pass because attacker owns attacker_token_id. The second check will pass because max lock time is reached.
The internal function self._merge_positions(owner, to) is then executed, victim's position is merged with attacker's.
Then erc721._burn(token_id) burns attacker's NFT. In short, attacker can sacrifice his own NFT and steal victim's position. If attacker's position is tiny compared to victim's position, this attack lets him sacrifice something small and grief something big.
To amplify the impact, consider this intricately designed attack:
- Attack prepares two wallets A and B, mints NFT for each (call them NFT A and NFT B)
- Wallets B approves wallet A max allowance (to bypass the snekmate
erc721._is_approved_or_ownercheck) - Attacker calls
transferFrom(victim_address, wallet_A_address, NFT_B_token_id) - Victim's position is merged to wallet A's position
- NFT B is burned but attacker can still withdraw all the asset via NFT A.
Impact:
Attacker can steal any user's position.
Recommendation:
Add validation to ensure the token_id corresponds to the owner parameter in both functions:
@external
@payable
def transferFrom(owner: address, to: address, token_id: uint256):
assert token_id == convert(owner, uint256), "token_id must match owner" # ← ADD THIS
assert erc721._is_approved_or_owner(msg.sender, token_id), "erc721: caller is not token owner or approved"
assert self._ve_transfer_allowed(owner, to), "Need max veLock"
self._merge_positions(owner, to)
erc721._burn(token_id)
@external
@payable
def safeTransferFrom(owner: address, to: address, token_id: uint256, data: Bytes[1_024] = b""):
assert token_id == convert(owner, uint256), "token_id must match owner" # ← ADD THIS
assert erc721._is_approved_or_owner(msg.sender, token_id), "erc721: caller is not token owner or approved"
assert self._ve_transfer_allowed(owner, to), "Need max veLock"
self._merge_positions(owner, to)
erc721._burn(token_id)
Developer Response:
Fixed in 3af52777ac4a199a8e1b97f6a557ea06ab642a0d.