Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $36,500 USDC
Total HM: 9
Participants: 69
Period: 3 days
Judge: Picodes
Total Solo HM: 2
Id: 190
League: ETH
Rank: 4/69
Findings: 4
Award: $1,932.93
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: zaskoh
Also found by: Tricko, deliriusz, rvierdiiev, unforgiven
1273.0771 USDC - $1,273.08
Because WithdrawHook.hook doesn't track user's withdraw time separately, when someone withdraws before user, he can't withdraw all amount
Function WithdrawHook.hook should not allow users to withdraw more then is allowed for some period. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/WithdrawHook.sol#L66-L72
if (lastUserPeriodReset + userPeriodLength < block.timestamp) { lastUserPeriodReset = block.timestamp; userToAmountWithdrawnThisPeriod[_sender] = _amountBeforeFee; } else { require(userToAmountWithdrawnThisPeriod[_sender] + _amountBeforeFee <= userWithdrawLimitPerPeriod, "user withdraw limit exceeded"); userToAmountWithdrawnThisPeriod[_sender] += _amountBeforeFee; }
This is logic controls how many tokens user can withdraw during some period.
What you should note here is that there is no lastUserPeriodReset
per account, it is used globally. And once any account withdraws when (lastUserPeriodReset + userPeriodLength < block.timestamp
it will be reset to block.timestamp.
I want to show situation to explain the problem.
Let's assume that allowed amount to withdraw per user is 100 tokens per hour.
1.At t1=0 lastUserPeriodReset is 0. userA calls withdraw with amount 100. userToAmountWithdrawnThisPeriod[_sender] becomes 100. And userA has no ability to withdraw more tokens during this hour. He will be able to do that at t2=1 hour and 1 minute.
2.At t2=1 hour and 1 minute userB calls withdraw 100 tokens. Because this condition if (lastUserPeriodReset + userPeriodLength < block.timestamp)
is true, lastUserPeriodReset becomes block.timestamp and == 1 hour and 1 minute. What happened next to this userB is not interested for us.
3.At t3 = 1 hour and 5 minutes userA calls withdraw 100 tokens. Because this condition if (lastUserPeriodReset + userPeriodLength < block.timestamp)
is false, this check will be done require(userToAmountWithdrawnThisPeriod[_sender] + _amountBeforeFee <= userWithdrawLimitPerPeriod, "user withdraw limit exceeded");
. And it will fail, because userToAmountWithdrawnThisPeriod[_sender]
is currently 100 and it was not cleared when userB updated lastUserPeriodReset.
As result userA can't withdraw money currently. To withdraw them he should be first one who will call withdraw after lastUserPeriodReset + userPeriodLength time amount, as only in that case his userToAmountWithdrawnThisPeriod amount will not be checked.
VsCode
You need to add one more bool field that will indicate that lastUserPeriodReset was recently reset.
if (lastUserPeriodReset + userPeriodLength < block.timestamp) { lastUserPeriodReset = block.timestamp; userToAmountWithdrawnThisPeriod[_sender] = _amountBeforeFee; userPeriodWasReset = true; } else { require(userPeriodWasReset || userToAmountWithdrawnThisPeriod[_sender] + _amountBeforeFee <= userWithdrawLimitPerPeriod, "user withdraw limit exceeded"); if (userPeriodWasReset ) { userToAmountWithdrawnThisPeriod[_sender] = 0; userPeriodWasReset = false; } userToAmountWithdrawnThisPeriod[_sender] += _amountBeforeFee; }
#0 - Picodes
2022-12-13T21:06:13Z
Your mitigation does not fully solves the issue. It works only for up to 2 users.
The PoC is correct but it doesn't look like the warden identified the root issue.
#1 - c4-judge
2022-12-13T21:06:58Z
Picodes marked the issue as duplicate of #325
#2 - c4-judge
2022-12-13T21:07:39Z
Picodes marked the issue as partial-50
#3 - rvierdiyev
2022-12-21T21:36:03Z
I respectfully disagree with your decision. First of all i understood the problem, because i have created correct POC as you said. Also i don't agree that my solution is incorrect. And the last thing is that i have identified the problem, even if i didn't find correct solution, this is not so important as i am not developer of protocol, my job is to find issues(what i did here), also i can recommend smth, but that doesn't guarantee that i will suggest the best solution as i am not developer.
and about recommended steps: i wanted to recommend to track time for each user separatly(as you can see in report title), but i tried to solve issue in another way(to not waste gas).
#4 - c4-judge
2023-01-01T17:07:44Z
Picodes marked the issue as satisfactory
#5 - Picodes
2023-01-01T17:07:45Z
@rvierdiyev indeed the issue is correctly identified so I'll remove the partial credit label, my first pass was too harsh. This being said, I'd appreciate if you could keep this kind of comments for the post judging Q&A s decisions are not supposed to be finalized until then :)
210.7761 USDC - $210.78
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/WithdrawHook.sol#L59-L62 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/WithdrawHook.sol#L140-L146
WithdrawHook doesn't check that userWithdrawLimitPerPeriod is less than globalWithdrawLimitPerPeriod. It allows to user withdraw more then globalWithdrawLimitPerPeriod per period.
Function WithdrawHook.hook should not allow to withdraw more than globalWithdrawLimitPerPeriod amount of tokens per globalPeriodLength of time. But it's possible when userWithdrawLimitPerPeriod > globalWithdrawLimitPerPeriod. Because there is no input checkings it's possible to provide userWithdrawLimitPerPeriod > globalWithdrawLimitPerPeriod.
This is how this global withdraw limit is handled. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/WithdrawHook.sol#L59-L65
if (lastGlobalPeriodReset + globalPeriodLength < block.timestamp) { lastGlobalPeriodReset = block.timestamp; globalAmountWithdrawnThisPeriod = _amountBeforeFee; } else { require(globalAmountWithdrawnThisPeriod + _amountBeforeFee <= globalWithdrawLimitPerPeriod, "global withdraw limit exceeded"); globalAmountWithdrawnThisPeriod += _amountBeforeFee; }
As you can see the check is done only when globalPeriodLength
has not passed since last lastGlobalPeriodReset
update. When this condition if (lastGlobalPeriodReset + globalPeriodLength < block.timestamp)
is true, then there is no amount validation.
And if userWithdrawLimitPerPeriod is bigger than globalWithdrawLimitPerPeriod then user can withdraw more than globalWithdrawLimitPerPeriod.
If userWithdrawLimitPerPeriod is less than globalWithdrawLimitPerPeriod then it will be not possible to do that, as user limit checking will fail(if they will be fixed as i described in another reports).
VsCode
Add check that userWithdrawLimitPerPeriod < globalWithdrawLimitPerPeriod in setters. Or add checking require(_amountBeforeFee <= globalWithdrawLimitPerPeriod, "global withdraw limit exceeded");
to if clause.
#0 - hansfriese
2022-12-14T18:01:26Z
duplicate of #310 but explains slightly different point
#1 - c4-judge
2022-12-19T09:22:47Z
Picodes marked the issue as duplicate of #310
#2 - c4-judge
2023-01-01T17:21:02Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-01-09T20:35:22Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: obront
Also found by: 8olidity, HE1M, Madalad, Trust, cccz, csanuragjain, deliriusz, hansfriese, hihen, joestakey, rvierdiiev, wait, zaskoh
52.8446 USDC - $52.84
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/ManagerWithdrawHook.sol#L17 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/ManagerWithdrawHook.sol#L29-L33 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L82
Collateral can become insolvent because of managerWithdraw function.
Collateral.managerWithdraw allows to withdraw base tokens to manager. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L80-L83
function managerWithdraw(uint256 _amount) external override onlyRole(MANAGER_WITHDRAW_ROLE) nonReentrant { if (address(managerWithdrawHook) != address(0)) managerWithdrawHook.hook(msg.sender, _amount, _amount); baseToken.transfer(manager, _amount); }
In case if managerWithdrawHook is 0 then there is no any checks and _amount is sent to manager. This can make Collateral insolvent. Scenario. 1.User deposit 1 million tokens to Collateral. 2.managerWithdraw is called and all tokens were sent to manager. 3.Collateral is insolvent, users can't withdraw.
In case if managerWithdrawHook is not 0 then managerWithdrawHook.hook is called. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/ManagerWithdrawHook.sol#L17
function hook(address, uint256, uint256 _amountAfterFee) external view override { require(collateral.getReserve() - _amountAfterFee >= getMinReserve(), "reserve would fall below minimum"); }
The check that is done here is that balance of base token of Collateral will stay above some provided percentage(minReservePercentage) multiplied by all amount recorded in depositRecord after withdraw. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/ManagerWithdrawHook.sol#L41
function getMinReserve() public view override returns (uint256) { return (depositRecord.getGlobalNetDepositAmount() * minReservePercentage) / PERCENT_DENOMINATOR; }
Even if this percentage is small, it's enough to make Collateral insolvent as well and Collateral will not hold enough funds to cover users withdraws.
VsCode
I believe that manager should not be able to withdraw users funds. He should not be able to withdraw any amount that will make Collateral balance less than depositRecord.getGlobalNetDepositAmount().
#0 - hansfriese
2022-12-14T17:58:27Z
duplicate of #254
#1 - c4-judge
2022-12-17T09:57:01Z
Picodes marked the issue as duplicate of #254
#2 - c4-judge
2023-01-01T17:41:51Z
Picodes marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0Kage, 0x52, 0xAgro, 0xNazgul, 0xTraub, 0xhacksmithh, Awesome, Aymen0909, Bnke0x0, Englave, Janio, Mukund, Parth, RaymondFam, Rolezn, SmartSek, Tointer, UdarTeam, Udsen, Zarf, caventa, chaduke, csanuragjain, deliriusz, gz627, idkwhatimdoing, izhelyazkov, joestakey, neumo, obront, oyc_109, rvierdiiev, shark, trustindistrust, wait, yongskiws
396.2326 USDC - $396.23
DepositTradeHelper.depositAndTrade will not work normally with ERC20 that need to clear allowance as it sets allowance to uint256.max and do not set it to 0 after. As result next time when user calls depositAndTrade function it will fail as allowance should be set to 0 before.
DepositTradeHelper.depositAndTrade function allows user to deposit base tokens into Collateral token and then swap it using Uniswap. https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/DepositTradeHelper.sol#L22-L34
function depositAndTrade(uint256 baseTokenAmount, Permit calldata baseTokenPermit, Permit calldata collateralPermit, OffChainTradeParams calldata tradeParams) external override { if (baseTokenPermit.deadline != 0) { IERC20Permit(address(_baseToken)).permit(msg.sender, address(this), type(uint256).max, baseTokenPermit.deadline, baseTokenPermit.v, baseTokenPermit.r, baseTokenPermit.s); } _baseToken.transferFrom(msg.sender, address(this), baseTokenAmount); if (collateralPermit.deadline != 0) { _collateral.permit(msg.sender, address(this), type(uint256).max, collateralPermit.deadline, collateralPermit.v, collateralPermit.r, collateralPermit.s); } uint256 _collateralAmountMinted = _collateral.deposit(msg.sender, baseTokenAmount); _collateral.transferFrom(msg.sender, address(this), _collateralAmountMinted); ISwapRouter.ExactInputSingleParams memory exactInputSingleParams = ISwapRouter.ExactInputSingleParams(address(_collateral), tradeParams.tokenOut, POOL_FEE_TIER, msg.sender, tradeParams.deadline, _collateralAmountMinted, tradeParams.amountOutMinimum, tradeParams.sqrtPriceLimitX96); _swapRouter.exactInputSingle(exactInputSingleParams); }
It uses permit function to provide allowance to himself and it sets allowance to type(uint256).max. After the transfer it doesn't set allowance to 0. And next time when user will call this function it will revert if _baseToken doesn't support providing allowance when current allowance is not set to 0(like USDT).
VsCode
Do not set allowance to type(uint256).max, set it to baseTokenAmount value.
#0 - c4-judge
2022-12-17T21:46:16Z
Picodes changed the severity to QA (Quality Assurance)
#1 - c4-judge
2022-12-19T14:04:19Z
Picodes marked the issue as grade-c
#2 - c4-judge
2022-12-19T14:04:33Z
Picodes marked the issue as grade-b
#3 - c4-judge
2022-12-19T14:04:46Z
Picodes marked the issue as grade-a