GithubHelp home page GithubHelp logo

2024-03-pooltogether-findings's Introduction

PoolTogether Audit

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Respond to issues.
  2. Weigh in on severity.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Respond to issues

For each High or Medium risk finding that appears in the dropdown at the top of the chrome extension, please label as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, select the disagree with severity option, along with a comment indicating your reasoning for the judge to review. You may also add questions for the judge in the comments. (Note: even if you disagree with severity, please still choose one of the sponsor confirmed or sponsor acknowledged options as well.)

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

QA reports, Gas reports, and Analyses

All warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

For QA reports, Gas reports, and Analyses, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-03-pooltogether-findings's People

Contributors

c4-bot-2 avatar c4-bot-5 avatar c4-bot-10 avatar c4-bot-3 avatar c4-bot-7 avatar c4-bot-4 avatar c4-bot-8 avatar c4-bot-1 avatar c4-bot-6 avatar c4-bot-9 avatar c4-judge avatar code423n4 avatar

Stargazers

 avatar saham avatar Mr Abdullah avatar lordofshadow avatar  avatar

Watchers

Ashok avatar  avatar

2024-03-pooltogether-findings's Issues

`maxDeposit` and `maxMint` is not utilized anywhere, which makes anyone can deposit and mint without any limit

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L524
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L475
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L482
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L843

Vulnerability details

Impact

Mint and deposit limit is not forced, which makes anyone being able to mint and deposit without limit.

Proof of Concept

In ERC4626, maxDeposit and maxMint exists to enforce participants to deposit and mint a limited amount in the protocol. We see in ERC4626.sol, such values are checked in deposit and mint:

    /** @dev See {IERC4626-deposit}. */
    function deposit(uint256 assets, address receiver) public virtual returns (uint256) {
        uint256 maxAssets = maxDeposit(receiver);
        if (assets > maxAssets) {
            revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets);
        }

        uint256 shares = previewDeposit(assets);
        _deposit(_msgSender(), receiver, assets, shares);

        return shares;
    }

    /** @dev See {IERC4626-mint}. */
    function mint(uint256 shares, address receiver) public virtual returns (uint256) {
        uint256 maxShares = maxMint(receiver);
        if (shares > maxShares) {
            revert ERC4626ExceededMaxMint(receiver, shares, maxShares);
        }

        uint256 assets = previewMint(shares);
        _deposit(_msgSender(), receiver, assets, shares);

        return assets;
    }

But in PrizeVault, maxDeposit and maxMint are well coded, except, not being used anywhere at all.

    function _depositAndMint(address _caller, address _receiver, uint256 _assets, uint256 _shares) internal {
        if (_shares == 0) revert MintZeroShares();
        if (_assets == 0) revert DepositZeroAssets();

        // If _asset is ERC777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the
        // `tokensToSend` hook. On the other hand, the `tokenReceived` hook that is triggered after the transfer
        // calls the vault which is assumed to not be malicious.
        //
        // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the
        // assets are transferred and before the shares are minted, which is a valid state.

        _asset.safeTransferFrom(
            _caller,
            address(this),
            _assets
        );

        // Previously accumulated dust is swept into the yield vault along with the deposit.
        uint256 _assetsWithDust = _asset.balanceOf(address(this));
        _asset.approve(address(yieldVault), _assetsWithDust);

        // The shares are calculated and then minted directly to mitigate rounding error loss.
        uint256 _yieldVaultShares = yieldVault.previewDeposit(_assetsWithDust);
        uint256 _assetsUsed = yieldVault.mint(_yieldVaultShares, address(this));
        if (_assetsUsed != _assetsWithDust) {
            // If some latent balance remains, the approval is set back to zero for weird tokens like USDT.
            _asset.approve(address(yieldVault), 0);
        }

        _mint(_receiver, _shares);

        if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());

        emit Deposit(_caller, _receiver, _assets, _shares);
    }

And for this function's callee, they don't have such limit checked either. This makes anyone who participate in the protocol can mint and deposit any amount they want.

Tools Used

Manual review.

Recommended Mitigation Steps

Add maxDeposit and maxMint in _depositAdnMint, and reverts on passing limit.

Assessed type

Context

Contracts are vulnerable to rebasing accounting-related issues

Lines of code


854-858, 118

Vulnerability details


The readme does not specifically exclude rebasing tokens

Rebasing tokens are tokens that have each holder's balanceof() increase over time. Aave aTokens are an example of such tokens. If rebasing tokens are used, rewards accrue to the contract holding the tokens, and cannot be withdrawn by the original depositor. To address the issue, track 'shares' deposited on a pro-rata basis, and let shares be redeemed for their proportion of the current balance at the time of the withdrawal.

File: pt-v5-vault/src/PrizeVault.sol

   |  // @audit-issue Ensure shares are tracked for rebasing tokens
854 |  _asset.safeTransferFrom(
855 |      _caller,
856 |      address(this),
857 |      _assets
858 |  );
File: pt-v5-vault/src/PrizeVaultFactory.sol

    |  // @audit-issue Ensure shares are tracked for rebasing tokens
118 |  IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

Assessed type


other

Liquidation can lead to temporary DoS of PrizeVault

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L659
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L692

Vulnerability details

Impact

When there is enough of yeldFee in the PrizeVault admin with a special role LiquidationPair can liquidate of either assets or prize vault shares. In some cases it can lead to temporary DoS of PrizeVault. Users will not be able to depost assets as totalDebt will be higher than totalAssets.

Proof of Concept

Whenever the liquidation is ready, admin calls transferTokensOut. Let's see it:

    function transferTokensOut(
        address,
        address _receiver,
        address _tokenOut,
        uint256 _amountOut
    ) public virtual onlyLiquidationPair returns (bytes memory) {
        if (_amountOut == 0) revert LiquidationAmountOutZero();

        uint256 _availableYield = availableYieldBalance();
        uint32 _yieldFeePercentage = yieldFeePercentage;

        // Determine the proportional yield fee based on the amount being liquidated:
        uint256 _yieldFee;
        if (_yieldFeePercentage != 0) {
            // The yield fee is calculated as a portion of the total yield being consumed, such that 
            // `total = amountOut + yieldFee` and `yieldFee / total = yieldFeePercentage`. 
            _yieldFee = (_amountOut * FEE_PRECISION) / (FEE_PRECISION - _yieldFeePercentage) - _amountOut;
        }

        // Ensure total liquidation amount does not exceed the available yield balance:
        if (_amountOut + _yieldFee > _availableYield) {
            revert LiquidationExceedsAvailable(_amountOut + _yieldFee, _availableYield);
        }

        // Increase yield fee balance:
        if (_yieldFee > 0) {
            yieldFeeBalance += _yieldFee;
        }

        // Mint or withdraw amountOut to `_receiver`:
        if (_tokenOut == address(_asset)) {
            _withdraw(_receiver, _amountOut);            
        } else if (_tokenOut == address(this)) {
@>            _mint(_receiver, _amountOut);
        } else {
            revert LiquidationTokenOutNotSupported(_tokenOut);
        }

        emit TransferYieldOut(msg.sender, _tokenOut, _receiver, _amountOut, _yieldFee);

        return "";
    }

In shot we calculate the available yeld, compares it to the amountOut and fee, increase yieldFeeBalance and has an option to withdraw assets on mint new tokens.

The second option (_mint()) will increase the totalDebt as it can be checked via:

    function _totalDebt(uint256 _totalSupply) internal view returns (uint256) {
        return _totalSupply + yieldFeeBalance;
    }

In the same time totalAssets remains untouched in the Vault.

Here is a point where a problem can rise.

When user want to depost in the Vault there is a check at the end of the deposit function:

if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());

totalAssets() is a sum of yieldVault.convertToAssets(yieldVault.balanceOf(address(this))) + _asset.balanceOf(address(this));.

totalDebt() is equal to _totalSupply + yieldFeeBalance;

Also we can see that _asset.balanceOf(address(this)) is equal to 1e5. That amount of assets is transfered from a user when he deploy the PrizeVault (as YIELD_BUFFER).

It is know from the comments that the vault will never mint more than 1 share per asset. So we can assume that deposited assets will be equal to token totalSupply.

In this case when yieldFeeBalance is about 1e5 (_asset.balanceOf(address(this))) which is not a great amount, there will be a possibility of totalDebt to be higher than totalAssets as more token will be minted but totalAssets will remail the same.

After all users deposits will be reverted due to a check I showed before.

Tools Used

Manual review

Recommended Mitigation Steps

Consider providing an additional check like if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt()); at the end of transferTokensOut() to be sure that such situatuin can not happen.

Assessed type

DoS

ERC-20: 'transfer()'/'transferFrom()' return values should be checked

Lines of code


939, 118

Vulnerability details


Not all IERC20 implementations revert() when there's a failure in transfer()/transferFrom(). The function signature has a boolean return value and they indicate errors that way instead. By not checking the return value, operations that should have marked as failed, may potentially go through without actually making a payment

File: pt-v5-vault/src/PrizeVault.sol

   |  // @audit-issue Check return value
939 |  _asset.transfer(_receiver, _assets);
File: pt-v5-vault/src/PrizeVaultFactory.sol

    |  // @audit-issue Check return value
118 |  IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

Assessed type


other

PrizeVault lacks slippage protection for some ERC4626 functions

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L475-L479
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L482-L486
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L489-L497
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L500-L508

Vulnerability details

Impact

The EIP-4626 mentions that "If implementors intend to support EOA account access directly, they should consider adding an additional function call for deposit/mint/withdraw/redeem with the means to accommodate slippage loss or unexpected deposit/withdrawal limits, since they have no other means to revert the transaction if the exact output amount is not achieved."

However, the PrizeVault.sol contract does not follow this, causing issues for EOAs.

Proof of Concept

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L475-L479

    function deposit(uint256 _assets, address _receiver) external returns (uint256) {
        uint256 _shares = previewDeposit(_assets);
        _depositAndMint(msg.sender, _receiver, _assets, _shares);
        return _shares;
    }

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L482-L486

    function mint(uint256 _shares, address _receiver) external returns (uint256) {
        uint256 _assets = previewMint(_shares);
        _depositAndMint(msg.sender, _receiver, _assets, _shares);
        return _assets;
    }

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L489-L497

   function withdraw(
        uint256 _assets,
        address _receiver,
        address _owner
    ) external returns (uint256) {
        uint256 _shares = previewWithdraw(_assets);
        _burnAndWithdraw(msg.sender, _receiver, _owner, _shares, _assets);
        return _shares;
    }

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L500-L508

    function redeem(
        uint256 _shares,
        address _receiver,
        address _owner
    ) external returns (uint256) {
        uint256 _assets = previewRedeem(_shares);
        _burnAndWithdraw(msg.sender, _receiver, _owner, _shares, _assets);
        return _assets;
    }

