Platform: Code4rena
Start Date: 07/07/2023
Pot Size: $121,650 USDC
Total HM: 36
Participants: 111
Period: 7 days
Judge: Picodes
Total Solo HM: 13
Id: 258
League: ETH
Rank: 27/111
Findings: 2
Award: $770.50
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Udsen
Also found by: 0xMirce, 0xPsuedoPandit, 0xStalin, 0xbepresent, Aymen0909, Bobface, Co0nan, GREY-HAWK-REACH, Jeiwan, John, KupiaSec, LuchoLeonel1, Nyx, Praise, RedTiger, alexweb3, bin2chen, btk, dacian, dirk_y, josephdara, keccak123, ktg, mahdirostami, markus_ether, minhtrng, ni8mare, peanuts, ptsanev, ravikiranweb3, rvierdiiev, seeques, serial-coder, shaka, teawaterwire, wangxx2026, zzzitron
2.2492 USDC - $2.25
Anyone can pass an arbitrary recipient
in mintYieldFee
function to mint free shares.
When someone calls a liquidate function on a vault, the yieldFeeTotalSupply
is increased if yieldFeePercentage != 0
if (_yieldFeePercentage != 0) { _increaseYieldFeeBalance( (_amountOut * FEE_PRECISION) / (FEE_PRECISION - _yieldFeePercentage) - _amountOut ); }
This yield fee can then be withdrawn through a mintYieldFee function. The issue is that instead of transfering the yield fee to the _yieldFeeRecipient
(a state variable), the function takes an arbitrary address as the recipient, meaning that anyone can mint free shares.
Manual review
Transfer the yield fee to the _yieldFeeRecipient
directly.
Access Control
#0 - c4-judge
2023-07-14T22:20:33Z
Picodes marked the issue as duplicate of #396
#1 - c4-judge
2023-08-05T22:04:30Z
Picodes marked the issue as satisfactory
768.245 USDC - $768.25
https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L570 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L498-L502 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L311-L330 (https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/abstract/TieredLiquidityDistributor.sol#L535-L536
It is possible to inflate the _reserve
variable thus breaking the assumption stated in the contest's README file: The balance of prize tokens held by the contract must always be equal to the sum of the available tier liquidity and the reserve.
Since _reserve
is used as a cushion when there is insufficient tier liquidity for the prizes, the inflated reserves would not account for tier liquidity correctly. In case when there is indeed insufficient liquidity for the tier and normally accounted reserves are not enough to cover the deficiency (in this case the tx should revert), the inflated ones would "steal" liquidity from the next draws.
The prize tokens are contributed to the prizePool
contract for the given vault by calling the contributePrizeTokens function on the prizePool
contract. Users are incentivized to contribute POOL tokens in exchange for vault's shares by liquidating vault's yield in liquidate function which subsequently calls the contributePrizeTokens function for this vault. Users have to provide POOL tokens before liquidation.
Generally, _reserves
are increased as a portion of vault's contribution as per docs. It is possible to increase reserves manually by calling the increaseReserve function with the provided amount of POOL tokens which will be transfered from msg.sender account.
As stated in README file, the balance of prize tokens in prize pool contract must always be equal to the sum of the available tier liquidity and the reserve.
However, it is possible for liquidator to break this assumption by transfering prize tokens through the increaseReserve
function just before liquidating the vault's yield. Such action would increase both the _reserve
s and the available tier liquidity for the next drawId.
A step by step actions (suppose the reserves and the tier liquidity are both zero and only one vault participates in a draw):
increaseReserves
function with amount of 1000 POOL tokens. _reserves == 1000
, POOL token balance == 1000liquidate
function on the vault (or it is possible to call contributePrizeTokens
directly). totalAccumulator amount would be 1000.Now with this share parameteres (only tokens to distribute would be 1000) and after all the prizes are distributed for all the tiers (assuming that no there is no remaining liquidity) the contract would have 70 POOL tokens in its balance and 1070 in its reserve.
It is also should be noted that if the draw manager decides to withdraw full reserve amount (accidentally or not), he will steal the liquidity from the next draw and also inflate the totalWithdrawn
variable which will cause revert on every attempt to call contributePrizeTokens
function because of underflow until someone transfers the withdrawn liquidity back.
Manual review
I think it is best to add some kind of access restriction to increaseReserve function since normal users are not incentivized to call it anyway.
Context
#0 - c4-judge
2023-07-16T15:22:57Z
Picodes marked the issue as duplicate of #200
#1 - c4-judge
2023-08-06T11:05:37Z
Picodes marked the issue as partial-50
#2 - c4-judge
2023-08-06T11:06:06Z
Picodes marked the issue as satisfactory
768.245 USDC - $768.25
https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L498 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L311
Anyone can backrun the transaction in which the increaseReserve
function is called to gain free liquidity for the vault by calling contributePrizeTokens
.
If protocol admins (or someone esle) decide to increase reserves to provide a better incentive for a draw and a better cushion for tier liquidity in case it is insufficient, a malicious user who watches the mempool can backrun the transaction to gain free liquidity for the vault he is interested in by calling the contributePrizeTokens
function.
It is possible due to how contributePrizeTokens
function works - it checks if a user transfered prize tokens before calling it - and due to the fact that reserves consist of prize pool tokens:
function contributePrizeTokens(address _prizeVault, uint256 _amount) external returns (uint256) { uint256 _deltaBalance = prizeToken.balanceOf(address(this)) - _accountedBalance(); if (_deltaBalance < _amount) { revert ContributionGTDeltaBalance(_amount, _deltaBalance); }
function increaseReserve(uint104 _amount) external { _reserve += _amount; prizeToken.safeTransferFrom(msg.sender, address(this), _amount); emit IncreaseReserve(msg.sender, _amount); }
In addition, backrunning would not only benefit the vault with free liquidity but also inflate the reserve amount as I described in my previous finding.
Manual review
The quickest way to fix this is to transfer prize tokens in time of execution the contributePrizeTokens
function from msg.sender. However, the Vault's liquidate
function should also be refactored in some way. Perhaps if the contribution comes from the liquidation, users should transfer prize tokens to vaults before calling liquidate
from which they are taken afterwards by prizePool
contract.
Timing
#0 - c4-judge
2023-07-16T15:21:50Z
Picodes marked the issue as primary issue
#1 - asselstine
2023-07-19T22:23:47Z
Going to add some notes here:
The issue is that _accountedBalance()
computes (obs.available + obs.disbursed) - _totalWithdrawn
, which basically means total contributed liquidity minus withdrawn liquidity.
However, when adding liquidity manually via increaseReserve
it does not increase the contributed liquidity.
This issue points to back-running as the problem, but that's not the case. The case is improper accounting, as in this issue that was closed as a duplicate: https://github.com/code-423n4/2023-07-pooltogether-findings/issues/147
#2 - c4-sponsor
2023-07-19T22:23:57Z
asselstine marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-06T11:03:13Z
Picodes marked the issue as partial-50
#4 - Picodes
2023-08-06T11:03:25Z
Partial credit as the root cause of the issue is not highlighted
#5 - c4-judge
2023-08-06T11:03:36Z
Picodes marked issue #147 as primary and marked this issue as a duplicate of 147