Puppy Raffle

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

Predictable on-chain randomness in selectWinner() lets the caller influence the winner and the puppy rarity

Root + Impact

Description

PuppyRaffle.selectWinner() in src/PuppyRaffle.sol generates both the winning index and the puppy rarity from on-chain values that are visible to everyone before the transaction is mined, and that the caller and the block producer can influence. The raffle is therefore not random in any meaningful sense.

The vulnerable code is:

uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;

and, for rarity:

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

Every input to these hashes is either chosen by the caller or public before the call. msg.sender is the address of whoever calls selectWinner(), and anyone may call it, so the caller knows their own input exactly. block.timestamp is the timestamp of the block the transaction lands in, which the caller can estimate closely and which a block producer can adjust within a small range. block.difficulty is also public, and on proof-of-stake networks it reflects the previous randomness value, which is likewise visible in advance. Hashing public inputs does not make the result unpredictable: anyone can compute the same keccak256 hash off-chain, with the same inputs, and get the same winner index and rarity before deciding whether to call the function.

There is no commit-and-reveal step, no verifiable random function, and no delay between the inputs becoming known and the winner being chosen. The contract comments even say that a hash of on-chain data is used to generate the random numbers, which confirms that this is the intended mechanism, not a hidden fallback.

Risk

Likelihood: Medium - exploiting this needs some preparation, but the preparation is ordinary off-chain calculation. An attacker can simulate the outcome for a coming block, wait for a block in which the hash selects them, and only then call selectWinner(). A block producer has even more direct influence over the timestamp and can order transactions to suit themselves.
Impact: High - the winner of the raffle, the 80% prize pool, and the minted puppy NFT can all be steered to an attacker instead of being drawn fairly. The rarity is affected in the same way, so an attacker can also aim for a legendary puppy, which is meant to be the rarest outcome. Honest players pay the entrance fee for a fair chance they do not actually get, and the raffle's whole purpose is undermined.

Proof of Concept

Manual trace - no tests were run; this is a step-by-step reading of the code, with a worked example.

Worked example: suppose four players have entered, so players.length is 4, and the raffle duration has passed.

  1. An attacker who is one of the four players reads the current block timestamp and difficulty from the chain, and knows their own address, because they will be msg.sender when they call the function.

  2. Off-chain, they compute uint256(keccak256(abi.encodePacked(theirAddress, nextTimestamp, difficulty))) % 4 for the next few likely timestamps. The result tells them, in advance, which index would win if they called selectWinner() in that block.

  3. If the result is not their own index, they simply do nothing and wait for a later block. They lose nothing by waiting, because calling is free apart from gas and anyone can call at any time after the duration ends.

  4. When a block arrives in which the computed winner index is their own, they call selectWinner(). The contract computes the same hash with the same inputs, selects them, sends them 80% of the collected entrance fees, and mints them a puppy.

  5. The same calculation applies to rarity: the attacker can check uint256(keccak256(abi.encodePacked(theirAddress, difficulty))) % 100 in advance, and call only when the result falls in the legendary range, above 95.

A block producer can go further, adjusting the timestamp within the allowed range or choosing which transactions to include, to change the hash outcome in their favour.

Recommended Mitigation

Do not use block.timestamp, block.difficulty, or msg.sender as sources of randomness. Use a verifiable random function, such as Chainlink VRF, so that the random value is generated off-chain and verified on-chain, and cannot be predicted or influenced by the caller or by a block producer. As a minimum, use a two-step commit-and-reveal scheme, so the inputs are fixed before the random value is revealed, and no single party can choose the outcome after seeing the inputs.

Updates

Lead Judging Commences

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