Summary
selectWinner using hash of on-chain data for generating random number to calculate rarity generates same number everytime thus allowing same type of token get drained
Vulnerability Details
selectWinner calculates rarity using on-chain data block.difficulty and msg.sender . if we keep msg.sender same , rarity will be same in all the cases.
Doing some modifications in selectWinner such that it returns rarity (aka. randomNumber).
function selectWinner() external returns (uint256) {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
uint256 randomNumber = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty)));
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee);
uint256 tokenId = totalSupply();
uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
if (rarity <= COMMON_RARITY) {
tokenIdToRarity[tokenId] = COMMON_RARITY;
} else if (rarity <= COMMON_RARITY + RARE_RARITY) {
tokenIdToRarity[tokenId] = RARE_RARITY;
} else {
tokenIdToRarity[tokenId] = LEGENDARY_RARITY;
}
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
return rarity;
}
here's a test case to check the randomness
modifier playersEntered() {
address[] memory players = new address[](5);
players[0] = playerTwo;
players[1] = playerOne;
players[2] = playerThree;
players[3] = playerFive;
players[4] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 5}(players);
_;
}
function test_SameRarityForAFixedSender() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
vm.prank(playerTwo);
(uint256 rarity) = puppyRaffle.selectWinner();
console.log(rarity);
}
Logs:
31
*/
modifier playersEntered() {
address[] memory players = new address[](7);
players[0] = playerTwo;
players[1] = playerOne;
players[2] = playerThree;
players[3] = playerFive;
players[4] = playerFour;
players[5] = playerSix;
players[6] = playerSeven;
puppyRaffle.enterRaffle{value: entranceFee * 7}(players);
_;
}
function test_SameRarityForAFixedSender() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
vm.prank(playerTwo);
(uint256 rarity) = puppyRaffle.selectWinner();
console.log(rarity);
}
Logs:
31
*/
clearly visible that rarity was same in both cases even tho different number of players entered in the raffle and the reason behind is random number generated being same.
Impact
Due to source of randomness not working properly all the tokens of similar rarity, in the upper case of COMMON_RARITY (as 31<=70) will get drained which can cause imbalance between different type of tokens plus it doesn't stand out on the behavior selectWinner was supposed to have
Tools Used
Foundry
Recommended Mitigation
Consider to not use on-chain data as the source of randomness to calculate rarity.
One way of achieving this is to use chainlink VRFs as source to generte random number and calculate rarity.