Tools Used

Manual Review

Recommended Mitigation Steps

Introduce additional parameters in deposit, mint, withdraw, and redeem functions that allow users to specify slippage tolerance

Assessed type

ERC4626

Initial depositor can manipulate the price per share value and future depositors are forced to deposit huge value in vault.

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L475

Vulnerability details

Impact

Most of the share based vault implementation will face this issue. The vault is based on the ERC4626 where the shares are calculated based on the deposit value. By depositing large amount as initial deposit, initial depositor can influence the future depositors value.

Proof of Concept

  function deposit(uint256 _assets, address _receiver) external returns (uint256) {
        uint256 _shares = previewDeposit(_assets);
        _depositAndMint(msg.sender, _receiver, _assets, _shares);
        return _shares;
    }

Although the vault checks for dust tokens, but it doesn't check for large deposit
Shares are minted based on the deposit value. vault is based on the ERC4626 where the shares are calculated based on the deposit value.

By depositing large amount as initial deposit, first depositor can take advantage over other depositors.

I am sharing reference for this type of issue that already reported and acknowledged. This explain how the share price could be manipulated to large value.
https://github.com/sherlock-audit/2022-08-sentiment-judging#issue-h-1-a-malicious-early-userattacker-can-manipulate-the-ltokens-pricepershare-to-take-an-unfair-share-of-future-users-deposits:~:text=Issue%20H%2D1%3A%20A%20malicious%20early%20user/attacker%20can%20manipulate%20the%20LToken%27s%20pricePerShare%20to%20take%20an%20unfair%20share%20of%20future%20users%27%20deposits

Recommended Mitigation Steps

Consider requiring a minimal amount of share tokens to be minted for the first minter.

-  function deposit(uint256 _assets, address _receiver) external returns (uint256) 
+  function deposit(uint256 _assets, address _receiver, uint256 minSharesOut) external returns (uint256) {
        uint256 _shares = previewDeposit(_assets);
+       require(_shares>minSharesOut,"");
        _depositAndMint(msg.sender, _receiver, _assets, _shares);
        return _shares;
    }

Assessed type

ERC4626

'PrizeVault::_setYieldFeeRecipient' zero address check missing could lead to DoS

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L958-L961

Vulnerability details

Impact

DoS due to ''PrizeVault::onlyYieldFeeRecipient'' modifier revert because of msg.sender != address(0)

Proof of Concept

Let's say that user deploys new PrizeVault contract and forget to set yieldFeeRecipient_ and yieldFeeRecipient is going to be defaulted to address(0). Then on ''PrizeVault::claimYieldFeeShares'' called, the function is going to revert due to ''PrizeVault::onlyYieldFeeRecipient'' modifier fail.

Tools Used

Recommended Mitigation Steps

Add zero address check at the beginning of the function as shown below:

function _setYieldFeeRecipient(address _yieldFeeRecipient) internal {
    ++ if (_yieldFeeRecipient == address(0) revert;
    yieldFeeRecipient = _yieldFeeRecipient;
    emit YieldFeeRecipientSet(_yieldFeeRecipient);
}

Assessed type

DoS

Constructor Incorrectly Assumes 18 Decimals for Underlying Token When Staticcall Fails

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L289
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L772

Vulnerability details

Impact

The constructor incorrectly assumes 18 decimals for the underlying token when the staticcall operation returns false or fails. The _underlyingDecimals variable, which is immutable, cannot be changed after deployment.

Proof of Concept

During the deployment of PrizeVault.sol, the constructor invokes the _tryGetAssetDecimals() function to retrieve the decimal value of the underlying assets. It then stores this value as an immutable state variable _underlyingDecimals.

        (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_);
        _underlyingDecimals = success ? assetDecimals : 18;

However, the _tryGetAssetDecimals() function performs a low-level static call to asset_, which can potentially return success = false due to various reasons such as insufficient gas or the target contract not being deployed.

    function _tryGetAssetDecimals(IERC20 asset_) internal view returns (bool, uint8) {
        (bool success, bytes memory encodedDecimals) = address(asset_).staticcall(
            abi.encodeWithSelector(IERC20Metadata.decimals.selector)
        );
        if (success && encodedDecimals.length >= 32) {
            uint256 returnedDecimals = abi.decode(encodedDecimals, (uint256));
            if (returnedDecimals <= type(uint8).max) {
                return (true, uint8(returnedDecimals));
            }
        }
        return (false, 0);
    }

In the constructor, when _tryGetAssetDecimals() returns false, it automatically assumes 18 decimals. While many tokens do have 18 decimals, it is not safe to make this assumption when the staticcall operation returns false.

Tools Used

Manual Review

Recommended Mitigation Steps

Eliminate _tryGetAssetDecimals() and add following

    constructor(
        string memory name_,
        string memory symbol_,
        IERC4626 yieldVault_,
        PrizePool prizePool_,
        address claimer_,
        address yieldFeeRecipient_,
        uint32 yieldFeePercentage_,
        uint256 yieldBuffer_,
        address owner_
    )
        TwabERC20(name_, symbol_, prizePool_.twabController())
        Claimable(prizePool_, claimer_)
        Ownable(owner_)
    {
        if (address(yieldVault_) == address(0)) revert YieldVaultZeroAddress();
        if (owner_ == address(0)) revert OwnerZeroAddress();

        IERC20 asset_ = IERC20(yieldVault_.asset());
        (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_);
-       _underlyingDecimals = success ? assetDecimals : 18;
+       _underlyingDecimals = asset_.decimals();
        _asset = asset_;

        yieldVault = yieldVault_;
        yieldBuffer = yieldBuffer_;

        _setYieldFeeRecipient(yieldFeeRecipient_);
        _setYieldFeePercentage(yieldFeePercentage_);
    }

Assessed type

ERC20

DOS in permit leads to users loss of funds

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L524

Vulnerability details

Impact

As reported earlier there is a front-run possibility leading to DOS in permit which will also lead to loss of funds

Proof of Concept

In this the permit might revert for most of the tokens but will not revert for WETH since WETH has a receive function. So even if the function reverts, user can steal money using receive and deposit tokens without paying

   function depositWithPermit(
        uint256 _assets,
        address _owner,
        uint256 _deadline,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) external returns (uint256) {
        if (_owner != msg.sender) {
            revert PermitCallerNotOwner(msg.sender, _owner);
        }

Recommended Mitigation Steps

I think the same way by adding a try-catch would solve this issue

Assessed type

DoS

Non-Compliance with ERC-4626 Standard: Deviation in Name and Symbol Functions

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L289-L314
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L92-L132

Vulnerability details

Impact

In the Attack ideas (Where to look for bugs) section PoolTogether team mentioned to look for PrizeVault contract ERC4626 standard compliance issues. So, the issue is according to ERC4626 specification:

All EIP-4626 tokenized Vaults MUST implement EIP-20’s optional metadata extensions. Thename and symbol functions SHOULD reflect the underlying token’s name and symbol in some way.

but in the PrizeVault and PrizeVaultFactory contract it's using user input to set name and symbol variable and this can result user can give any input for this variable and it may not reflect the underlying token’s name and symbol in any way.

Proof of Concept

In the Factory contract deployVault() function is used to deploy PrizeVault contract and taking user input to set the name and symbol variable and not using underlying asset name and symbol to set the value as we can see in the function:

function deployVault(
      string memory _name,
      string memory _symbol,
      IERC4626 _yieldVault,
      PrizePool _prizePool,
      address _claimer,
      address _yieldFeeRecipient,
      uint32 _yieldFeePercentage,
      address _owner
    ) external returns (PrizeVault) {
        PrizeVault _vault = new PrizeVault{
            salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
        }(
            _name,
            _symbol,
            _yieldVault,
            _prizePool,
            _claimer,
            _yieldFeeRecipient,
            _yieldFeePercentage,
            YIELD_BUFFER,
            _owner
        );

        // A donation to fill the yield buffer is made to ensure that early depositors have
        // rounding errors covered in the time before yield is actually generated.
        IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

        allVaults.push(_vault);
        deployedVaults[address(_vault)] = true;

        emit NewPrizeVault(
            _vault,
            _yieldVault,
            _prizePool,
            _name,
            _symbol
        );

        return _vault;
    }

In the same way PrizeVault contract constructor taking user input to set the name and symbol variable and not using underlying asset name and symbol to set the value.

Tools Used

Manual Analysis

Recommended Mitigation Steps

An example modification of deployVault() function to use asset name and symbol to deploy PrizeVault:

function deployVault(
      IERC4626 _yieldVault,
      PrizePool _prizePool,
      address _claimer,
      address _yieldFeeRecipient,
      uint32 _yieldFeePercentage,
      address _owner
    ) external returns (PrizeVault) {
	    string memory _name = string(abi.encodePacked(IERC20(_vault.asset()).name(), "PrizeVault"));
	    string memory _symbol = string(abi.encodePacked(IERC20(_vault.asset()).symbol(), "PrizeVault"));
	    
        PrizeVault _vault = new PrizeVault{
            salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
        }(
            _name,
            _symbol,
            _yieldVault,
            _prizePool,
            _claimer,
            _yieldFeeRecipient,
            _yieldFeePercentage,
            YIELD_BUFFER,
            _owner
        );

        // A donation to fill the yield buffer is made to ensure that early depositors have
        // rounding errors covered in the time before yield is actually generated.
        IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

        allVaults.push(_vault);
        deployedVaults[address(_vault)] = true;

        emit NewPrizeVault(
            _vault,
            _yieldVault,
            _prizePool,
            _name,
            _symbol
        );

        return _vault;
    }

Assessed type

ERC4626

Potential Yield Theft by PrizeVault Owner

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L659-L700
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L742-L748

Vulnerability details

Impact

According to the protocol Documentation:

The prize vault takes deposits of an asset and earns yield with the deposits through an underlying yield vault. The yield is then expected to be liquidated and contributed to the prize pool as prize tokens.

But in the PrizeVault.sol contract owner can set liquidationPair to any arbitrary address and that address can call transferTokensOut() with address _receiver parameter and the yield will be transferred to _receiver and it should not be allowed according to the documentation and it should be only transferable to PrizePool.

Proof of Concept

save below code in test/unit/PrizeVault/ folder as VaultMock.t.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

  

import { ERC4626Mock } from "openzeppelin/mocks/ERC4626Mock.sol";

import { Math } from "openzeppelin/utils/math/Math.sol";

  

import "openzeppelin/token/ERC20/ERC20.sol";

import "openzeppelin/token/ERC20/utils/SafeERC20.sol";

  

contract VaultMock is ERC4626Mock {

using Math for uint256;

using SafeERC20 for IERC20;

  

constructor(address _asset, string memory _name, string memory _symbol) ERC4626Mock(_asset) {

pricePerFullShare = 1e18;

IBT_UNIT = 10 ** ERC20(_asset).decimals();

}

  

uint256 private pricePerFullShare;

uint256 private IBT_UNIT;

mapping(address => bool) requestedWithdrawal;

  

function requestWithdraw(address owner) public {

requestedWithdrawal[owner] = true;

}

  

/**

* @notice Function to update the price of IBT to its underlying token.

* @param _price The new price of the ibt.

*/

function setPricePerFullShare(uint256 _price) public {

pricePerFullShare = _price;

}

  

/**

* @notice Function to convert the no of shares to it's amount in assets.

* @param shares The no of shares to convert.

* @return The amount of assets from the specified shares.

*/

function convertToAssets(uint256 shares) public view override returns (uint256) {

return (shares * pricePerFullShare) / IBT_UNIT;

}

  

/**

* @notice Function to convert the no of assets to it's amount in shares.

* @param assets The no of assets to convert.

* @return The amount of shares from the specified assets.

*/

function convertToShares(uint256 assets) public view override returns (uint256) {

if (pricePerFullShare == 0) {

return 0;

}

return (assets * IBT_UNIT) / pricePerFullShare;

}

  

/**

* @notice Function to deposit the provided amount in assets.

* @param amount The amount of assets to deposit.

* @param receiver The address of the receiver.

* @return shares The amount of shares received.

*/

function deposit(uint256 amount, address receiver) public override returns (uint256 shares) {

IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount);

shares = convertToShares(amount);

_mint(receiver, shares);

}

  

/**

* @notice Function to withdraw the provided no of shares.

* @param assets The amount of assets to withdraw.

* @param receiver The address of the receiver.

* @return shares The amount of shares to burn to withdraw assets.

*/

function withdraw(

uint256 assets,

address receiver,

address owner

) public override returns (uint256 shares) {

shares = convertToShares(assets);

if (msg.sender != owner) {

_spendAllowance(owner, msg.sender, shares);

}

_burn(owner, shares);

IERC20(asset()).safeTransfer(receiver, assets);

emit Withdraw(msg.sender, receiver, owner, assets, shares);

}

  

/** @dev See {IERC4626-previewDeposit}. */

function previewDeposit(uint256 _amount) public view override returns (uint256) {

return convertToShares(_amount);

}

  

/** @dev See {IERC4626-previewWithdraw}. */

function previewWithdraw(uint256 assets) public view override returns (uint256) {

return convertToShares(assets);

}

  

/** @dev See {IERC4626-previewRedeem}. */

function previewRedeem(uint256 shares) public view virtual override returns (uint256) {

return convertToAssets(shares) + 1;

}

  

/** @dev See {IERC4626-redeem}. */

function redeem(

uint256 shares,

address receiver,

address owner

) public virtual override returns (uint256) {

if (shares > maxRedeem(owner)) {

revert();

}

uint256 assets = previewRedeem(shares);

  

if (msg.sender != owner) {

_spendAllowance(owner, msg.sender, shares);

}

_burn(owner, shares);

IERC20(asset()).safeTransfer(receiver, assets);

emit Withdraw(msg.sender, receiver, owner, assets, shares);

return assets;

}

}

and modify the UnitBaseSetup.sol file with below code

 // import below file
 import { VaultMock } from "./VaultMock.t.sol";
 // modify setUpYieldVault() function
 function setUpYieldVault() public virtual returns (IERC4626) {

return new VaultMock(address(underlyingAsset), "Test Yield Vault", "yvTest");

}

Paste the below code in PrizeVault.t.sol file

function testSteaalYieldByOwner() public {
uint aliceAmount = type(uint96).max;
underlyingAsset.mint(alice, aliceAmount);
console.log("Bob prev asset Balance:", underlyingAsset.balanceOf(bob));

vm.startPrank(alice);
underlyingAsset.approve(address(vault), aliceAmount);
vault.deposit(aliceAmount, alice);
vm.stopPrank();

_increaseRate(10);
vault.setLiquidationPair(address(this));
uint256 maxLiquidation = vault.liquidatableBalanceOf(address(underlyingAsset));

vault.transferTokensOut(address(0), bob, address(underlyingAsset), maxLiquidation);

console.log("Bob after asset Balance:", underlyingAsset.balanceOf(bob));

}

  

function _increaseRate(int256 rate) internal {

VaultMock ibt = VaultMock(address(yieldVault));

int256 currentRate = int256(ibt.convertToAssets(10 ** ibt.decimals()));

int256 newRate = (currentRate * (rate + 100)) / 100;

ibt.setPricePerFullShare(uint256(newRate));

}

Now run forge test --mt testSteaalYieldByOwner -vvv and check bob asset balance will be increased but it should not be allowed according to the doc.

Tools Used

Manual Analysis

Recommended Mitigation Steps

Enforce a check and make sure that yield can be transferred to the PrizePool contract.

Assessed type

Access Control

QA Report

See the markdown file with the details of this report here.

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

The `claimYieldFeeShares` function wrongly updates the `yieldFeeBalance`

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L617

Vulnerability details

Impact

The fee recipient probably loses the yield fee after the first claim if the _shares are less than the _yieldFeeBalance.

In the claimYieldFeeShares function, the yieldFeeBalance will be set to be zero:

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
        ...

        uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

        yieldFeeBalance -= _yieldFeeBalance; 

        ...
    }

