Root Cause
@> refund() performs an external ETH transfer before invalidating the player's entry:
A malicious contract can re-enter refund() during sendValue() while the same player entry is still active.
Risk
Likelihood: High
A malicious contract can participate in the raffle and execute code during the ETH transfer.
The player entry remains valid until the external call returns.
Impact: High
The same ticket can be refunded multiple times.
Repeated refunds can drain ETH belonging to other participants.
Proof of Concept
The Foundry reentrancy test successfully reproduces reentrant calls to refund() before the player entry is cleared.
Recommended Mitigation
Remove:
Add:
Using ReentrancyGuard with nonReentrant is also recommended as defense in depth.
Root Cause
@> The winner index is derived from msg.sender, block.timestamp, and block.difficulty:
msg.sender is fully controlled by the caller, while block-derived values are not suitable as a secure randomness source.
Risk
Likelihood: High
Any address can call selectWinner().
The caller directly contributes msg.sender to the winner calculation.
Impact: High
The selected winner can be influenced by the caller.
An attacker can potentially bias the selection toward a favorable participant and the associated prize.
Proof of Concept
Foundry tests demonstrate that changing the caller changes the calculated winner index.
Recommended Mitigation
Remove the use of caller-controlled and predictable block values as the randomness source.
Use a secure randomness provider such as Chainlink VRF or another verifiable randomness mechanism.
Root Cause
@> The calculated fee is explicitly cast from uint256 to uint64:
Solidity 0.7.6 does not automatically check integer casts for truncation.
Risk
Likelihood: Medium
The issue occurs when the calculated fee exceeds the maximum value representable by uint64.
Such a value requires an extremely large raffle value, making exploitation less likely under normal conditions.
Impact: High
The stored totalFees can differ from the actual accumulated fees.
Incorrect accounting can result in incorrect or inaccessible fee withdrawals.
Proof of Concept
Arithmetic tests confirm that converting a value larger than uint64.max to uint64 truncates the value before it is stored.
Recommended Mitigation
Remove:
Add:
and change totalFees to:
Root Cause
@> withdrawFees() requires the contract balance to exactly equal totalFees:
ETH can be forced into the contract without increasing totalFees, causing the balance to exceed the recorded fee amount.
Risk
Likelihood: Medium
ETH can be forced into the contract through mechanisms such as selfdestruct.
Once unexpected ETH is present, the exact-balance requirement causes withdrawFees() to revert.
Impact: Medium
Fee withdrawals become unavailable.
The protocol's fee accounting becomes dependent on the contract receiving no unexpected ETH.
Proof of Concept
Echidna successfully generated a counterexample by forcing 1 wei into the raffle contract and then calling withdrawFees(), which reverted.
Recommended Mitigation
Remove:
Add a check that accounts for outstanding player funds separately, rather than requiring exact balance equality.
Root Cause
@> Duplicate validation uses nested loops over the entire players array:
The number of comparisons grows quadratically with the number of players.
Risk
Likelihood: High
Every enterRaffle() call performs the duplicate check over the complete player array.
Gas consumption becomes impractical as the raffle grows.
Impact: Medium
Large player batches can exceed the block gas limit.
The raffle can become practically unusable at sufficiently large player counts.
Proof of Concept
Gas testing showed approximately 1550 players passing while 1575 and 1600 players failed, with gas usage reaching approximately 1.057 billion gas.
Recommended Mitigation
Remove the nested-loop duplicate check.
Add a mapping-based uniqueness check:
Then validate each new player in O(1):
Root Cause
@> A refunded player's array entry is replaced with the zero address:
The entry remains in the array instead of being removed or otherwise excluded from active-player calculations.
Risk
Likelihood: High
Every successful refund creates an address(0) entry.
Subsequent raffle logic continues to use the same players array.
Impact: Medium
Invalid entries remain in the active player array.
These entries can affect winner selection and downstream raffle logic.
Proof of Concept
The refund tests confirm that a refunded player's position remains in players[] as address(0).
Recommended Mitigation
Remove the player from the active player set rather than replacing the entry with address(0).
For example, swap the refunded entry with the last player and remove the last array element.
Root Cause
@> selectWinner() selects directly from players[] without verifying that the selected address is non-zero:
Since refund() can leave address(0) entries in the array, the winner can resolve to the zero address.
Risk
Likelihood: Medium
A refunded entry remains in players[].
The randomness can select the index occupied by address(0).
Impact: Medium
The winner payout can fail.
_safeMint(address(0), tokenId) reverts, preventing completion of the raffle.
Proof of Concept
Tests demonstrate that a refunded player leaves an address(0) slot and that selecting that slot causes the winner process to fail.
Recommended Mitigation
Remove refunded players from the active array, or explicitly reject zero-address winners before processing the prize.
For example:
Root Cause
@> The Common condition includes both 0 and COMMON_RARITY:
With COMMON_RARITY = 70, values 0 through 70 produce 71 Common outcomes.
Risk
Likelihood: High
Every NFT rarity is generated using the same boundary logic.
The off-by-one behavior occurs deterministically.
Impact: Low
Legendary NFTs are less likely than intended.
The implemented distribution differs from the distribution represented by the constants.
Proof of Concept
Rarity tests confirm the effective distribution is approximately 71% Common, 25% Rare, and 4% Legendary.
Recommended Mitigation
Adjust the boundary conditions to match the intended probabilities, for example:
and adjust subsequent boundaries accordingly.
Root Cause
@> The rarity value is inserted without quotation marks:
This produces metadata such as:
instead of:
Risk
Likelihood: High
Every generated token uses the same metadata construction.
The malformed JSON is deterministic.
Impact: Low
NFT marketplaces and wallets may fail to parse the metadata.
Rarity attributes may not be displayed correctly.
Proof of Concept
The tokenURI() test confirms that the generated metadata does not conform to valid JSON syntax.
Recommended Mitigation
Remove:
Add:
## Description The `PuppyRaffle::refund()` function doesn't have any mechanism to prevent a reentrancy attack and doesn't follow the Check-effects-interactions pattern ## Vulnerability Details ```javascript function refund(uint256 playerIndex) public { 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"); payable(msg.sender).sendValue(entranceFee); players[playerIndex] = address(0); emit RaffleRefunded(playerAddress); } ``` In the provided PuppyRaffle contract is potentially vulnerable to reentrancy attacks. This is because it first sends Ether to msg.sender and then updates the state of the contract.a malicious contract could re-enter the refund function before the state is updated. ## Impact If exploited, this vulnerability could allow a malicious contract to drain Ether from the PuppyRaffle contract, leading to loss of funds for the contract and its users. ```javascript PuppyRaffle.players (src/PuppyRaffle.sol#23) can be used in cross function reentrancies: - PuppyRaffle.enterRaffle(address[]) (src/PuppyRaffle.sol#79-92) - PuppyRaffle.getActivePlayerIndex(address) (src/PuppyRaffle.sol#110-117) - PuppyRaffle.players (src/PuppyRaffle.sol#23) - PuppyRaffle.refund(uint256) (src/PuppyRaffle.sol#96-105) - PuppyRaffle.selectWinner() (src/PuppyRaffle.sol#125-154) ``` ## POC <details> ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.7.6; import "./PuppyRaffle.sol"; contract AttackContract { PuppyRaffle public puppyRaffle; uint256 public receivedEther; constructor(PuppyRaffle _puppyRaffle) { puppyRaffle = _puppyRaffle; } function attack() public payable { require(msg.value > 0); // Create a dynamic array and push the sender's address address[] memory players = new address[](1); players[0] = address(this); puppyRaffle.enterRaffle{value: msg.value}(players); } fallback() external payable { if (address(puppyRaffle).balance >= msg.value) { receivedEther += msg.value; // Find the index of the sender's address uint256 playerIndex = puppyRaffle.getActivePlayerIndex(address(this)); if (playerIndex > 0) { // Refund the sender if they are in the raffle puppyRaffle.refund(playerIndex); } } } } ``` we create a malicious contract (AttackContract) that enters the raffle and then uses its fallback function to repeatedly call refund before the PuppyRaffle contract has a chance to update its state. </details> ## Recommendations To mitigate the reentrancy vulnerability, you should follow the Checks-Effects-Interactions pattern. This pattern suggests that you should make any state changes before calling external contracts or sending Ether. Here's how you can modify the refund function: ```javascript function refund(uint256 playerIndex) public { 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"); // Update the state before sending Ether players[playerIndex] = address(0); emit RaffleRefunded(playerAddress); // Now it's safe to send Ether (bool success, ) = payable(msg.sender).call{value: entranceFee}(""); require(success, "PuppyRaffle: Failed to refund"); } ``` This way, even if the msg.sender is a malicious contract that tries to re-enter the refund function, it will fail the require check because the player's address has already been set to address(0).Also we changed the event is emitted before the external call, and the external call is the last step in the function. This mitigates the risk of a reentrancy attack.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.