Platform: Code4rena
Start Date: 21/10/2021
Pot Size: $80,000 ETH
Total HM: 28
Participants: 15
Period: 7 days
Judge: ghoulsol
Total Solo HM: 19
Id: 42
League: ETH
Rank: 4/15
Findings: 9
Award: $8,492.04
๐ Selected for report: 10
๐ Solo Findings: 4
cmichel
The MochiProfileV0
defines liquidation and collateral factors for different asset types.
For the AssetClass.Sigma
type, the liquidation factor is less than the collateral factor:
function liquidationFactor(address _asset) public view override returns (float memory) { AssetClass class = assetClass(_asset); if (class == AssetClass.Sigma) { // } else if (class == AssetClass.Sigma) { return float({numerator: 40, denominator: 100}); } } function maxCollateralFactor(address _asset) public view override returns (float memory) { AssetClass class = assetClass(_asset); if (class == AssetClass.Sigma) { return float({numerator: 45, denominator: 100}); } }
This means that one can take a loan of up to 45% of their collateral but then immediately gets liquidated as the liquidation factor is only 40%. There should always be a buffer between these such that taking the max loan does not immediately lead to liquidations:
A safety buffer is maintained between max CF and LF to protect users against liquidations due to normal volatility. Docs
The max collateral factor for the Sigma type should be higher than its liquidation factor.
cmichel
The contracts are missing slippage checks which can lead to being vulnerable to sandwich attacks.
A common attack in DeFi is the sandwich attack. Upon observing a trade of asset X for asset Y, an attacker frontruns the victim trade by also buying asset Y, lets the victim execute the trade, and then backruns (executes after) the victim by trading back the amount gained in the first trade. Intuitively, one uses the knowledge that someoneโs going to buy an asset, and that this trade will increase its price, to make a profit. The attackerโs plan is to buy this asset cheap, let the victim buy at an increased price, and then sell the received amount again at a higher price afterwards.
See FeePoolV0._buyMochi
:
uniswapRouter.swapExactTokensForTokens( mochiShare, 1, // @audit min return set to zero path, address(this), type(uint256).max );
Also ReferralFeePoolV0.claimRewardAsMochi
:
uniswapRouter.swapExactTokensForTokens( reward[msg.sender], 1, // @audit min return set to zero path, address(this), type(uint256).max );
Same with MochiTreasuryV0._buyCRV
.
Trades can happen at a bad price and lead to receiving fewer tokens than at a fair market price. The attacker's profit is the protocol's loss.
Add minimum return amount checks. Accept a function parameter that can be chosen by the transaction sender, then check that the actually received amount is above this parameter.
Alternatively, check if it's feasible to send these transactions directly to a miner (flashbots) such that they are not visible in the public mempool.
#0 - r2moon
2021-10-29T13:40:08Z
duplicated with https://github.com/code-423n4/2021-10-mochi-findings/issues/62
cmichel
The ERC20.transfer()
and ERC20.transferFrom()
functions return a boolean value indicating success. This parameter should checked for success.
See VestedRewardPool.claim
which performs ERC20 transfers without checking for the return value.
As the trusted mochi
token is used which supposedly reverts on failed transfers, not checking the return value does not lead to any security issues.
We still recommend checking it to abide by the EIP20 standard.
Consider using require(mochi.transfer(msg.sender, vesting[msg.sender].claimable), "transfer failed")
instead.
#0 - r2moon
2021-10-29T15:22:43Z
duplicated with https://github.com/code-423n4/2021-10-mochi-findings/issues/75
๐ Selected for report: nikitastupin
cmichel
There is no check in ChainlinkAdapterEth.getPrice
if the return values indicate stale data. This could lead to stale prices according to the Chainlink documentation:
Stale prices that do not reflect the current market price anymore could be used which would influence the liquidations.
Add the recommended checks:
( uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound ) = feed[_asset].latestRoundData(); require( timeStamp != 0, โChainlinkOracle::getLatestAnswer: round is not completeโ ); require( answeredInRound >= roundID, โChainlinkOracle::getLatestAnswer: stale dataโ ); require(price != 0, "Chainlink Malfunctionโ);
#0 - r2moon
2021-10-29T13:22:19Z
duplicated with https://github.com/code-423n4/2021-10-mochi-findings/issues/87
๐ Selected for report: cmichel
0.2915 ETH - $1,213.55
cmichel
The total debt
in MochiVault.accrueDebt
increases by the current debt
times the debt index growth.
This is correct but the total debt
is then reduced again by the calling user's discounted debt, meaning, the total debt depends on which specific user performs the debt accrual.
This should not be the case.
Assume we have a total debt of 2000
, two users A and B, where A has a debt of 1000, and B has a debt of 100.
The (previous) debtIndex = 1.0
and accruing it now would increase it to 1.1
.
There's a difference if user A or B first does the accrual.
User A calls accrueDebt
: increased = 2000 * 1.1/1.0 - 2000 = 200
. Thus debts
is first set to 2200
. The user's increasedDebt = 1000 * 1.1 / 1.0 - 1000 = 100
and assume a discount of 10%
, thus discountedDebt = 100 * 10% = 10
.
Then debts = 2200 - 10 = 2190
.
The next accrual will work with a total debt of 2190
.
User B calls accrueDebt
: increased = 2000 * 1.1/1.0 - 2000 = 200
. Thus debts
is first set to 2200
. The user's increasedDebt = 100 * 1.1 / 1.0 - 100 = 10
and assume a discount of 10%
, thus discountedDebt = 10 * 10% = 1
.
Then debts = 2200 - 1 = 2199
.
The next accrual will work with a total debt of 2199
, leading to more debt overall.
The total debt of a system depends on who performs the accruals which should ideally not be the case. The discrepancy compounds and can grow quite large if a whale always does the accrual compared to someone with almost no debt or no discount.
Don't use the discounts or track the weighted average discount across all users that is subtracted from the increased total debt each time, i.e., reduce it by the discount of all users (instead of current caller only) when accruing to correctly track the debt.
๐ Selected for report: cmichel
0.2915 ETH - $1,213.55
cmichel
Governance can change the engine.nft
address which is used by vaults to represent collateralized debt positions (CDP).
When minting a vault using MochiVault.mint
the address returned ID will be used and overwrite the state of an existing debt position and set its status to Idle
.
Changing the NFT address will allow overwriting existing CDPs.
Disallow setting a new NFT address. or ensure that the new NFT's IDs start at the old NFT's IDs.
๐ Selected for report: cmichel
0.2915 ETH - $1,213.55
cmichel
The UniswapV2LPAdapter/SushiswapV2LPAdapter.update
function retrieves the underlying
from the LP token pair (_asset
) but then calls router.update(_asset, _proof)
which is the LP token itself again.
This will end up with the router calling this function again recursively.
This function fails as there's an infinite recursion and eventually runs out of gas.
The idea was most likely to update the underlying
price which is used in _getPrice
as uint256 eAvg = cssr.getExchangeRatio(_underlying, weth);
.
Call router.update(underlying, _proof)
instead. Note that the _proof
does not necessarily update the underlying <> WETH
pair, it could be any underlying <> keyAsset
pair.
๐ Selected for report: cmichel
0.2915 ETH - $1,213.55
cmichel
The UniswapV2TokenAdapter.supports
function calls its aboveLiquidity
function which returns the UniswapV2 liquidity if the pair exists.
If this is below minimumLiquidity
, the supports
function will return false
.
However, it could be that the Sushiswap
pair has lots of liquidity and could be used.
try uniswapCSSR.getLiquidity(_asset, _pairedWith) returns ( uint256 liq ) { float memory price = cssrRouter.getPrice(_pairedWith); // @audit this returns early. if it's false it should check sushiswap first return convertToValue(liq, price) >= minimumLiquidity; } catch { try sushiCSSR.getLiquidity(_asset, _pairedWith) returns ( uint256 liq ) { float memory price = cssrRouter.getPrice(_pairedWith); return convertToValue(liq, price) >= minimumLiquidity; } catch { return false; } }
Suppose the UniswapV2TokenAdapter
wants to be used as an adapter for a Sushiswap pool.
An attacker creates a UniswapV2 pool for the same pair and does not provide liquidity.
The Router.setPriceSource
calls UniswapV2TokenAdapter.supports
and returns false as the Uniswap liquidity is too low, without checking the Sushiswap liquidity.
In aboveLiquidity
, if the UniswapV2 liquidity is less than the minimum liquidity, instead of returning, compare the Sushiswap liquidity against this threshold.
cmichel
Some parameters of functions are not checked for invalid values:
MochiEngine.constructor: governance
should be non-zeroWrong user input or wallets defaulting to the zero addresses for a missing input can lead to the contract needing to redeploy or wasted gas.
Validate the parameters.
#0 - r2moon
2021-10-29T15:27:17Z
duplicated with https://github.com/code-423n4/2021-10-mochi-findings/issues/106
๐ Selected for report: cmichel
0.0972 ETH - $404.52
cmichel
The UniswapV2TokenAdapter.addKeyCurrency
does not validate if the currency is already a key currency.
The currency will be pushed to the array multiple times.
The price and liquidity will be double counted as it iterates over the entire keyCurrency
array.
Removing the currency again also does not work correctly as isKeyCurrency
is set to false but the currency is only removed from the array once.
Add a require(!isKeyCurrency[_currency])
check to addKeyCurrency
.
๐ Selected for report: cmichel
cmichel
The NoMochiFeePool
allows anyone to call withdraw
which sends the tokens to withdrawer
.
This variable can however be set to zero, either in the constructor or in the changeWithdrawer
function.
Thus tokens can be accidentally burned by anyone if the withdrawer
is the zero address at any point.
Add a check in withdraw
that the withdrawer != address(0)
to avoid accidentally burning tokens.
๐ Selected for report: cmichel
cmichel
If a flashloan contract does not properly authenticate the USDM
flashloan contract callbacks, anyone can perform a griefing attack which will lead to the caller losing tokens equal to the fees.
This is because the flashloan receiver
is not authenticated and anyone can start flashloans on behalf of another contract.
They don't even need to approve the usdm
contract as it uses internal _burn
and _transfer
functions instead of burnFrom
/transferFrom
.
FlashLoan.flashLoan(receiver=victim, ...)
.receiver
in _loan
.If fees are non-zero, it's possible to drain the victim's balance if their contract is implemented incorrectly without proper authentication checks.
This is an inherent issue with EIP-3156 which defines the interface with an arbitrary receiver
.
Contracts should be aware to revert if the flashloan was not initiated by them.
To mitigate this issue one could use functions that work with explicit approvals from the victim, instead of using internal _burn
and _transfer
functions. This way, the victim must first have approved the tokens for transfer.
๐ Selected for report: cmichel
cmichel
As MochiVault.accrueDebt
function performs unsafe casts: claimable += int256(increased);
.
If the unsigned values are above the maximum signed value (type(int256).max
), it will be interpreted as a negative value instead.
Even though overflowing the max int256
value is unlikely, it's still recommended to use safe casts.
Make sure the value fits into the type first by using a SafeCast library.
cmichel
The MochiVaultProxy.updateTemplate
performs a low-level call to beacon
and does not check the success
return value.
If the beacon
is set to the wrong address and the call fails, it will not be noticeable.
Check for success
for the address(beacon).call(abi.encode(_newTemplate))
call.
#0 - r2moon
2021-10-29T13:27:09Z
duplicated with https://github.com/code-423n4/2021-10-mochi-findings/issues/76
๐ Selected for report: cmichel
0.0972 ETH - $404.52
cmichel
The UniswapV2CSSR.getExchangeRatio
function blindly uses the observedData[lastObserved][address(pair)]
fields when blockState[lastObserved]
was set.
However, it could be that blockState
was set in saveState
but the data for the specific pair
was not set yet through saveReserve
.
The historic data is computed on uninitialized data.
Using a historic price of zero could be used to make liquidating someone easier as their collateral value is lowered.
Note that the computation will luckily fail as there's a division by 0 in calculatedPriceCumulative
.
It's recommended to add further checks to ensure that the observedData[lastObserved][address(pair)]
fields are indeed initialized.