Thus, when the parameter _shares is less than the yieldFeeBalance, the yieldFeeBalance is updated to be zero un-expectedly, which results in the yield fee loss for the fee recipient.

Proof of Concept

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L611-L622

When the recipient invokes the claimYieldFeeShares function, assume the yieldFeeBalance is 100, and the parameter _shares is 1.

  • the _yieldFeeBalance becomes 100;
  • the yieldFeeBalance updated to be zero, because 100 -= 100 results in the yieldFeeBalance becomes zero;
  • mint 1 token to the recipient;
  • emit an event that the recipient claimed 1 share.

The real share minted to the recipient is 1 share, but the yieldFeeBalance is set to 0 unexpectedly. If the recipient wants to claim the rest 99 shares, the function will revert because the condition _shares > _yieldFeeBalance is true.

Tools Used

Manual Review

Recommended Mitigation Steps

Recommend updating the calculation of yieldFeeBalance as below:

yieldFeeBalance -= _shares;

Assessed type

Context

Explicit permission checks and parameter validations for deployVault function in the PrizeVaultFactory contract

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L92

Vulnerability details

Impact

The absence of explicit permission checks and parameter validations in the deployVault function of the PrizeVaultFactory contract exposes the PoolTogether protocol to several security risks:

  1. Unauthorized Deployment: Without access control, any user can deploy a new PrizeVault, potentially flooding the system with unauthorized or malicious vaults.
  2. Invalid Parameters: The lack of checks on critical parameters (e.g., zero addresses for _yieldVault, _prizePool, _claimer, _yieldFeeRecipient, and _owner) could lead to the deployment of non-functional vaults, impacting the integrity of the protocol and potentially leading to loss of funds or assets.
  3. System Integrity: The unrestricted ability to deploy vaults could be exploited to create vaults with unintended behaviors, affecting the overall trust and security of the PoolTogether ecosystem.

Proof of Concept

Lines 92 -132 of the PrizeVaultFactory contract:
function deployVault(
string memory _name,
string memory _symbol,
IERC4626 _yieldVault,
PrizePool _prizePool,
address _claimer,
address _yieldFeeRecipient,
uint32 _yieldFeePercentage,
address _owner
) external returns (PrizeVault) {
// The absence of explicit permission checks and parameter validations
PrizeVault _vault = new PrizeVault{
salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
}(
_name,
_symbol,
_yieldVault,
_prizePool,
_claimer,
_yieldFeeRecipient,
_yieldFeePercentage,
YIELD_BUFFER,
_owner
);

    // A donation to fill the yield buffer is made to ensure that early depositors have
    // rounding errors covered in the time before yield is actually generated.
    IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

    allVaults.push(_vault);
    deployedVaults[address(_vault)] = true;

    emit NewPrizeVault(
        _vault,
        _yieldVault,
        _prizePool,
        _name,
        _symbol
    );

    return _vault;
}

By adding the onlyOwner modifier to the deployVault function, you effectively limit this action to the contract owner, mitigating the risk of unauthorized deployment. Additionally, the require statements ensure that none of the critical parameters are zero addresses, preventing the creation of non-functional vaults. This approach enhances the contract's security by enforcing stricter access control and parameter validation.

Recommended Mitigation Steps

  1. Ensure the contract uses OpenZeppelin's Ownable contract for owner management and Address utility for address validation:

// Import Ownable from OpenZeppelin for owner management
import "@openzeppelin/contracts/access/Ownable.sol";
// Import Address utility for address validation
import "@openzeppelin/contracts/utils/Address.sol";

  1. Integrate these features into the PrizeVaultFactory contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import {IERC20, IERC4626} from "openzeppelin/token/ERC20/extensions/ERC4626.sol";
import {PrizePool} from "pt-v5-prize-pool/PrizePool.sol";
import {PrizeVault} from "./PrizeVault.sol";

/// @title PoolTogether V5 Prize Vault Factory
/// @notice Factory contract for deploying new prize vaults using a standard underlying ERC4626 yield vault.
contract PrizeVaultFactory is Ownable {
using Address for address;

// Constants, Events, and Variables remain unchanged

/// @notice Deploy a new vault with enhanced security checks
function deployVault(
    string memory _name,
    string memory _symbol,
    IERC4626 _yieldVault,
    PrizePool _prizePool,
    address _claimer,
    address _yieldFeeRecipient,
    uint32 _yieldFeePercentage,
    address _owner
) external onlyOwner returns (PrizeVault) { // Restricting access to onlyOwner
    require(address(_yieldVault).isContract(), "Invalid yieldVault address");
    require(address(_prizePool).isContract(), "Invalid prizePool address");
    require(_claimer == address(0) || _claimer.isContract(), "Invalid claimer address");
    require(_yieldFeeRecipient != address(0), "Invalid yieldFeeRecipient address");
    require(_owner != address(0), "Owner cannot be the zero address");

    PrizeVault _vault = new PrizeVault{
        salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
    }(
        _name,
        _symbol,
        _yieldVault,
        _prizePool,
        _claimer,
        _yieldFeeRecipient,
        _yieldFeePercentage,
        YIELD_BUFFER,
        _owner
    );

    IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

    allVaults.push(_vault);
    deployedVaults[address(_vault)] = true;

    emit NewPrizeVault(
        _vault,
        _yieldVault,
        _prizePool,
        _name,
        _symbol
    );

    return _vault;
}

// Other parts of the contract remain unchanged

}
Key Modifications:

  1. Access Control: Added the onlyOwner modifier to the deployVault function, ensuring that only the contract owner can deploy new vaults.
  2. Parameter Validation: Added require statements to check for non-zero and valid contract addresses.
  3. The validation for _claimer allows for a zero address, considering scenarios where a claimer might not be available at deployment time.

