Platform: Code4rena
Start Date: 24/07/2023
Pot Size: $100,000 USDC
Total HM: 18
Participants: 73
Period: 7 days
Judge: alcueca
Total Solo HM: 8
Id: 267
League: ETH
Rank: 6/73
Findings: 2
Award: $5,235.33
๐ Selected for report: 1
๐ Solo Findings: 1
๐ Selected for report: said
5190.4455 USDC - $5,190.45
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L1263-L1273 https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L358 https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L402
Users can prevent themselves from being liquidated by entering another market that has low supply/borrow activity or have low/volatile value compared to other markets, the detailed scenario will be explained in PoC.
The scenario :
_addReserves
in market B so that totalReserves
is bigger than totalCash
+ totalBorrows
.liquidateBorrow
:https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MErc20.sol#L139-L142
function liquidateBorrow(address borrower, uint repayAmount, MTokenInterface mTokenCollateral) override external returns (uint) { (uint err,) = liquidateBorrowInternal(borrower, repayAmount, mTokenCollateral); return err; }
It will eventually trigger comptroller's liquidateBorrowAllowed
hook :
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L970
function liquidateBorrowFresh(address liquidator, address borrower, uint repayAmount, MTokenInterface mTokenCollateral) internal returns (uint, uint) { /* Fail if liquidate not allowed */ uint allowed = comptroller.liquidateBorrowAllowed(address(this), address(mTokenCollateral), liquidator, borrower, repayAmount); if (allowed != 0) { return (failOpaque(Error.COMPTROLLER_REJECTION, FailureInfo.LIQUIDATE_COMPTROLLER_REJECTION, allowed), 0); } ... }
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Comptroller.sol#L394-L424
Inside liquidateBorrowAllowed
hook, it will eventually call getHypotheticalAccountLiquidityInternal
that will loop trough Alice entered markets/assets and call getAccountSnapshot
to get token balance, borrow, and exchangeRate :
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/Comptroller.sol#L578
function getHypotheticalAccountLiquidityInternal( address account, MToken mTokenModify, uint redeemTokens, uint borrowAmount) internal view returns (Error, uint, uint) { AccountLiquidityLocalVars memory vars; // Holds all our calculation results uint oErr; // For each asset the account is in MToken[] memory assets = accountAssets[account]; for (uint i = 0; i < assets.length; i++) { MToken asset = assets[i]; // Read the balances and exchange rate from the mToken (oErr, vars.mTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account); if (oErr != 0) { // semi-opaque error code, we assume NO_ERROR == 0 is invariant between upgrades return (Error.SNAPSHOT_ERROR, 0, 0); } ... }
But getAccountSnapshot
will result in error when try to calculate exchangeRateStoredInternal
because totalReserves
bigger than totalCash
+ totalBorrows
.
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L357-L361
function exchangeRateStoredInternal() virtual internal view returns (MathError, uint) { uint _totalSupply = totalSupply; if (_totalSupply == 0) { /* * If there are no tokens minted: * exchangeRate = initialExchangeRate */ return (MathError.NO_ERROR, initialExchangeRateMantissa); } else { /* * Otherwise: * exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply */ uint totalCash = getCashPrior(); uint cashPlusBorrowsMinusReserves; Exp memory exchangeRate; MathError mathErr; // @audit - this will revert if totalReservers > totalBorrow + totalCash (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, _totalSupply); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } return (MathError.NO_ERROR, exchangeRate.mantissa); } }
Also this scenario will prevent _reduceReserves
because when try to accrueInterest
, it will also fail due to the same condition (totalReserves
bigger than totalCash
+ totalBorrows
) .
https://github.com/code-423n4/2023-07-moonwell/blob/main/src/core/MToken.sol#L402
Now Alice can't be liquidated.
this scenario is profitable when market B has lower token value compared to market A and has low supply/borrow activity, (shortfall of Alice on market A has bigger value than the donated reserves value to market B).
Manual Review
Consider to restrict add reserves function, or actively monitor and remove market of token that have low/volatile value or low borrow and supply activity.
Other
#0 - c4-pre-sort
2023-08-03T13:59:01Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T22:03:25Z
this can be quickly fixed by having the admin pull funds
#2 - c4-sponsor
2023-08-03T22:03:43Z
ElliotFriedman marked the issue as disagree with severity
#3 - c4-sponsor
2023-08-03T22:04:15Z
ElliotFriedman marked the issue as sponsor confirmed
#4 - alcueca
2023-08-12T20:46:35Z
this can be quickly fixed by having the admin pull funds.
Hopefully not through governance. That wouldn't be quick.
#5 - c4-judge
2023-08-12T20:46:43Z
alcueca marked the issue as satisfactory
#6 - c4-judge
2023-08-21T21:39:07Z
alcueca marked the issue as selected for report
#7 - ElliotFriedman
2023-10-02T18:33:29Z
Here is a PoC proving this is not an issue: https://github.com/moonwell-fi/moonwell-contracts-v2/pull/68
it's never possible for
totalReserves
to be bigger than totalCash
+ totalBorrow
because when adding funds to totalReserves, it also increases cash.
invariant totalReserves
<= totalCash
+ totalBorrow
should always hold as funds added to reserves are counted as cash until they are borrowed. so even if a market had 0 balance of tokens, which it never will because gov proposals mint an initial amount and then send some of those tokens to address 0, effectively burning them, once the funds are borrowed, they move from cash to borrows, meaning even if all funds in a market were donated by adding to reserves, you couldn't never get that line to revert.
this should be downgraded to not an issue and removed from the report.
#8 - alcueca
2023-10-03T05:33:02Z
I think that you are right. I was fooled by your own previous confirmation and by the apparently thorough nature of the report. In a more careful dissection, after removing the long-winded trip through liquidateBorrow
, we are left with:
๐ Selected for report: immeas
Also found by: 0x70C9, 0xAnah, 0xArcturus, 0xComfyCat, 0xWaitress, 0xackermann, 0xkazim, 2997ms, 33audits, Arz, Aymen0909, ChrisTina, JP_Courses, John_Femi, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Nyx, Rolezn, Sathish9098, Stormreckson, T1MOH, Tendency, Topmark, Udsen, Vagner, albertwh1te, ast3ros, banpaleo5, berlin-101, catellatech, cats, codetilda, cryptonue, eeshenggoh, fatherOfBlocks, hals, jamshed, jaraxxus, josephdara, kankodu, kodyvim, kutugu, lanrebayode77, mert_eren, nadin, naman1778, niki, petrichor, ravikiranweb3, said, solsaver, souilos, twcctop, wahedtalash77
44.8793 USDC - $44.88
If users owned high borrower and supplier accrued rewards, it is potentially hard to claim, due to bad handling when sendReward
is called, potentially punishing big rewards holders.
inside sendReward
, when _amount
is higher than currentTokenHoldings
, it will not send the reward to user, just emit event and return the _amount
back :
function sendReward( address payable _user, uint256 _amount, address _rewardToken ) internal nonReentrant returns (uint256) { // Short circuit if we don't have anything to send out if (_amount == 0) { return _amount; } // If pause guardian is active, bypass all token transfers, but still accrue to local tally if (paused()) { return _amount; } IERC20 token = IERC20(_rewardToken); // Get the distributor's current balance uint256 currentTokenHoldings = token.balanceOf(address(this)); // Only transfer out if we have enough of a balance to cover it (otherwise just accrue without sending) if (_amount > 0 && _amount <= currentTokenHoldings) { // Ensure we use SafeERC20 to revert even if the reward token isn't ERC20 compliant token.safeTransfer(_user, _amount); return 0; } else { // @audit - this will make big reward hard to claim // If we've hit here it means we weren't able to emit the reward and we should emit an event // instead of failing. emit InsufficientTokensToEmit(_user, _rewardToken, _amount); // By default, return the same amount as what's left over to send, we accrue reward but don't send them out return _amount; } }
This design has a problem and will punish big rewards holders, because sendReward
is called only when disburseSupplierRewardsInternal
or disburseBorrowerRewardsInternal
is called, which will always process emissionConfig.borrowerRewardsAccrued[_borrower]
and emissionConfig.supplierRewardsAccrued[_supplier]
, not allowing users to put the amount that wants to be claimed.
... if (_sendTokens) { // Emit rewards for this token/pair uint256 unsendableRewards = sendReward( payable(_supplier), emissionConfig.supplierRewardsAccrued[_supplier], emissionConfig.config.emissionToken ); emissionConfig.supplierRewardsAccrued[ _supplier ] = unsendableRewards; } ...
... if (_sendTokens) { // Emit rewards for this token/pair uint256 pendingRewards = sendReward( payable(_borrower), emissionConfig.borrowerRewardsAccrued[_borrower], emissionConfig.config.emissionToken ); emissionConfig.borrowerRewardsAccrued[ _borrower ] = pendingRewards; } } ...
Also, when disburseSupplierRewardsInternal
or disburseBorrowerRewardsInternal
is triggered, potentially update rewards accrued to bigger value and make the claim reward even harder if it is bigger than currentTokenHoldings
.
This will make high accrued rewards harder to claim and punishing users to have high rewards unclaimed.
Manual review
Consider to update sendReward
to :
function sendReward( address payable _user, uint256 _amount, address _rewardToken ) internal nonReentrant returns (uint256) { // Short circuit if we don't have anything to send out if (_amount == 0) { return _amount; } // If pause guardian is active, bypass all token transfers, but still accrue to local tally if (paused()) { return _amount; } IERC20 token = IERC20(_rewardToken); // Get the distributor's current balance uint256 currentTokenHoldings = token.balanceOf(address(this)); // Only transfer out if we have enough of a balance to cover it (otherwise just accrue without sending) if (_amount > 0 && _amount <= currentTokenHoldings) { // Ensure we use SafeERC20 to revert even if the reward token isn't ERC20 compliant token.safeTransfer(_user, _amount); return 0; } else { + token.safeTransfer(_user, currentTokenHoldings); - emit InsufficientTokensToEmit(_user, _rewardToken, _amount); // By default, return the same amount as what's left over to send, we accrue reward but don't send them out - return _amount; + return _amount - currentTokenHoldings; } }
Or have separate functions to allow user claim rewards with the specified amount.
Other
#0 - c4-pre-sort
2023-08-03T13:59:47Z
0xSorryNotSorry marked the issue as primary issue
#1 - ElliotFriedman
2023-08-03T22:16:15Z
non issue and this is in fact expected behavior
#2 - c4-sponsor
2023-08-03T22:16:31Z
ElliotFriedman marked the issue as sponsor disputed
#3 - c4-sponsor
2023-08-03T22:16:35Z
ElliotFriedman marked the issue as disagree with severity
#4 - alcueca
2023-08-12T21:29:48Z
Not a major issue, easily managed.
#5 - c4-judge
2023-08-12T21:30:18Z
alcueca changed the severity to QA (Quality Assurance)
#6 - c4-judge
2023-08-12T21:30:28Z
alcueca marked the issue as grade-a