Summary
The fulfillRandomWords
internal function incorrectly uses the divisor number 3
to calculate NFT rarities. Additionally, the tokenURI
external function fails to utilize rarity chances to return a legitimate NFT. This behavior deviates from the expected functionality of the Snek Raffle.
vulnerability Details
Checkout the Line 154 and Line 166...
.
.
.
@internal
def fulfillRandomWords(request_id: uint256, random_words: uint256[MAX_ARRAY_SIZE]):
index_of_winner: uint256 = random_words[0] % len(self.players)
recent_winner: address = self.players[index_of_winner]
self.recent_winner = recent_winner
self.players = []
self.raffle_state = RaffleState.OPEN
self.last_timestamp = block.timestamp
rarity: uint256 = random_words[0] % 3
--------------------------------------------^
self.tokenIdToRarity[ERC721._total_supply()] = rarity
log WinnerPicked(recent_winner)
ERC721._mint(recent_winner, ERC721._total_supply())
send(recent_winner, self.balance)
@external
@view
def tokenURI(token_id: uint256) -> String[53]:
rarity: uint256 = self.tokenIdToRarity[token_id]
------------^
return self.rarityToTokenURI[rarity]
.
.
.
Impact
Winners will find themselves with incorrect and illegitimate NFTs assigned to their owned token IDs, which could be perceived as advantageous by some winners but disadvantageous by others. However, the Snek Raffle
is intended to assign NFTs with valid rarity chances to the selected winners.
PoC: Wrongly Calc NFT
def test_fulfill_random_words_picks_a_winner_resets_and_sends_money_but_assing_an_invalid_wrongly_calc_nft(
raffle_boa_entered, vrf_coordinator_boa, entrance_fee
):
additional_entrants = 10
for i in range(additional_entrants):
player = boa.env.generate_address(i)
boa.env.set_balance(player, STARTING_BALANCE)
with boa.env.prank(player):
raffle_boa_entered.enter_raffle(value=entrance_fee)
starting_balance = boa.env.get_balance(USER)
boa.env.time_travel(seconds=INTERVAL + 1)
raffle_boa_entered.request_raffle_winner()
vrf_coordinator_boa.fulfillRandomWords(0, raffle_boa_entered.address)
recent_winner = raffle_boa_entered.get_recent_winner()
winner_balance = boa.env.get_balance(recent_winner)
prize = entrance_fee * (additional_entrants + 1)
print("\nWrongly calculated NFT: ", raffle_boa_entered.tokenURI(0))
assert recent_winner == USER
assert winner_balance == starting_balance + prize
pytest -v tests/snek_raffle_test.py::test_fulfill_random_words_picks_a_winner_resets_and_sends_money_but_assing_an_invalid_wrongly_calc_nft -s
==================================================================== test session starts =====================================================================
platform linux -- Python 3.10.12, pytest-8.0.2, pluggy-1.4.0 -- /home/theirrationalone/vyperenv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/theirrationalone/first-flights/2024-03-snek-raffle/.hypothesis/examples'))
rootdir: /home/theirrationalone/first-flights/2024-03-snek-raffle
plugins: titanoboa-0.1.8, cov-4.1.0, hypothesis-6.98.17
collected 1 item
tests/snek_raffle_test.py::test_fulfill_random_words_picks_a_winner_resets_and_sends_money_but_assing_an_invalid_wrongly_calc_nft
Wrongly calculated NFT: ipfs:
PASSED
===================================================================== 1 passed in 1.55s ======================================================================
Tools Used
Manual Review, Pytest
Recommendations
.
.
.
@internal
def fulfillRandomWords(request_id: uint256, random_words: uint256[MAX_ARRAY_SIZE]):
index_of_winner: uint256 = random_words[0] % len(self.players)
recent_winner: address = self.players[index_of_winner]
self.recent_winner = recent_winner
self.players = []
self.raffle_state = RaffleState.OPEN
self.last_timestamp = block.timestamp
- rarity: uint256 = random_words[0] % 3
+ rarity: uint256 = random_words[0] % (COMMON_RARITY + RARE_RARITY + LEGEND_RARITY)
self.tokenIdToRarity[ERC721._total_supply()] = rarity
log WinnerPicked(recent_winner)
ERC721._mint(recent_winner, ERC721._total_supply())
send(recent_winner, self.balance)
#####################
# View Functions #
#####################
@external
@view
def tokenURI(token_id: uint256) -> String[53]:
rarity: uint256 = self.tokenIdToRarity[token_id]
+ selectedURI: String[53] = self.rarityToTokenURI[COMMON]
+ if rarity > COMMON:
+ selectedURI = self.rarityToTokenURI[RARE]
+ if rarity > COMMON_RARITY + RARE_RARITY:
+ selectedURI = self.rarityToTokenURI[LEGEND]
- return self.rarityToTokenURI[rarity]
+ return selectedURI
.
.
.
Now if we re-execute the PoC Test then we will get a true legit assigned NFT...
==================================================================== test session starts =====================================================================
platform linux -- Python 3.10.12, pytest-8.0.2, pluggy-1.4.0 -- /home/theirrationalone/vyperenv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/theirrationalone/first-flights/2024-03-snek-raffle/.hypothesis/examples'))
rootdir: /home/theirrationalone/first-flights/2024-03-snek-raffle
plugins: titanoboa-0.1.8, cov-4.1.0, hypothesis-6.98.17
collected 1 item
tests/snek_raffle_test.py::test_fulfill_random_words_picks_a_winner_resets_and_sends_money_but_assing_an_invalid_wrongly_calc_nft
Wrongly calculated NFT: ipfs:
PASSED
===================================================================== 1 passed in 1.57s ======================================================================