These changes significantly improve the security posture of the contract by preventing unauthorized deployment of vaults and ensuring that all parameters are valid before proceeding with the deployment, thereby avoiding potential issues related to misconfiguration or malicious inputs.

Assessed type

Access Control

`claimYieldFeeShares()` should revert in lossy state

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L611

Vulnerability details

Impact

The fee recipient can invoke claimYieldFeeShares() even when the protocol is in a lossy state, leading to a breach of protocol invariants.

Proof of Concept

The fee recipient can trigger claimYieldFeeShares() to liquidate or withdraw yield by minting shares.

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
        if (_shares == 0) revert MintZeroShares();

        uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

        yieldFeeBalance -= _yieldFeeBalance;

        _mint(msg.sender, _shares);

        emit ClaimYieldFeeShares(msg.sender, _shares);
    }

However, this function fails to verify if PrizeVault is in a lossy state, allowing liquidation and share minting despite the protocol's invariants:

no new deposits or mints allowed
no liquidations can occur

Consider following scenario:

  1. Users deposit funds into PrizeVault.sol.
  2. Over time, yieldFeeBalance accumulates from transferTokensOut().
  3. PrizeVault enters a lossy state due to fluctuations in ERC4626.
  4. Despite being in a lossy state, the fee recipient can still call claimYieldFeeShares(), disregarding the protocol's invariants.

Tools Used

Manual Review

Recommended Mitigation Steps

I recommend checking state of PrizeVault before AND after claimYieldFeeShares().

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
+       if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());
        if (_shares == 0) revert MintZeroShares();

        uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

        yieldFeeBalance -= _yieldFeeBalance;

        _mint(msg.sender, _shares);
+       if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());

        emit ClaimYieldFeeShares(msg.sender, _shares);
    }

Assessed type

Context

User Permit can be frontrun

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L524

Vulnerability details

Impact

DOS and poor US due to permit vulnerability

Proof of Concept

User submits a tx to depositWithPermit()
Attacker frontruns, takes the signature and call IERC20Permit themselves.
Since the signature is valid, token will accept it and increase the nonce
So, when user tx is mined , it fails due to incorrect nonce.

 function depositWithPermit(
        uint256 _assets,
        address _owner,
        uint256 _deadline,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) external returns (uint256) {
        if (_owner != msg.sender) {
            revert PermitCallerNotOwner(msg.sender, _owner);
        }

        // Skip the permit call if the allowance has already been set to exactly what is needed. This prevents
        // griefing attacks where the signature is used by another actor to complete the permit before this
        // function is executed.
        if (_asset.allowance(_owner, address(this)) != _assets) {
 @>           IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s);
        }

You can read more about it in here https://www.trust-security.xyz/post/permission-denied

Recommended Mitigation Steps

Use try-catch from solidity as told by OZ

try IERC20permit(address(asset)).permit(_owner,..........................,s){} catch {};

Assessed type

DoS

Loss of Yield Fee

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L617

Vulnerability details

Impact

The protocol can lose out on Yield Fee if the entirety of the Yield Fee is not claimed.

Proof of Concept

The Yield Fee is accrued gradually through calls to PrizeVault::transferTokensOut(). Overtime, yieldFeeBalance grows large enough to withdraw assets from the vault (via minting shares to the vault). However, If the yieldFeeRecipient does not claim the entirety of the yieldFeeBalance, the assets are no longer redeemable for the Yield Fee. This is the case because PrizeVault::claimYieldFeeShares() subtracts _yieldFeeBalance from yieldFeeBalance instead of subtracting the shares that are being claimed. Line 617 of PrizeVault.sol is where the error in calculation stems from:

yieldFeeBalance -= _yieldFeeBalance;

If we add the following test case it demonstrates the loss of Yield Fee:

function testLostYieldFee() public {
    uint256 amount = 1 ether;
    underlyingAsset.mint(alice, amount * 10);

    uint32 maxFee = 9e8;

    vault.setYieldFeePercentage(uint32(9e8));


    vm.startPrank(alice);
    underlyingAsset.approve(address(vault), amount);
    vault.deposit(amount, alice);
    vm.stopPrank();

    uint256 totalAssetsBefore = vault.totalAssets();
    uint256 totalSupplyBefore = vault.totalSupply();

    vm.startPrank(vault.liquidationPair());
    _accrueYield(underlyingAsset, vault, 10 ether);
    uint256 availableYield = vault.availableYieldBalance();
    vault.transferTokensOut(address(0), bob, address(underlyingAsset), 0.1 ether);
    vm.stopPrank();

    uint256 startBalance = vault.balanceOf(address(this));

    uint256 possibleYieldFee = vault.yieldFeeBalance();

    vault.claimYieldFeeShares(1);

    uint256 newYieldFee = vault.yieldFeeBalance();

    uint256 endBalance = vault.balanceOf(address(this));

    
    console.log("possibleYieldFee : ", possibleYieldFee);
    console.log("newYieldFee      : ", newYieldFee);
    console.log("startBalance     : ", startBalance);
    console.log("endBalance       : ", endBalance);
    
}

OUTPUT:

possibleYieldFee :  900000000000000000
newYieldFee      :  0
startBalance     :  0
endBalance       :  1

The output demonstrates that if the entirety of the YieldFee is not claimed at once, it is reset to 0 (newYieldFee).

Tools Used

Manual Analysis & Foundry

Recommended Mitigation Steps

Update PrizeVault::claimYieldFeeShares()'s calculation of yieldFeeBalance. Instead of subtracting _yieldFeeBalance, subtract _shares instead.

function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
    if (_shares == 0) revert MintZeroShares();

    uint256 _yieldFeeBalance = yieldFeeBalance;
    if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

    yieldFeeBalance -= _shares; // updates from "_yieldFeeBalance" to "_shares"

    _mint(msg.sender, _shares);

    emit ClaimYieldFeeShares(msg.sender, _shares);
}

Assessed type

Error

depositWithPermit() is not compatible with DAI.permit

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L540

Vulnerability details

Impact

The prize vault allowed its user to deposit using a signature through depositWithPermit(), which allowed the user to only make a single transaction when depositing their funds to the vault. However, if the vault accept a DAI asset, and the user wanted to deposit their funds through depositWithPermit() their transaction will fail/revert. This can happen because the function signature that the DAI.permit has and IERC20Permit.permit has is different.

Proof of Concept

IERC20Permit = https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/IERC20Permit.sol#L66-L74

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external;

ETHEREUM.DAI = https://etherscan.io/token/0x6b175474e89094c44da98b954eedeac495271d0f#code

     function permit(
        address holder, 
        address spender, 
        uint256 nonce, 
        uint256 expiry,
        bool allowed, 
        uint8 v, 
        bytes32 r, 
        bytes32 s
     ) external;

Tools Used

Manual

Assessed type

Context

Insufficient Gas Limit for External Calls Leads to Potential Denial of Service (DoS) Attack (in Claimable.sol)

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/abstract/Claimable.sol#L21

Vulnerability details

Impact

The contract uses a fixed gas amount (HOOK_GAS) for calls to the beforeClaimPrize and afterClaimPrize functions. If this value is set too low, it may prevent the contract from performing the expected operations, leading to a potential Denial of Service (DoS) attack.

Note that Ethereum has had multiple adjustments to its gas settings in the past, and it is likely to have adjustments in the future as well. Therefore, such attack suface might indeed exist.

Proof of Concept

The Claimable contract specifies a fixed gas limit of 150,000 for the beforeClaimPrize and afterClaimPrize hooks.

uint24 public constant HOOK_GAS = 150_000;

It will be used for each claim operation.

function claimPrize(
        address _winner,
        uint8 _tier,
        uint32 _prizeIndex,
        uint96 _reward,
        address _rewardRecipient
    ) external onlyClaimer returns (uint256) {
        address recipient;

        if (_hooks[_winner].useBeforeClaimPrize) {
            recipient = _hooks[_winner].implementation.beforeClaimPrize{ gas: HOOK_GAS }(
                _winner,
                _tier,
                _prizeIndex,
                _reward,
                _rewardRecipient
            );
        } else {
            recipient = _winner;
        }

        if (recipient == address(0)) revert ClaimRecipientZeroAddress();

        uint256 prizeTotal = prizePool.claimPrize(
            _winner,
            _tier,
            _prizeIndex,
            recipient,
            _reward,
            _rewardRecipient
        );

        if (_hooks[_winner].useAfterClaimPrize) {
            _hooks[_winner].implementation.afterClaimPrize{ gas: HOOK_GAS }(
                _winner,
                _tier,
                _prizeIndex,
                prizeTotal,
                recipient
            );
        }

        return prizeTotal;
    }

Tools Used

Manual Analysis

Recommended Mitigation Steps

Consider Add a Setter Function: Implement a setter function that allows the claimer to adjust the HOOK_GAS value. This provides flexibility to adapt to different gas requirements for the beforeClaimPrize and afterClaimPrize hooks.

    /// @notice Allows the contract owner to adjust the HOOK_GAS value
    /// @param newHookGas The new gas limit for the hooks
    function setHookGas(uint24 newHookGas) external onlyClaimer {
        HOOK_GAS = newHookGas;
    }

Assessed type

DoS

Missing Zero Address check on `_yieldFeeRecipient` address

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L958

Vulnerability details

Impact

unintentional setting of _yieldFeeRecipient address to zero Address , may lead to premenant loss of fund generated via yeield fee.

Proof of Concept

//call from constructor
function _setYieldFeeRecipient(address _yieldFeeRecipient) internal {
        yieldFeeRecipient = _yieldFeeRecipient;
        emit YieldFeeRecipientSet(_yieldFeeRecipient);
    }

Tools Used

Manual Review

Recommended Mitigation Steps

//adding a custom error
+    error YieldFeeRecipentZeroAddress();

//adding this check inside the funciton .
+     if(_yieldFeeRecipient ==address(0)){
+         revert YieldFeeRecipentZeroAddress();
+       }

Assessed type

Other

claimYieldFeeShares resets the whole yieldFeeBalance

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L617

Vulnerability details

Summary

The claimYieldFeeShares() allows the yieldFeeRecipient to select how many shares he wants to mint to himself and then burn yieldFeeBalance. The problem is that even if he inputs _shares amount that is less than yieldFeeBalance it still sets the whole yieldFeeBalance to 0.

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L617

