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: 8/69
Findings: 4
Award: $1,239.22
🌟 Selected for report: 1
🚀 Solo Findings: 0
274.009 USDC - $274.01
https://github.com/prepo-io/prepo-monorepo/blob/3541bc704ab185a969f300e96e2f744a572a3640/apps/smart-contracts/core/contracts/WithdrawHook.sol#L61 https://github.com/prepo-io/prepo-monorepo/blob/3541bc704ab185a969f300e96e2f744a572a3640/apps/smart-contracts/core/contracts/WithdrawHook.sol#L68
In Collateral.sol, users may withdraw underlying tokens using withdraw. Importantly, the withdrawal must be approved by withdrawHook if set:
function withdraw(uint256 _amount) external override nonReentrant { uint256 _baseTokenAmount = (_amount * baseTokenDenominator) / 1e18; uint256 _fee = (_baseTokenAmount * withdrawFee) / FEE_DENOMINATOR; if (withdrawFee > 0) { require(_fee > 0, "fee = 0"); } else { require(_baseTokenAmount > 0, "amount = 0"); } _burn(msg.sender, _amount); uint256 _baseTokenAmountAfterFee = _baseTokenAmount - _fee; if (address(withdrawHook) != address(0)) { baseToken.approve(address(withdrawHook), _fee); withdrawHook.hook(msg.sender, _baseTokenAmount, _baseTokenAmountAfterFee); baseToken.approve(address(withdrawHook), 0); } baseToken.transfer(msg.sender, _baseTokenAmountAfterFee); emit Withdraw(msg.sender, _baseTokenAmountAfterFee, _fee); }
The hook requires that two checks are passed:
if (lastGlobalPeriodReset + globalPeriodLength < block.timestamp) { lastGlobalPeriodReset = block.timestamp; globalAmountWithdrawnThisPeriod = _amountBeforeFee; } else { require(globalAmountWithdrawnThisPeriod + _amountBeforeFee <= globalWithdrawLimitPerPeriod, "global withdraw limit exceeded"); globalAmountWithdrawnThisPeriod += _amountBeforeFee; } if (lastUserPeriodReset + userPeriodLength < block.timestamp) { lastUserPeriodReset = block.timestamp; userToAmountWithdrawnThisPeriod[_sender] = _amountBeforeFee; } else { require(userToAmountWithdrawnThisPeriod[_sender] + _amountBeforeFee <= userWithdrawLimitPerPeriod, "user withdraw limit exceeded"); userToAmountWithdrawnThisPeriod[_sender] += _amountBeforeFee; }
If it has been less than "globalPeriodLength" seconds since the global reset, we step into the if block, reset time becomes now and starting amount is the current requested amount. Otherwise, the new amount must not overpass the globalWithdrawLimitPerPeriod. Very similar check is done for "user" variables.
The big issue here is that the limit can be easily bypassed by the first person calling withdraw in each group ("global" and "user"). It will step directly into the if block where no check is done, and fill the variable with any input amount.
As I understand, the withdraw limit is meant to make sure everyone is guaranteed to be able to withdraw the specified amount, so there is no chance of freeze of funds. However, due to the bypassing of this check, a whale user is able to empty the current reserves put in place and cause a freeze of funds for other users, until the Collateral contract is replenished.
A whale user is able to cause freeze of funds of other users by bypassing withdraw limit
Manual audit
Add limit checks in the if blocks as well, to make sure the first request does not overflow the limit.
I've confirmed with PrePO team during the contest that withdraw limit bypass is a very serious issue
#0 - c4-judge
2022-12-13T19:06:29Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2022-12-19T21:38:36Z
ramenforbreakfast marked the issue as sponsor confirmed
#2 - c4-judge
2023-01-01T17:18:22Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-01-01T17:18:48Z
Picodes marked the issue as selected for report
🌟 Selected for report: hansfriese
Also found by: Trust, cccz, unforgiven
530.4488 USDC - $530.45
In PrePOMarket.sol, the _finalLongPayout represents the finalized value of a single long token in the market. It is settled post ICO / IPO according to predetermined rules. The issue is that this value may be re-initialized as many times as owner desires:
function setFinalLongPayout(uint256 _finalLongPayout) external override onlyOwner { require(_finalLongPayout >= floorLongPayout, "Payout cannot be below floor"); require(_finalLongPayout <= ceilingLongPayout, "Payout cannot exceed ceiling"); finalLongPayout = _finalLongPayout; emit FinalLongPayoutSet(_finalLongPayout); }
This introduces a very substantial centralization risk. A malicious owner may set long payout to floorLongPayout and cash out on all their short tokens, which would be worth their maximum potential. Then, they could re-initialize it to ceilingLongPayout and cash out their long tokens. This way, they are directly stealing from long / short token holders which will not be able to claim their underlying holdings.
For reference, this block in redeem() calculates the collateral amount. It clearly shows how the long tokens appreciate in value when finalLongPayout is high, and short tokens appreciate in value when finalLongPayout is low.
uint256 _collateralAmount; if (finalLongPayout <= MAX_PAYOUT) { uint256 _shortPayout = MAX_PAYOUT - finalLongPayout; _collateralAmount = (finalLongPayout * _longAmount + _shortPayout * _shortAmount) / MAX_PAYOUT; } else { require(_longAmount == _shortAmount, "Long and Short must be equal"); _collateralAmount = _longAmount; }
M - finalLongPayout can be reinitialized many times although by design it is final. Add require. Risk of owner misusing to enjoy high evaluation of Long position and then high evaluation of short position.
Owner can rug PrePOMarket using re-initialized finalLongPayout.
Manual audit
finalLongPayout should only be allowed to be set once.
#0 - hansfriese
2022-12-14T17:33:32Z
duplicate of #231 (my submission)
#1 - c4-judge
2022-12-17T10:24:18Z
Picodes marked the issue as duplicate of #231
#2 - c4-judge
2023-01-07T15:30:39Z
Picodes marked the issue as satisfactory
🌟 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
Judge has assessed an item in Issue #315 as M risk. The relevant finding follows:
Vulnerability details Description Collateral.sol exposes a permissioned withdraw function:
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); } An approved manager can withdraw the desired amount provided the hook executes successfully.
The hooks implementation is:
function hook(address, uint256, uint256 _amountAfterFee) external view override { require(collateral.getReserve() - _amountAfterFee >= getMinReserve(), "reserve would fall below minimum"); } The code above checks the remaining collateral amount is above a calculated minimum reserve. getMinReserve():
function getMinReserve() public view override returns (uint256) { return (depositRecord.getGlobalNetDepositAmount() * minReservePercentage) / PERCENT_DENOMINATOR; } It returns the current total deposited amount multiplied by minReservePercentage. The issue is that owner can immediately change minReservePercentage to zero:
function setMinReservePercentage(uint256 _newMinReservePercentage) external override onlyRole(SET_MIN_RESERVE_PERCENTAGE_ROLE) { require(_newMinReservePercentage <= PERCENT_DENOMINATOR, ">100%"); minReservePercentage = _newMinReservePercentage; emit MinReservePercentageChange(_newMinReservePercentage); } The combination of setting minimum percentage to zero, and allowing withdraw if leaving at least minimum percentage, allows owner to instantly steal the entire reserve held in Collateral.sol.
Impact Compromised manager + Withdraw hook manager can steal entire Collateral.sol reserves.
Tools Used Manual audit
Recommended Mitigation Steps Don't allow minReservePercentage to drop below a constant high number.
#0 - c4-judge
2022-12-17T09:48:34Z
Picodes marked the issue as duplicate of #254
#1 - c4-judge
2023-01-07T18:33:03Z
Picodes marked the issue as satisfactory
🌟 Selected for report: trustindistrust
381.9231 USDC - $381.92
In collateral deposit() and withdraw() flow, a fee is calculated as a percentage of user's requested amount. It is passed to the DepositHook and WithdrawHook, for example in deposit():
uint256 _amountAfterFee = _amount - _fee; if (address(depositHook) != address(0)) { baseToken.approve(address(depositHook), _fee); // hook will calculate fee = _amount - _amountAfterFee depositHook.hook(_recipient, _amount, _amountAfterFee); baseToken.approve(address(depositHook), 0); }
In the DepositHook, if there is a fee two actions take place:
uint256 _fee = _amountBeforeFee - _amountAfterFee; if (_fee > 0) { collateral.getBaseToken().transferFrom(address(collateral), _treasury, _fee); _tokenSender.send(_sender, _fee); }
The issue is that TokenSender has an early exit problem:
function send(address recipient, uint256 unconvertedAmount) external override onlyAllowedMsgSenders { uint256 scaledPrice = (_price.get() * _priceMultiplier) / MULTIPLIER_DENOMINATOR; if (scaledPrice <= _scaledPriceLowerBound) return; uint256 outputAmount = (unconvertedAmount * _outputTokenDecimalsFactor) / scaledPrice; if (outputAmount == 0) return; // EARLY EXIT PROBLEM if (outputAmount > _outputToken.balanceOf(address(this))) return; _outputToken.transfer(recipient, outputAmount); }
In the code above, note that if the TokenSender does not currently have enough reward tokens to hand out, it will simply return successfully from the call. Therefore, user which assumed they will be getting cash back rewards from fees when depositing or withdrawing, are actually paying the fees with no compensation.
Permanent freeze of yield when TokenSender rewards bank is depleted and deposit or withdraw is called.
Manual audit
The root cause is that there is no differentiation between user's request to mint expecting rewards, and user's request to mint in any case. This lack of acknoledgement means user may be left under-compensated.
It is very advisable to add "forfeitRewards" flag, which is required to be true when TokenSender is not able to satisfy the reward owings to user.
Permanent freezing of unclaimed yield is always rated as High severity on Immunefi bounties.
#0 - Picodes
2022-12-13T20:12:01Z
Indeed, it would be good to add a flag or some parameter called minimumCompensationExpected
.
However, I do not agree that this falls in the "Permanent freezing of unclaimed yield" category. From my perspective, this part of the code does not promise users anything. Instead, there is eventually a fee rebate if there are still PPO to send. Therefore, the additional parameter would only be an additional safety check for the user.
#1 - c4-judge
2022-12-13T20:13:12Z
Picodes changed the severity to 2 (Med Risk)
#2 - c4-judge
2022-12-14T07:40:22Z
Picodes marked the issue as primary issue
#3 - c4-sponsor
2022-12-19T22:56:26Z
ramenforbreakfast marked the issue as sponsor disputed
#4 - ramenforbreakfast
2022-12-19T22:57:51Z
Yes, i agree that to categorize this under "Permanent freezing of unclaimed yield", since this is just an additional rebate reward given to the user. It is within the contract documentation that this is expected behavior and in our front end we will be very clear on the lack of a rebate if the TokenSender
contract does not have funds to issue one.
We do not want a situation where the rebate contract has exhausted its PPO
and the platform is halted because of it.
#5 - c4-judge
2023-01-07T11:41:35Z
Picodes marked the issue as duplicate of #257
#6 - c4-judge
2023-01-07T15:13:54Z
Picodes marked the issue as satisfactory