Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Predictable randomness in selectWinner allows manipulation of NFT rarity

Predictable randomness in PuppyRaffle::selectWinner allows manipulation of NFT rarity

Description

The rarity of the minted NFT is determined using a hash of msg.sender and block.difficulty. Since both values are known or predictable, the caller of selectWinner can manipulate which rarity tier the NFT falls into -- guaranteeing a legendary puppy NFT instead of a common one.

function selectWinner() external {
// ...
@> 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:

  • The rarity RNG uses only two predictable inputs: msg.sender (chosen by the caller) and block.difficulty (publicly known)

  • An attacker can precompute the rarity result for multiple sender addresses and select the one that yields a legendary NFT

Impact:

  • Legendary NFTs (meant to be 5% probability) can be minted on demand, destroying the intended rarity distribution

  • The value of legitimately rare NFTs is undermined

Proof of Concept

  1. Four players enter the raffle normally and we advance time past the raffle duration.

  2. The attacker iterates over a range of candidate addresses (address(1000) through address(2000)), computing the rarity formula keccak256(abi.encodePacked(candidate, block.difficulty)) % 100 for each one.

  3. Since block.difficulty is publicly known, the attacker can do this computation entirely off-chain. They stop as soon as they find an address that produces a rarity value > 95 (the legendary range).

  4. The test asserts that such an address is found and that its computed rarity indeed falls in the legendary tier.

  5. In practice, the attacker would deploy a contract at a favorable address using CREATE2 (which allows deterministic address generation), or simply call selectWinner from a pre-computed EOA. Since only ~5% of addresses yield legendary, the attacker only needs to try ~20 addresses on average to find one.

function testPredictRarity() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
// Attacker computes rarity for different sender addresses until they find one that yields LEGENDARY
address attacker;
for (uint160 i = 1000; i < 2000; i++) {
address candidate = address(i);
uint256 rarity = uint256(keccak256(abi.encodePacked(candidate, block.difficulty))) % 100;
if (rarity > 95) { // LEGENDARY range
attacker = candidate;
break;
}
}
// The attacker can now call selectWinner from that address to get a legendary NFT
// (In practice, the attacker deploys a contract at a chosen address or uses CREATE2)
uint256 expectedRarity = uint256(keccak256(abi.encodePacked(attacker, block.difficulty))) % 100;
assert(expectedRarity > 95);
}

Recommended Mitigation

Use the same Chainlink VRF randomness source for rarity as recommended for winner selection. Request an additional random word for rarity:

- uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
+ // In the VRF callback, use a second random word for rarity
+ uint256 rarity = randomWords[1] % 100;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!