Impact

yieldFieldBalance is used to calculate the total debt in _totalDebt():
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L790-L792

The issue is that _totalDebt() assumes that yieldFeeBalance represents the amount of shares that the yieldFeeRecipient will redeem. However that is not the case. If yieldFeeBalance is 50 and the yieldFeeRecipient decides to mint only 5 shares for now, the whole yieldFeeBalance resets to 0.

The totalDebt() is used for a lot of checks for calculations but I assume yieldFeeBalance will be a very small fraction to have any severe impacts on these checks. Nevertheless this is something to keep in mind.

Mitigation

 L-617   yieldFeeBalance -= _shares;

Assessed type

Error

Rug Pull via Malicious Vault

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVaultFactory.sol#L92-L114

Vulnerability details

Impact

Users are at risk of interacting and losing funds from a malicious vault that is endorsed by the PoolTogether protocol.

Proof of Concept

Any user can create a prize vault and specify the yield vault on deployment of the prize vault, as seen in PrizeVaultFatory.sol:

function deployVault(
      string memory _name,
      string memory _symbol,
      IERC4626 _yieldVault,
      PrizePool _prizePool,
      address _claimer,
      address _yieldFeeRecipient,
      uint32 _yieldFeePercentage,
      address _owner
    ) external returns (PrizeVault) {
        PrizeVault _vault = new PrizeVault{
            salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
        }(
            _name,
            _symbol,
            _yieldVault,
            _prizePool,
            _claimer,
            _yieldFeeRecipient,
            _yieldFeePercentage,
            YIELD_BUFFER,
            _owner
        );

        // A donation to fill the yield buffer is made to ensure that early depositors have
        // rounding errors covered in the time before yield is actually generated.
        IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

        allVaults.push(_vault);
        deployedVaults[address(_vault)] = true;

        emit NewPrizeVault(
            _vault,
            _yieldVault,
            _prizePool,
            _name,
            _symbol
        );

        return _vault;
}

There is no access control or verification of the parameters for this function. Additionally, the vault is added to the allVaults array. This allows anyone to create a vault and have it be a part of the PoolTogether protocol.

If a malicious user can easily deploy a prize vault that points to a malicious yield vault. This will allow users to deposit into the prize vault, and have their tokens transferred to the malicious yield vault. Here is a snippet of the function PrizeVault::_depositAndMint(), which approves the yieldVault to spend tokens:

// Previously accumulated dust is swept into the yield vault along with the deposit.
uint256 _assetsWithDust = _asset.balanceOf(address(this));
_asset.approve(address(yieldVault), _assetsWithDust);

Once funds are transferred to the yield vault the malicious actor can pull them out through custom logic in a withdraw function. This leads to the users, who trusted PoolTother, to have their assets stolen.

Tools Used

Manual Analysis

Recommended Mitigation Steps

The simplest mitigation would be to review and whitelist users who wish to create prize vaults. Since PoolTogether aims to be permissionless, I would instead recommend creating a voting system where users can vote to delist prize vaults. This would also require a function to remove prize vaults from the allVaults array.

Assessed type

Rug-Pull

Missing Lossy State Check in `_withdraw()` Method Leads to Potential Overwithdrawal in `transferTokensOut()`

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L659
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L928

Vulnerability details

Impact

The _withdraw() method within transferTokensOut() lacks a check for the lossy state, potentially resulting in excessive yield withdrawal.

Proof of Concept

The LiquidationPair contract can invoke transferTokensOut() for liquidation purposes. This function first calculates _availableYield by calling availableYieldBalance(). Subsequently, it computes _yieldFee based on _amountOut and _yieldFeePercentage. Both _amountOut and _yieldFee are compared against _availableYield to ensure that PrizeVault doesn't enter a lossy state post-liquidation.

    function transferTokensOut(
        address,
        address _receiver,
        address _tokenOut,
        uint256 _amountOut
    ) public virtual onlyLiquidationPair returns (bytes memory) {
        if (_amountOut == 0) revert LiquidationAmountOutZero();

        uint256 _availableYield = availableYieldBalance();
        uint32 _yieldFeePercentage = yieldFeePercentage;

        // Determine the proportional yield fee based on the amount being liquidated:
        uint256 _yieldFee;
        if (_yieldFeePercentage != 0) {
            // The yield fee is calculated as a portion of the total yield being consumed, such that 
            // `total = amountOut + yieldFee` and `yieldFee / total = yieldFeePercentage`. 
            _yieldFee = (_amountOut * FEE_PRECISION) / (FEE_PRECISION - _yieldFeePercentage) - _amountOut;
        }

        // Ensure total liquidation amount does not exceed the available yield balance:
        if (_amountOut + _yieldFee > _availableYield) {
            revert LiquidationExceedsAvailable(_amountOut + _yieldFee, _availableYield);
        }

        // Increase yield fee balance:
        if (_yieldFee > 0) {
            yieldFeeBalance += _yieldFee;
        }

        // Mint or withdraw amountOut to `_receiver`:
        if (_tokenOut == address(_asset)) {
            _withdraw(_receiver, _amountOut);            
        } else if (_tokenOut == address(this)) {
            _mint(_receiver, _amountOut);
        } else {
            revert LiquidationTokenOutNotSupported(_tokenOut);
        }

        emit TransferYieldOut(msg.sender, _tokenOut, _receiver, _amountOut, _yieldFee);

        return "";
    }

Liquidation can occur in two ways:

  1. Via _withdraw(), which redeems ERC4626 tokens held in PrizeVault.
  2. By _mint() of the PrizeVault token.

We'll focus on the _withdraw() method. It redeems ERC4626 tokens to access underlying assets for liquidation. Upon redemption, the conversion rate between ERC4626 and its underlying asset changes. However, this method fails to verify if PrizeVault is in a lossy state post-liquidation. In such a scenario, transferTokensOut() overdraws, thereby disabling deposit() and mint().

Tools Used

Manual Review

Recommended Mitigation Steps

    function transferTokensOut(
        address,
        address _receiver,
        address _tokenOut,
        uint256 _amountOut
    ) public virtual onlyLiquidationPair returns (bytes memory) {
        if (_amountOut == 0) revert LiquidationAmountOutZero();

        uint256 _availableYield = availableYieldBalance();
        uint32 _yieldFeePercentage = yieldFeePercentage;

        // Determine the proportional yield fee based on the amount being liquidated:
        uint256 _yieldFee;
        if (_yieldFeePercentage != 0) {
            // The yield fee is calculated as a portion of the total yield being consumed, such that 
            // `total = amountOut + yieldFee` and `yieldFee / total = yieldFeePercentage`. 
            _yieldFee = (_amountOut * FEE_PRECISION) / (FEE_PRECISION - _yieldFeePercentage) - _amountOut;
        }

        // Ensure total liquidation amount does not exceed the available yield balance:
        if (_amountOut + _yieldFee > _availableYield) {
            revert LiquidationExceedsAvailable(_amountOut + _yieldFee, _availableYield);
        }

        // Increase yield fee balance:
        if (_yieldFee > 0) {
            yieldFeeBalance += _yieldFee;
        }

        // Mint or withdraw amountOut to `_receiver`:
        if (_tokenOut == address(_asset)) {
            _withdraw(_receiver, _amountOut);
+           if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());            
        } else if (_tokenOut == address(this)) {
            _mint(_receiver, _amountOut);
        } else {
            revert LiquidationTokenOutNotSupported(_tokenOut);
        }

        emit TransferYieldOut(msg.sender, _tokenOut, _receiver, _amountOut, _yieldFee);

        return "";
    }

Assessed type

Context

Return values of `approve()` not checked

Lines of code


862, 869

Vulnerability details


Not all IERC20 implementations revert() when there's a failure in approve(). The function signature has a boolean return value and they indicate errors that way instead. By not checking the return value, operations that should have marked as failed, may potentially go through without actually approving anything.

File: pt-v5-vault/src/PrizeVault.sol

   |  // @audit-issue Check approve() return value
862 |  _asset.approve(address(yieldVault), _assetsWithDust);

   |  // @audit-issue Check approve() return value
869 |  _asset.approve(address(yieldVault), 0);

Assessed type


other

ERC20: unsafe use of `transfer()`/`transferFrom()`

Lines of code


939, 118

Vulnerability details


Some tokens do not implement the ERC20 standard properly but are still accepted by most code that accepts ERC20 tokens. For example Tether (USDT)'s transfer() and transferFrom() functions on L1 do not return booleans as the specification requires, and instead have no return value. When these sorts of tokens are cast to IERC20, their function signatures do not match and therefore the calls made, revert (see this link for a test case). Use OpenZeppelin’s SafeERC20's safeTransfer()/safeTransferFrom() instead

File: pt-v5-vault/src/PrizeVault.sol

   |  // @audit-issue Use safeTransfer()/safeTransferFrom()
939 |  _asset.transfer(_receiver, _assets);
File: pt-v5-vault/src/PrizeVaultFactory.sol

    |  // @audit-issue Use safeTransfer()/safeTransferFrom()
118 |  IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

Assessed type


other

Analysis

See the markdown file with the details of this report here.

`claimer` can not be set to address zero as it's stated in code comments

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L80
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L299
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/abstract/Claimable.sol#L67

Vulnerability details

Impact

claimer can not be set to address zero if none is available yet as it's stated in code comments.

Proof of Concept

When we deploy a new PrizeVault there is a comment sections above the deploy func:

    /// @notice Deploy a new vault
    /// @dev Emits a `NewPrizeVault` event with the vault details.

@>    /// @dev `claimer` can be set to address zero if none is available yet.

    /// @dev The caller MUST approve this factory to spend underlying assets equal to `YIELD_BUFFER` so the yield
    /// buffer can be filled on deployment. This value is unrecoverable and is expected to be insignificant.
    /// @param _name Name of the ERC20 share minted by the vault
    /// @param _symbol Symbol of the ERC20 share minted by the vault
    /// @param _yieldVault Address of the ERC4626 vault in which assets are deposited to generate yield
    /// @param _prizePool Address of the PrizePool that computes prizes
    /// @param _claimer Address of the claimer
    /// @param _yieldFeeRecipient Address of the yield fee recipient

    
function deployVault(
      ...
    ) external returns (PrizeVault) {}

However it is not possible as there is a check in Claimable contract.

Vault Factory deployment goes to the Vault constructor:

    constructor(
        string memory name_,
        string memory symbol_,
        IERC4626 yieldVault_,
        PrizePool prizePool_,
        address claimer_,
        address yieldFeeRecipient_,
        uint32 yieldFeePercentage_,
        uint256 yieldBuffer_,
        address owner_
    ) TwabERC20(name_, symbol_, prizePool_.twabController()) Claimable(prizePool_, claimer_) Ownable(owner_) {}

