Skip links
mushroom-logo

Mushroom Finance Smart Contract Audit

 

Introduction

CoinFabrik was asked to audit the contracts for the Mushroom project. First we will provide a summary of our discoveries and then we will show the details of our findings.

Summary

The contracts audited are from the Mushroom project. The audit is based on the deployed addresses and the code verified on Etherscan block explorer.

Contracts

The audited contracts are:

File Address
ControllerV3.sol 0x4bf5059065541a2b176500928e91fbfd0b121d07
MasterChef.sol 0xf8873a6080e8dbf41ada900498de0951074af577
MMToken.sol 0xa283aa7cfbb27ef0cfbcb2493dd9f4330e0fd304
MMVault.sol 0x23b197dc671a55f256199cf7e8bee77ea2bdc16d
StrategyCmpdUsdcV1.sol 0x8f288a56a6c06ffc75994a2d46e84f8bda1a0744
StrategyCurve3CRVv1.sol 0x1f11055eb66f2bba647fb1adc64b0dd4e0018de7

The following contracts are out of the scope:

File Address
OneSplitAudit.sol 0xc586bef4a0992c495cf22e1aeee4e446cecdee0e
Timelock.sol 0x5dae9b27313670663b34ac8bffd18825bb9df736

For contracts out of scope we assume they will behave according to their interfaces and that malicious parties cannot alter their behavior.

Description

The Mushroom contracts are a fork of the Yearn contracts to which the MasterChef and Flash loan functionalities were added.

We used the contracts descriptions from the Yearn documentation.

MasterChef.sol:

The MasterChef contract allows the user to stake MM , LP tokens, or MTokens and returns the MM tokens to the user during the time the tokens where staked. The MM tokens are used for governance.

ControllerV3.sol:

The Controller acts as the gatekeeping interface between vaults and strategies and oversees communication and fund flows. Deposits and withdrawals in and out of strategies flow through the Controller. It keeps track of the addresses for the active vaults, strategies, tokens, and strategy rewards destination, acting as a pseudo-registry that verifies the origin and destination of a transaction. The Controller also handles strategy migration, moving funds from an old strategy to a new one.

MMVault.sol:

Vaults act as the representation of the user in the system, and is the internal customer for investments. There is one vault per deposit token, and they are agnostic to the strategies they interact with.

Their primary tasks are to:

  • Process user deposits and withdrawals: minting or burning LP tokens as receipts for these;
  • Manage disposable funds: Ensuring there is enough to satisfy the minimum amount available to handle withdrawals, and issuing withdrawal requests from strategies when more funds need to be added; and
  • Deposit funds into strategies: When there is a surplus of funds in the vault above what’s required to be kept at disposal.

Strategies:

Strategies are investment instruction sets, written by a Strategist. They are agnostic to the vaults that use them. In this audit we will analyze two strategies:

  • CmpdUsdcV1.sol:
    • USDC tokens are supplied to Compound to earn interest and COMP tokens.
    • USDC tokens are borrowed to be reinvested and earn more COMP tokens.
    • COMP tokens are traded at Uniswap for USDC tokens.
    • USDC tokens are reinvested in Compound.
  • Curve3CRVV1.sol:
    • 3CRV tokens are supplied to Curve to earn CRV tokens.
    • CRV tokens are traded at Uniswap for DAI, USDC or USDT.
    • That stablecoin is then supplied to the tri-pool in exchange for 3CRV tokens.
    • 3CRV tokens are reinvested in Curve.

Main Contracts

Note: The dashed lines indicate composition and the solid lines indicate inheritance.

Controller

Strategies

Analyses

The following analyses were performed:

  • Misuse of the different call methods
  • Integer overflow errors
  • Division by zero errors
  • Outdated version of Solidity compiler
  • Front running attacks
  • Reentrancy attacks
  • Misuse of block timestamps
  • Softlock denial of service attacks
  • Functions with excessive gas cost
  • Missing or misused function qualifiers
  • Needlessly complex code and contract interactions
  • Poor or nonexistent error handling
  • Failure to use a withdrawal pattern
  • Insufficient validation of the input parameters
  • Incorrect handling of cryptographic signatures

Detailed findings

Severity Classification

Security risks are classified as follows:

  • Critical: These are issues that we manage to exploit. They compromise the system seriously. They must be fixed immediately.
  • Medium: These are potentially exploitable issues. Even though we did not manage to exploit them or their impact is not clear, they might represent a security risk in the near future. We suggest fixing them as soon as possible.
  • Minor: These issues represent problems that are relatively small or difficult to take advantage of but can be exploited in combination with other issues. These kinds of issues do not block deployments in production environments. They should be taken into account and be fixed when possible.
  • Enhancement: These kinds of findings do not represent a security risk. They are best practices that we suggest to implement.