where it forward a variables to the Claimable contract to initiate it:

    constructor(PrizePool prizePool_, address claimer_) {
        if (address(prizePool_) == address(0)) revert PrizePoolZeroAddress();
        prizePool = prizePool_;
        _setClaimer(claimer_);
    }

    function _setClaimer(address _claimer) internal {
@>       if (_claimer == address(0)) revert ClaimerZeroAddress();
        claimer = _claimer;
        emit ClaimerSet(_claimer);
    }

And _setClaimer() check for address(0) and revert the tx in case of that.

Tools Used

Manual review

Recommended Mitigation Steps

If it suppose to be allowed to be set as zero address initially, consider to set it directly in the constructor and make a _setClaimer() as public to let user or admin to set the claimer later.

Assessed type

Context

Donation Attack can inflate `convertToAssets`

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L500-L508
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L470-L472
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L355-L366
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L336-L338

Vulnerability details

Impact

PrizeVault::convertToAssets accepts the number of shares as a parameter and converts it to assets by calculating the totalDebt_ and _totalAssets, with a final calculation of _shares.mulDiv(_totalAssets, totalDebt_, Math.Rounding.Down);. This is a very important function used in PrizeVault::redeem, when users redeem their shares for assets.

However, due to how _totalAssets is calculated, the Prize Vault is susceptible to a Donation Attack, where a malicious user can donate a large number of assets to the vault directly, which will lead to an incorrect calculation of _shares.mulDiv(_totalAssets, totalDebt_, Math.Rounding.Down);.

Users will be able to redeem much more assets than expected for the amount of shares transferred, quickly draining the vault, causing a multitude of issues and potential DoS.

Proof of Concept

The following function is called when users want to redeem shares for assets:

PrizeVault::redeem #L500-508

    function redeem(
        uint256 _shares,
        address _receiver,
        address _owner
    ) external returns (uint256) {
@>      uint256 _assets = previewRedeem(_shares);
        _burnAndWithdraw(msg.sender, _receiver, _owner, _shares, _assets);
        return _assets;
    }

To calculate the _assets to redeem, previewRedeem is called with the number of shares passed in.

PrizeVault::previewRedeem #L470-472

   function previewRedeem(uint256 _shares) public view returns (uint256) {
        return convertToAssets(_shares);
    }

Which calls the convertToAssets function:

PrizeVault::convertToAssets #L355-366

    function convertToAssets(uint256 _shares) public view returns (uint256) {
        uint256 totalDebt_ = totalDebt();
@>      uint256 _totalAssets = totalAssets();
        if (_totalAssets >= totalDebt_) {
            return _shares;
        } else {
            // If the vault controls less assets than what has been deposited a share will be worth a
            // proportional amount of the total assets. This can happen due to fees, slippage, or loss
            // of funds in the underlying yield vault.
            return _shares.mulDiv(_totalAssets, totalDebt_, Math.Rounding.Down);
        }
    }

Take a look how _totalAssets is calculated, by calling totalAssets()

PrizeVault::totalAssets #L336-338

    function totalAssets() public view returns (uint256) {
        return yieldVault.convertToAssets(yieldVault.balanceOf(address(this))) + _asset.balanceOf(address(this));
    }

Notice how _asset.balanceOf(address(this)) is used. This can be manipulated through a direct donation attack, where an attacker will transfer a larger number of the asset to the contract directly. Since they did not deposit, the totalDebt() will not change because no new shares will be minted.

Therefore, when PrizeVault::previewRedeem is executed, _totalAssets will be much greater and the formula will be inflated, where the following calculation will give users more assets than they should when redeeming for shares:

return _shares.mulDiv(_totalAssets, totalDebt_, Math.Rounding.Down);.

Due to the donation _totalAssets will be much greater but totalDebt_ will remain the same.

This will quickly drain the assets in the vault and cause users to lose more funds along with a DoS.

Tools Used

Manual Review.

Recommended Mitigation Steps

Implement internal accounting instead of relying on balanceOf.

Assessed type

Other

deposit() and mint() function will Revert on Zero Value Approvals for certain tokens e.g: BNB

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L869

Vulnerability details

Impact

Some tokens (e.g. BNB) revert when approving a zero value amount and in the PrizeVault contract 0 value approval is happening while depositing tokens which will revert and user will not be able to deposit tokens in the vault.

if (_assetsUsed != _assetsWithDust) {
            // If some latent balance remains, the approval is set back to zero for weird tokens like USDT.
            _asset.approve(address(yieldVault), 0);
        }

Proof of Concept

BNB approve function revert on zero value approval, code snippet:

function approve(address _spender, uint256 _value)
        returns (bool success) {
		if (_value <= 0) throw; 
        allowance[msg.sender][_spender] = _value;
        return true;
    }

also refer this: Revert on Zero Value Approvals for more info about the issue.

Tools Used

Manual Analysis

Recommended Mitigation Steps

Use SafeERC20's safeApprove function to mitigate approval related issues.

Assessed type

DoS

Input validation concerns in the setHooks function in the HookManager contract

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/abstract/HookManager.sol#L29

Vulnerability details

Impact

The setHooks function directly sets user-specified hooks without any validation checks. It's essential to validate that the hooks address provided is a legitimate contract that implements the VaultHooks interface correctly.

Proof of Concept

The provided code snippet for setHooks directly assigns user-provided hooks without any form of validation.

 /// @notice Sets the hooks for a winner.
/// @dev Emits a `SetHooks` event
/// @param hooks The hooks to set
function setHooks(VaultHooks calldata hooks) external {
    _hooks[msg.sender] = hooks;
    emit SetHooks(msg.sender, hooks);
}

Tools Used

VS code

Recommended Mitigation Steps

To ensure the hooks provided are indeed valid contracts. This can be achieved by using OpenZeppelin's Address utility for contract checks.

  1. Import the Address library:

import "@openzeppelin/contracts/utils/Address.sol";

  1. Validate the VaultHooks address within the setHooks function:

function setHooks(VaultHooks calldata hooks) external {
require(Address.isContract(address(hooks)), "Hooks must be a valid contract");
_hooks[msg.sender] = hooks;
emit SetHooks(msg.sender, hooks);
}

It is crucial for DeFi protocols, especially those involving value transfer and external contract interactions like PoolTogether, to rigorously validate user inputs and external contract references to safeguard against vulnerabilities and ensure system robustness.

Assessed type

Access Control

Users can deposit more than it's checked in maxDepost

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L380
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L475
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L441
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L843

Vulnerability details

Impact

Users can deposit more than it's checked in maxDepost and may break the main invariant in TwabController contract.

Proof of Concept

There is a special function for users to check a max amount of assets to deposit in the Vault:

    function maxDeposit(address) public view returns (uint256) {
        uint256 _totalSupply = totalSupply();
        uint256 totalDebt_ = _totalDebt(_totalSupply);
        if (totalAssets() < totalDebt_) return 0;

@>        uint256 twabSupplyLimit_ = _twabSupplyLimit(_totalSupply);
        uint256 _maxDeposit;
        uint256 _latentBalance = _asset.balanceOf(address(this));
        uint256 _maxYieldVaultDeposit = yieldVault.maxDeposit(address(this));
        if (_latentBalance >= _maxYieldVaultDeposit) {
            return 0;
        } else {
            unchecked {
                _maxDeposit = _maxYieldVaultDeposit - _latentBalance;
            }
            return twabSupplyLimit_ < _maxDeposit ? twabSupplyLimit_ : _maxDeposit;
        }
    }

And there is another internal func to determine a max allowed amount:

uint256 twabSupplyLimit_ = _twabSupplyLimit(_totalSupply);

    function _twabSupplyLimit(uint256 _totalSupply) internal pure returns (uint256) {
        unchecked {
            return type(uint96).max - _totalSupply;
        }
    }

So it is assumed that deposit amount should not be higher than a type(uint96).max - totalSupply of tokens.

However deposit() functions miss this check.

    function deposit(uint256 _assets, address _receiver) external returns (uint256) {
        uint256 _shares = previewDeposit(_assets); // assets == _shares
        _depositAndMint(msg.sender, _receiver, _assets, _shares);
        return _shares;
    }

    function _depositAndMint(address _caller, address _receiver, uint256 _assets, uint256 _shares) internal {
        ...

        _asset.safeTransferFrom(
            _caller,
            address(this),
            _assets
        );

        uint256 _assetsWithDust = _asset.balanceOf(address(this));
        _asset.approve(address(yieldVault), _assetsWithDust);

        uint256 _yieldVaultShares = yieldVault.previewDeposit(_assetsWithDust);
        uint256 _assetsUsed = yieldVault.mint(_yieldVaultShares, address(this));

        ...

        _mint(_receiver, _shares);

        if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());

        emit Deposit(_caller, _receiver, _assets, _shares);
    }

So users are able to mint more tokens that they allowed to and exceed type(uint96).max.

The same problem can be found in depositWithPermit() function.

Tools Used

Manual review

Recommended Mitigation Steps

Consider providing a check in deposit() to verify user amount is not higher than allowed one. Also it will help to spend less gas beacuse of the check: if (totalAssets() < totalDebt_) return 0; in case to prevent a loss deposit for users.

Assessed type

Token-Transfer

QA Report

See the markdown file with the details of this report here.

The function `claimYieldFeeShares` has been incorrectly implemented

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L617

Vulnerability details

Impact

In the claimYieldFeeShares function, regardless of the value of _shares, the yieldFeeBalance is consistently set to zero when called by the YieldFeeRecipient. This leads to a loss of Yield Fees.

Proof of Concept

The claimYieldFeeShares function, employed by the YieldFeeRecipient to retrieve Yield Fees, currently exhibits a flaw. Specifically, it consistently sets the yieldFeeBalance to zero, regardless of whether the _shares amount is less than the existing yieldFeeBalance. This oversight leads to a loss of Yield Fees.

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
        if (_shares == 0) revert MintZeroShares();

        uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);
//@audit yieldFeeBalance will always become zero whatever the value of _shares is.
        yieldFeeBalance -= _yieldFeeBalance;

        _mint(msg.sender, _shares);

        emit ClaimYieldFeeShares(msg.sender, _shares);
    }

Tools Used

Manual Review

Recommended Mitigation Steps

The yieldFeeBalance should subtract _shares rather than _yieldFeeBalance.

diff --git a/pt-v5-vault/src/PrizeVault.sol b/pt-v5-vault/src/PrizeVault.sol
index fafcff3..a2f5d81 100644
--- a/pt-v5-vault/src/PrizeVault.sol
+++ b/pt-v5-vault/src/PrizeVault.sol
@@ -614,7 +614,7 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab
         uint256 _yieldFeeBalance = yieldFeeBalance;
         if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

-        yieldFeeBalance -= _yieldFeeBalance;
+        yieldFeeBalance -= _shares;

         _mint(msg.sender, _shares);

Assessed type

Context

Slippage Vulnerability in `_depositAndMint()` Allows Potential Yield Drainage

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L843
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L475
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L482

Vulnerability details

Impact

Lack of slippage protection in _depositAndMint() can potentially drain yields.

Proof of Concept

Users contribute to PrizeVault by executing deposit() and mint(), both of which trigger _depositAndMint(). This function begins with previewDeposit() using the entire asset balance of PrizeVault. Following yieldVault.mint(), _mint() mints _shares directly to the caller at a 1:1 ratio.

    function _depositAndMint(address _caller, address _receiver, uint256 _assets, uint256 _shares) internal {
        if (_shares == 0) revert MintZeroShares();
        if (_assets == 0) revert DepositZeroAssets();

        _asset.safeTransferFrom(
            _caller,
            address(this),
            _assets
        );

        // Previously accumulated dust is swept into the yield vault along with the deposit.
        uint256 _assetsWithDust = _asset.balanceOf(address(this));
        _asset.approve(address(yieldVault), _assetsWithDust);

        // The shares are calculated and then minted directly to mitigate rounding error loss.
        uint256 _yieldVaultShares = yieldVault.previewDeposit(_assetsWithDust);
        uint256 _assetsUsed = yieldVault.mint(_yieldVaultShares, address(this));
        if (_assetsUsed != _assetsWithDust) {
            // If some latent balance remains, the approval is set back to zero for weird tokens like USDT.
            _asset.approve(address(yieldVault), 0);
        }

        _mint(_receiver, _shares);

        if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt());

        emit Deposit(_caller, _receiver, _assets, _shares);
    }

In ERC4626, one common issue is the inflation attack, where an attacker dilutes the value of other users' deposits. This means yieldVault.mint() can yield less ERC4626 tokens to PrizeVault than expected, potentially even yielding zero.

Another common occurrence is exchange rate manipulation in ERC4626.

https://docs.openzeppelin.com/contracts/4.x/erc4626