This classification is summarized in the following table:

SEVERITY EXPLOITABLE ROADBLOCK TO BE FIXED
Critical Yes Yes Immediately
Medium In the near future Yes As soon as possible
Minor Unlikely No Eventually
Enhancement No No Eventually

Issues Found by Severity

Critical severity

Denial of Service attack on Compound strategy withdrawals

In the withdraw function the balance of USDC is calculated and compared with the amount intended to be withdrawn. In case the balance is insufficient, the withdrawSome function is called with the difference, amount – balance, as parameter.

function withdraw(uint256 _amount) external {
    require(msg.sender == controller, "!controller");
    uint256 _balance = IERC20(want).balanceOf(address(this));
    if (_balance < _amount) {
        _amount = _withdrawSome(_amount.sub(_balance));
        _amount = _amount.add(_balance);
    }

Then, the _withdrawSome function should free up that difference in the borrowed amount for reaching the intended balance asked to be withdrawn.

Instead it asks for USDC balance, compares it with the _amount parameter and makes the subtraction again, mistakenly assuming that is the total amount and not the difference.

function _withdrawSome(uint256 _amount)internal override returns (uint256) {
    uint256 _want = balanceOfWant();
    if (_want < _amount) {
        uint256 _redeem = _amount.sub(_want);

As a consequence the strategy contract will have insufficient funds and the safeTransfer to the vault will revert.

This only happens when the withdrawal amount is greater than the vault USDC balance plus the strategy USDC balance.

In order to make an effective attack, a malicious actor would just need to send some USDC to the CmpdUsdcV1 contract causing withdrawals (greater than vault’s balance) to fail. 

However withdrawals will only fail as long as there is some USDC in the contract, the moment those funds get deposited in Compound withdrawals resumes functioning normally.

One more permanent solution we propose is to follow the same semantics used in other strategies in which the _amount parameter in _withdrawSome is the amount to be redeemed.

function _withdrawSome(uint256 _amount)
    internal
    override
    returns (uint256)
{
    // -- CoinFabrik: save initial balance --
    uint256 _balance = balanceOfWant();
    uint256 _redeem = _amount;

    // Make sure market can cover liquidity
    require(ICToken(cusdc).getCash() >= _redeem, "!cash-liquidity");

    // How much borrowed amount do we need to free?
    uint256 borrowed = getBorrowed();
    uint256 supplied = getSupplied();
    uint256 curLeverage = getCurrentLeverage();
    uint256 borrowedToBeFree = _redeem.mul(curLeverage).div(1e18);

    // If the amount we need to free is > borrowed
    // Just free up all the borrowed amount
    if (borrowedToBeFree > borrowed) {
     	  this.deleverageToMin();
    } else {
       // Otherwise just keep freeing up borrowed amounts until
       // we hit a safe number to redeem our underlying
       this.deleverageUntil(supplied.sub(borrowedToBeFree));
    }

    // Redeems underlying
    require(ICToken(cusdc).redeemUnderlying(_redeem) == 0, "!redeem");


    // -- CoinFabrik: calculate tokens redeemed --
    uint256 _reedemed = balanceOfWant();
    _reedemed = _reedemed.sub(_balance);


    return _reedemed;
}

This solution was implemented and the issue was fixed in the new version of the smart contract.

StrategyCmpdUsdcV1.sol     at    0x1a2AAf3bDfce246c6d2F9d93bEe2C649EBE2C32F

Medium severity

No medium severity issues have been found.

Minor severity

Ignoring possible errors returned by external calls

In the function getSuppliedView at StrategyCmpdUsdcV1 the possible error code returned by a call to ICToken.getAccountSnapshot is ignored.

function getSuppliedView() public view returns (uint256) {
     (, uint256 cTokenBal, , uint256 exchangeRate) = ICToken(cusdc)
            .getAccountSnapshot(address(this));

The signature of function getAccountSnapshot at USDC contract indicates that the first parameter returned is an error code, which is ignored by the call from getSuppliedView. 

/**
 * @notice Get a snapshot of the account's balances, and the cached exchange rate
 * @dev This is used by comptroller to more efficiently perform liquidity checks.
 * @param account Address of the account to snapshot
 * @return (possible error, token balance, borrow balance, exchange rate mantissa)
 */
function getAccountSnapshot(address account) external view returns (uint, uint, uint, uint) {

Similarly, other calls to external functions that may return an invalid value are ignored. For example, in function getMarketColFactor the first parameter from the call to Comptroller is ignored

function getMarketColFactor() public view returns (uint256) {
        (, uint256 colFactor) = IComptroller(comptroller).markets(cusdc);

        return colFactor;
    }

The public getter markets return a struct whose first parameter indicates if the parameter is valid

struct Market {
    /// @notice Whether or not this market is listed
    bool isListed;

    /**
     * @notice Multiplier representing the most one can borrow against their collateral in this market.
     *  For instance, 0.9 to allow borrowing 90% of collateral value.
     *  Must be between 0 and 1, and stored as a mantissa.
     */
    uint collateralFactorMantissa;

    [..]
}

/**
 * @notice Official mapping of cTokens -> Market metadata
 * @dev Used e.g. to determine if a market is supported
 */
mapping(address => Market) public markets;

These calls to external functions are invoked from view functions that cannot change the contract’s internal state. But, it can affect other contracts that invoke those public functions and rely on the values returned.

We suggest to always check the error codes returned by external calls. 

Enhancements

Compiler version not fixed

The solidity files use semantic version to indicate they support any solc version above 0.6.7.

It is recommended to explicitly set a fixed version to minimize changes introduced by future untested versions.

pragma solidity 0.6.7;

Some require statements can be changed to modifiers

The following require statement can be seen in many functions through the code:

require(msg.sender == governance, "!governance");

The functions using them can be refactored to use a modifier instead

modifier onlyGovernance {
      require(msg.sender == governance, "!governance");
	_;
}

[··]
function setDevFund(address _devfund) public onlyGovernance {
    	devfund = _devfund;
}

The same can be done with the following require statement

require(msg.sender == strategist || msg.sender == governance);

Like this

modifier onlyGovernanceOrStrategist {
      require(msg.sender == strategist || msg.sender == governance);
	_;
}

[··]
function inCaseTokensGetStuck(address _token, uint _amount) public onlyGovernanceOrStrategist {
    	 IERC20(_token).safeTransfer(msg.sender, _amount);
}

Reduce deployment gas in ControllerV3

In the function earn at ControllerV3 there are calls to safeTransfer in both branches of an if statement.

function earn(address _token, uint256 _amount) public {
    [..]
    if (_want != _token) {
        [..]
        IERC20(_want).safeTransfer(_strategy, _amount);
    } else {
        IERC20(_token).safeTransfer(_strategy, _amount);
    }
    IStrategy(_strategy).deposit();
}

These calls can be merged together since both calls are exactly the same. This change will slightly reduce the cost of deployment without altering the runtime behavior.


function earn(address _token, uint256 _amount) public {
    [..]
    if (_want != _token) {
        [..]
    }
    IERC20(_want).safeTransfer(_strategy, _amount);
    IStrategy(_strategy).deposit();
}

Use existing function balanceOfWant

Several functions withdraw, withdrawAll, harvest and deposit at StrategyCmpdUsdcV1 retrieve the balance calling directly to want contract.

IERC20(want).balanceOf(address(this))

This code can be replaced by a call to balanceOfWant that does exactly the same.

function balanceOfWant() public view returns (uint256) {
    return IERC20(want).balanceOf(address(this));
}

For example the function deposit will look like this.

function deposit() public override {
    // -- CoinFabrik: use balanceOfWant --
    uint256 _want = balanceOfWant();

    if (_want > 0) {
        IERC20(want).safeApprove(cusdc, 0);
        IERC20(want).safeApprove(cusdc, _want);
        require(ICToken(cusdc).mint(_want) == 0, "!deposit");
    }
}

Observations

Privileged Accounts are EOA

The owner and governance addresses are privileged accounts that can make significant modifications to some of the contracts parameters. In the deployed contracts they are External Owned Accounts, meaning they are controlled by a private key,  which creates a possible single point of failure. In the hypothetical case that the private key associated falls in the wrong hands it can cause significant damage to the project.

We suggest using a multisig account to minimize risks until proper governance contracts are developed.

Conclusion

The contracts are simple but interactions with other contracts are complex. They are based on other well known projects like Yearn Finance. The project technical documentation is not abundant.

We found a critical issue in the StrategyCmpdUsdcV1 contract that might cause denial of service to the withdrawals. We suggest a solution and urge the team to take measures before it is exploited.

Another concern is using EOA for governance and ownership. An easy solution until governance contracts are developed is to use a multisig account and split the responsibility among several members of the Mushroom Finance project.

We found one critical issue plus a minor one. They do not present a risk of losing funds. The issues could be fixed using the solutions we proposed. We also suggest several enhancements that will not affect the contract security.

Disclaimer: This audit report is not a security warranty, investment advice, or an approval of the Mushroom Finance project since CoinFabrik has not reviewed its platform. Moreover, it does not provide a smart contract code faultlessness guarantee.