Consider how this issue can be exploited to effectively reduce yield:

  1. Suppose totalAssets() = 102, totalDebt() = 100, yieldBuffer = 1, and availableYieldBalance() = 1.
  2. A user executes deposit() with 1 wei in _assets.
  3. yieldVault.mint() mints 0 shares to PrizeVault due to an inflation attack or exchange rate manipulation.
  4. `_depositAndMint() proceeds to _mint() 1 wei of PrizeVault token to the user.
  5. The lossy state check is bypassed as long as totalAssets() >= totalDebt().
  6. totalAssets() remains unchanged but totalDebt() is now 101. availableYieldBalance() now becomes 0.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider adding slippage protection in _depositAndMint() when interacting with ERC4626

Assessed type

MEV

User deposit will be stuck forever in the PrizeVault contract

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L936

Vulnerability details

Impact

User deposit will be stuck forever in the PrizeVault contract if the contract need Approval before withdrawal from the Vault the contract (standard EIP4626)

Proof of Concept

According to the EIP4626 standard it's possible that some Vault may implement a pre-requesting withdraw or redeem check in the Vault before a withdrawal may be performed and PrizeVault contract doesn't have any implementation that can first request the token from the Vault, before the withdrawal process resulting the tokens will be stuck forever in the PrizeVault contract and users will going to loose all of their tokens for this.

EIP4626 snippet for Withdrawal:

Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. Those methods should be performed separately.

EIP4626 snippet for Redeem:

Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. Those methods should be performed separately.

save below code in test/unit/PrizeVault/ folder as VaultMock.t.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

  

import { ERC4626Mock } from "openzeppelin/mocks/ERC4626Mock.sol";

import { Math } from "openzeppelin/utils/math/Math.sol";

  

import "openzeppelin/token/ERC20/ERC20.sol";

import "openzeppelin/token/ERC20/utils/SafeERC20.sol";

  

contract VaultMock is ERC4626Mock {

using Math for uint256;

using SafeERC20 for IERC20;

  

constructor(address _asset, string memory _name, string memory _symbol) ERC4626Mock(_asset) {

pricePerFullShare = 1e18;

IBT_UNIT = 10 ** ERC20(_asset).decimals();

}

  

uint256 private pricePerFullShare;

uint256 private IBT_UNIT;

mapping(address => bool) requestedWithdrawal;

  

function requestWithdraw(address owner) public {

requestedWithdrawal[owner] = true;

}

  

/**

* @notice Function to update the price of IBT to its underlying token.

* @param _price The new price of the ibt.

*/

function setPricePerFullShare(uint256 _price) public {

pricePerFullShare = _price;

}

  

/**

* @notice Function to convert the no of shares to it's amount in assets.

* @param shares The no of shares to convert.

* @return The amount of assets from the specified shares.

*/

function convertToAssets(uint256 shares) public view override returns (uint256) {

return (shares * pricePerFullShare) / IBT_UNIT;

}

  

/**

* @notice Function to convert the no of assets to it's amount in shares.

* @param assets The no of assets to convert.

* @return The amount of shares from the specified assets.

*/

function convertToShares(uint256 assets) public view override returns (uint256) {

if (pricePerFullShare == 0) {

return 0;

}

return (assets * IBT_UNIT) / pricePerFullShare;

}

  

/**

* @notice Function to deposit the provided amount in assets.

* @param amount The amount of assets to deposit.

* @param receiver The address of the receiver.

* @return shares The amount of shares received.

*/

function deposit(uint256 amount, address receiver) public override returns (uint256 shares) {

IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount);

shares = convertToShares(amount);

_mint(receiver, shares);

}

  

/**

* @notice Function to withdraw the provided no of shares.

* @param assets The amount of assets to withdraw.

* @param receiver The address of the receiver.

* @return shares The amount of shares to burn to withdraw assets.

*/

function withdraw(

uint256 assets,

address receiver,

address owner

) public override returns (uint256 shares) {
require(requestedWithdrawal[owner], "Should request First");
shares = convertToShares(assets);

if (msg.sender != owner) {

_spendAllowance(owner, msg.sender, shares);

}

_burn(owner, shares);

IERC20(asset()).safeTransfer(receiver, assets);

emit Withdraw(msg.sender, receiver, owner, assets, shares);

}

  

/** @dev See {IERC4626-previewDeposit}. */

function previewDeposit(uint256 _amount) public view override returns (uint256) {

return convertToShares(_amount);

}

  

/** @dev See {IERC4626-previewWithdraw}. */

function previewWithdraw(uint256 assets) public view override returns (uint256) {

return convertToShares(assets);

}

  

/** @dev See {IERC4626-previewRedeem}. */

function previewRedeem(uint256 shares) public view virtual override returns (uint256) {

return convertToAssets(shares) + 1;

}

  

/** @dev See {IERC4626-redeem}. */

function redeem(

uint256 shares,

address receiver,

address owner

) public virtual override returns (uint256) {
require(requestedWithdrawal[owner], "Should request First");
if (shares > maxRedeem(owner)) {

revert();

}

uint256 assets = previewRedeem(shares);

  

if (msg.sender != owner) {

_spendAllowance(owner, msg.sender, shares);

}

_burn(owner, shares);

IERC20(asset()).safeTransfer(receiver, assets);

emit Withdraw(msg.sender, receiver, owner, assets, shares);

return assets;

}

}

and modify the UnitBaseSetup.sol file with below code

 // import below file
 import { VaultMock } from "./VaultMock.t.sol";
 // modify setUpYieldVault() function
 function setUpYieldVault() public virtual returns (IERC4626) {

return new VaultMock(address(underlyingAsset), "Test Yield Vault", "yvTest");

}

Paste the below code in PrizeVault.t.sol file

function testStuckToken() public {

uint aliceAmount = type(uint96).max;

underlyingAsset.mint(alice, aliceAmount);

  

vm.startPrank(alice);

underlyingAsset.approve(address(vault), aliceAmount);

vault.deposit(aliceAmount, alice);

  

vm.expectRevert();

vault.withdraw(vault.maxWithdraw(alice), alice, alice);

vm.stopPrank();

}

Now run forge test --mt testSteaalYieldByOwner -vvv and the test will pass means withdraw reverted.

Tools Used

Manual Analysis

Recommended Mitigation Steps

Implement a function which can be used to call approval function of the Vault contract(ERC4626) for the Vaults which required a approval before withdraw/redeem. or else implement a function to transfer the Vault tokens to users directly instead of underlying asset.

Assessed type

ERC4626

depositWithPermit is allowed to be called only by the permit creator

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L532-L534
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L540

Vulnerability details

Impact

The function depositWithPermit can only be called by the creator of the permit signature.

Proof of Concept

As we can see the function allows user to deposit assets into the PrizeVault using the permit function.

    function depositWithPermit(
        uint256 _assets,
        address _owner,
        uint256 _deadline,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) external returns (uint256) {
        if (_owner != msg.sender) {
            revert PermitCallerNotOwner(msg.sender, _owner);
        }

        // Skip the permit call if the allowance has already been set to exactly what is needed. This prevents
        // griefing attacks where the signature is used by another actor to complete the permit before this
        // function is executed.
        if (_asset.allowance(_owner, address(this)) != _assets) {
            IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s);
        }

        uint256 _shares = previewDeposit(_assets);
        _depositAndMint(_owner, _owner, _assets, _shares);
        return _shares;
    }

But there is a validation _owner != msg.sender. Later on _owner is passed to the permit function. This means that the signer can be only the same address that called depositWithPermit function. This violates the purpose of the permit function.

The issue is similar to this one.

Tools Used

EIP2612

Recommended Mitigation Steps

The issue linked in the PoC part is the same so do the same changes.

Assessed type

Error

permit will not work on some tokens

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L540

Vulnerability details

Impact

The depositWithPermit function will not work as expected if the _asset does not support the permit functionality.

Proof of Concept

Some tokens(for example WETH and stETH) do not have a permit function and others(for example DAI) utilizes a permit function that deviates from the reference implementation.

This means that the permit will execute, but the allowance will not be correct, resulting in unexpected behaviour. The tokens above are widely used so it is likely for them to be the _asset of the vault. Also there are more tokens that have the same issues.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider adding a validation after the permit to check if the allowance is correct and revert with a message if not.

    require(_asset.allowance(_owner, address(this)) >= _assets, "Allowance with permit failed.");

Assessed type

Error

Attacker can deploy vaults with malicious `_yieldVault`

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVaultFactory.sol#L92-L132

Vulnerability details

Impact

PrizeVaultFactory::deployVault allows anyone to deploy a new prize vault. It accepts IERC4626 _yieldVault as a parameter. However, there are no checks to verify _yieldVault is part of a pre-approved list or registry of trusted ERC4626 vaults.

An attacker can take advantage of this by creating a _yieldVault contract that acts like a regular ERC-4626 vault but with a backdoor function that allows them to withdraw all the deposited funds in the contract. This can lead to loss of all user funds and severely damage the protocol's credibility.

Proof of Concept

PrizeVaultFactory::deployVault #L92-132

    /// @notice Deploy a new vault
    /// @dev Emits a `NewPrizeVault` event with the vault details.
    /// @dev `claimer` can be set to address zero if none is available yet.
    /// @dev The caller MUST approve this factory to spend underlying assets equal to `YIELD_BUFFER` so the yield
    /// buffer can be filled on deployment. This value is unrecoverable and is expected to be insignificant.
    /// @param _name Name of the ERC20 share minted by the vault
    /// @param _symbol Symbol of the ERC20 share minted by the vault
    /// @param _yieldVault Address of the ERC4626 vault in which assets are deposited to generate yield
    /// @param _prizePool Address of the PrizePool that computes prizes
    /// @param _claimer Address of the claimer
    /// @param _yieldFeeRecipient Address of the yield fee recipient
    /// @param _yieldFeePercentage Yield fee percentage
    /// @param _owner Address that will gain ownership of this contract
    /// @return PrizeVault The newly deployed PrizeVault
    function deployVault(
      string memory _name,
      string memory _symbol,
@>    IERC4626 _yieldVault,
      PrizePool _prizePool,
      address _claimer,
      address _yieldFeeRecipient,
      uint32 _yieldFeePercentage,
      address _owner
    ) external returns (PrizeVault) {
        PrizeVault _vault = new PrizeVault{
            salt: keccak256(abi.encode(msg.sender, deployerNonces[msg.sender]++))
        }(
            _name,
            _symbol,
@>          _yieldVault,
            _prizePool,
            _claimer,
            _yieldFeeRecipient,
            _yieldFeePercentage,
            YIELD_BUFFER,
            _owner
        );

        // A donation to fill the yield buffer is made to ensure that early depositors have
        // rounding errors covered in the time before yield is actually generated.
        IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

        allVaults.push(_vault);
        deployedVaults[address(_vault)] = true;

        emit NewPrizeVault(
            _vault,
            _yieldVault,
            _prizePool,
            _name,
            _symbol
        );

        return _vault;
    }

As explained above, _yieldVault can be a malicious ERC-4626 vault.

Tools Used

Manual Review.

Recommended Mitigation Steps

Verify that the _yieldVault address is part of a pre-approved list or registry of trusted ERC4626 vaults.

Assessed type

ERC4626

Permit doesnt work with DAI

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L524-L546

Vulnerability details

Impact

The function depositWithPermit in PrizeVault.sol contract is used with permit options so that users can submit a signed message and use that to give allowance to the contract to then extract the tokens required for the deposit.

IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s);

The issue is that the test suite shows that the protocol aims to use sDAI, the dai savings rate, but the DAI token's permit signature is different. From the contract at address 0x6B175474E89094C44Da98b954EedeAC495271d0F, we see the permit function

function permit(address holder, address spender, uint256 nonce, uint256 expiry,
                    bool allowed, uint8 v, bytes32 r, bytes32 s) external

Due to the missing nonce field, DAI, a token which allows permit based interactions, cannot be used with signed messages for depositing into sDAI vaults. Due to the wrong parameters, the permit transactions will revert.

Proof of Concept

It is evident from the code that the permit function call does not match the signature of DAI's permit function.

Tools Used

Manual Review

Recommended Mitigation Steps

For the special case of DAI token, allow a different implementation of the permit function which allows a nonce variable.

Assessed type

Token-Transfer

Incorrect `yieldFeeBalance` calculation

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L611-L622

Vulnerability details

Impact

PrizeVault::claimYieldFeeShares transfers yield fee shares to the yield fee recipient. However, it incorrectly resets the yieldFeeBalance to 0 on each call, causing any remaining yieldFeeBalance to be unclaimable indefinitely.

Proof of Concept

PrizeVault::claimYieldFeeShares #L611-622

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
        if (_shares == 0) revert MintZeroShares();

@>      uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

@>      yieldFeeBalance -= _yieldFeeBalance;

        _mint(msg.sender, _shares);

        emit ClaimYieldFeeShares(msg.sender, _shares);
    }

In the function, you can see that the value of yieldFeeBalance is stored in _yieldFeeBalance, which is used to check if the number of shares entered exceeds the fees available to claim. However, we can see that yieldFeeBalance is then reduced by _yieldFeeBalance, effectively resetting it to 0. Any fee balance that was previously claimable now becomes unclaimable indefinitely.

This will cause issues if _shares < yieldFeeBalance. The correct way is to reduce yieldFeeBalance by _shares.

Tools Used

Manual Review.

Recommended Mitigation Steps

Perform the correct calculation:

    function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
        if (_shares == 0) revert MintZeroShares();

        uint256 _yieldFeeBalance = yieldFeeBalance;
        if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);

-        yieldFeeBalance -= _yieldFeeBalance;
+        yieldFeeBalance -= _shares;
        _mint(msg.sender, _shares);

        emit ClaimYieldFeeShares(msg.sender, _shares);
    }

Assessed type

Math

`address(this)` can be passed as param in `_setClaimer`

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/abstract/Claimable.sol#L128

Vulnerability details

Impact

If a user sets up a Claimable for his prize pool and sets the claimer address to be the address of the contract, all of the prize funds will get locked up in the contract.

Proof of Concept

In the Claimable.sol contract, the constructor takes in prizePool_ and claimer_ as parameters. If a user sets their own prize pool address properly, but instead of using their own address for the claimer_ they paste the address of the contract: address(this) - the internal function _setClaimer will get called with the address of the contract passed. As a result of this, all of the prizes that will get accumulated will be frozen forever, as there's no way for the user to call the claimPrize function due to the onlyClaimer modifier, which in this case would be the contract.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider implementing the following changes:

     /// @notice Thrown when the Prize Pool is set to the zero address.
     error PrizePoolZeroAddress();

     /// @notice Thrown when the Claimer is set to the zero address.
     error ClaimerZeroAddress();

+    /// @notice Thrown when the Claimer is set to address(this).
+    error ClaimerContractAddress();
    
function _setClaimer(address _claimer) internal {
  if (_claimer == address(0)) revert ClaimerZeroAddress();
+ if (_claimer == address(this)) revert ClaimerContractAddress();
  claimer = _claimer;
  emit ClaimerSet(_claimer);
}

Assessed type

Other

ERC20 transferFrom return values not checked

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L118

Vulnerability details

Impact

Within the PrizeVaultFactory::deployVault, in case the transferFrom fails and returns false, which is the case for some tokens, and a newly deployed PrizeVault isn't funded with the initial yield buffer, it would not be able to cover for rounding errors.

Even though ineligible deposits would fail, any withdrawals that would turn out to have rounding errors would not be covered for by the yield buffer and could incur losses for the users, especially during periods of low yield generation or unexpected losses from the underlying yield vault.

Proof of Concept

function deployVault(...) external returns (PrizeVault) {
      PrizeVault _vault = new PrizeVault{}(...);

      // A donation to fill the yield buffer is made to ensure that early depositors have
      // rounding errors covered in the time before yield is actually generated.
      // >>> NOTE: if this transfer fails and returns false instead of reverting, `_vault` will have an empty `yieldBuffer` <<<
      IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);

     // remainder code below
}

Given the following scenario:

  • Deployer's transferFrom does not go through, but the ERC20 contract does not revert and instead it returns false
  • Newly deployed PrizeVault has a yieldBuffer of 0
  • Users deposit valid amounts into the PrizeVault
  • Underlying strategy experiences either
    • a) a minor yield loss or
    • b) a low yield gain
  • Users are able to withdraw either:
    • a) less than they've deposited (breaking the "no-loss" property)
    • b) less than they've earned

Tools Used

Manual inspection

Recommended Mitigation Steps

Choose one of the two approaches:

  1. Check the value of the success boolean that's returned from the transferFrom() call

Example:

bool success = IERC20(_vault.asset()).transferFrom(msg.sender, address(_vault), YIELD_BUFFER);
if(!success){
   revert TransferFailed(msg.sender, address(_vault), YIELD_BUFFER);
}
  1. Alternatively, the use of OpenZeppelin's SafeERC20 library and its safeTransferFrom() function is highly encouraged, as it would check for the return value implicitly

Example:

IERC20(_vault.asset()).safeTransferFrom(msg.sender, address(_vault), YIELD_BUFFER);

Assessed type

ERC20

User can provide malicious prizePool and yieldVault in the PrizeVaultFactory.sol

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L95-L96

Vulnerability details

Summary

User can deploy a malicious PrizeVault

Proof of Concept

Everyone can create their own vault by calling deployVault() in PrizePoolFactory.sol. The problem is that the function does not check for the authenticity of the prizePool and yieldVault the user has entered as input. This factory also has a mapping deployedVaults which other users can use to verify that the deployed vault is not malicious. However there is no way for them to check for the authenticity of the PrizePool and the yieldVault contracts that the vault integrates with.

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L95-L96

https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVaultFactory.sol#L69

Impact

An attacker can for example create their own yieldVault and use it to deploy a Prize Vault. After users interact with it by depositing tokens he can perhaps steal them using his malicious yieldVault

An attacker can also deploy a Prize Vault using a malicious PrizePool. This could perhaps allow him to determine winners or steals the prize token.

Recommended Mitigation Steps

To check for the authenticity of a PrizePool a PrizePoolFactory can be implemented that has a mapping just like the one in PrizeVault that adds authentic PrizePools to it. Then the PrizeVaultFactory can check if the PrizePool has been deployed using its factory.
I'm not sure how to check for the authenticity of the yieldVault though.

Assessed type

ERC4626

The code doesn't check number of shares to be minted

Lines of code

https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L476
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L483
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L494
https://github.com/code-423n4/2024-03-pooltogether/blob/480d58b9e8611c13587f28811864aea138a0021a/pt-v5-vault/src/PrizeVault.sol#L505

Vulnerability details

Impact

The code doesn't check number of shares to be minted/burn

Description

The problem arises because of not checking for 0 number of assets to be minted for a particular deposited asset . This could lead to a potential DOS while minting those shares.

Recommended Mitigation Steps

I might have missed some other instances like withdraw/redeem where it is also necessary
require(uint256 _shares = previewDeposit(_assets) != 0, "");

Assessed type

Invalid Validation

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.