Halborn Logo

EVM Contracts - Moonwell


Prepared by:

Halborn Logo

HALBORN

Last Updated 06/11/2024

Date of Engagement by: January 24th, 2022 - February 1st, 2022

Summary

88% of all REPORTED Findings have been addressed

All findings

8

Critical

0

High

0

Medium

3

Low

3

Informational

2


1. INTRODUCTION

Moonwell Finance engaged Halborn to conduct a security audit on their smart contracts beginning on January 24th, 2022 and ending on February 1st, 2022. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided one week for the engagement and assigned a full-time security engineer to audit the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit is to:

    • Ensure that smart contract functions operate as intended

    • Identify potential security issues with the smart contracts

In summary, Halborn identified some security risks that were solved and addressed by the Moonwell Finance team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the audit:

    • Research into architecture and purpose.

    • Smart Contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions(solgraph)

    • Manual Assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Static Analysis of security for scoped contract, and imported functions.(Slither)

    • Dynamic Analysis (ganache-cli, brownie, hardhat)

4. SCOPE

\begin{enumerate} \item Moonwell Finance Smart Contracts \begin{enumerate} \item Repository: \href{https://github.com/moonwell-fi/contracts-open-source/tree/master/contracts/core}{Moonwell Finance - Moonwell Core} \item Commit ID: \href{https://github.com/moonwell-fi/contracts-open-source/tree/master/contracts/core}{70fb9c4a899ba0cd787855582ea3bd803317f51a} \end{enumerate} \end{enumerate}

FIX Commit ID : e23657c5fbeb12c7393fa49da6f350dc0bd5114e

TAG : apollo-v1

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

3

Low

3

Informational

2

Impact x Likelihood

HAL-02

HAL-03

HAL-01

HAL-06

HAL-07

HAL-08

HAL-04

HAL-05

Security analysisRisk levelRemediation Date
ALLOWING ERC777-KIND TOKENS ON PROTOCOL LEADS RE-ENTRANCYMediumSolved - 02/04/2022
USE OF DEPRECATED CHAINLINK APIMediumSolved - 02/04/2022
ASSETS MAY LOCKED DOWN ON GOVERNORALPHA CONTRACTMediumSolved - 02/04/2022
SHORT CIRCUIT IS NECESSARY FOR GAS OPTIMIZATIONLowNot Applicable
GOVERNORALPHA DOES NOT CONTROL QUEUED PROPOSALS ON CANCEL METHODLowNot Applicable
MISSING ZERO ADDRESS CHECKSLowRisk Accepted
MULTIPLE PRAGMA DEFINITIONInformational-
UNUSED FUNCTION PARAMETERSInformationalSolved - 02/04/2022

8. Findings & Tech Details

8.1 ALLOWING ERC777-KIND TOKENS ON PROTOCOL LEADS RE-ENTRANCY

// Medium

Description

The ERC777 standard allows the token contract to notify senders and recipients when ERC777 tokens are sent or received from their accounts with function hooks. These hooks are called as callbacks. If the recipient of the token is a smart contract, the smart contract may cause to re-entrancy by calling another transfer function.

During the tests, it was seen that the protocol could be affected by this vulnerability if ERC777-kind tokens are planned to be used. This may cause loss of funds.

Code Location

MToken.sol

        /////////////////////////
        // EFFECTS & INTERACTIONS
        // (No safe failures beyond this point)

        /* We write previously calculated values into storage */
        totalSupply = vars.totalSupplyNew;
        accountTokens[redeemer] = vars.accountTokensNew;

        /* We emit a Transfer event, and a Redeem event */
        emit Transfer(redeemer, address(this), vars.redeemTokens);
        emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);

        /* We call the defense hook */
        comptroller.redeemVerify(address(this), redeemer, vars.redeemAmount, vars.redeemTokens);

        /*
         * We invoke doTransferOut for the redeemer and the redeemAmount.
         *  Note: The mToken must handle variations between ERC-20 and GLMR underlying.
         *  On success, the mToken has redeemAmount less of cash.
         *  doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
         */
        doTransferOut(redeemer, vars.redeemAmount);

        return uint(Error.NO_ERROR);
Score
Impact: 3
Likelihood: 4
Recommendation

SOLVED: Moonwell Team solved this issue by implementing Reentrancy Guard and better check-effect-interaction design to Comptroller.sol contract.

Commit ID: e23657c5fbeb12c7393fa49da6f350dc0bd5114e && 762cdc4cd9a8d09f29765f9e143b25af0ebe9720

8.2 USE OF DEPRECATED CHAINLINK API

// Medium

Description

The ChainlinkOracle contract uses Chainlink’s deprecated API latestAnswer(). Such functions might suddenly stop working if Chainlink stopped supporting deprecated APIs. This method will return the last value, but it is possible to check if the data is fresh.

Code Location

ChainlinkOracle.sol

function getChainlinkPrice(AggregatorV2V3Interface feed) internal view returns (uint) {
        // Chainlink USD-denominated feeds store answers at 8 decimals
        uint decimalDelta = uint(18).sub(feed.decimals());
        // Ensure that we don't multiply the result by 0
        if (decimalDelta > 0) {
            return uint(feed.latestAnswer()).mul(10**decimalDelta);
        } else {
            return uint(feed.latestAnswer());
        }
    }
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: This issue was solved by implementing better ChainLink Oracle API call (latestRoundData()).

Commit ID: e23657c5fbeb12c7393fa49da6f350dc0bd5114e && 762cdc4cd9a8d09f29765f9e143b25af0ebe9720.

8.3 ASSETS MAY LOCKED DOWN ON GOVERNORALPHA CONTRACT

// Medium

Description

Eth sent to Timelock will be locked in current implementation.

Code Location

GovernorAlpha.sol

function execute(uint proposalId) external {
        require(state(proposalId) == ProposalState.Queued, "GovernorAlpha::execute: proposal can only be executed if it is queued");
        Proposal storage proposal = proposals[proposalId];
        proposal.executed = true;
        for (uint i = 0; i < proposal.targets.length; i++) {
            timelock.executeTransaction(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta);
        }
        emit ProposalExecuted(proposalId);
    }
  • Set up the governance contracts (GovernanceAlpha, Timelock).
  • Send eth to timelock contract.
  • Set up a proposal to send 0.1 eth out. Code snippet in ether.js below. proxy refers to GovernorAlpha.
    await proxy.propose(
      [signers[3].address],
      [ethers.utils.parseEther("0.1")],
      [""],
      [ethers.BigNumber.from(0)],
      "Send funds to 3rd signer"
    );
  • Vote and have the proposal succeed.
  • Execute the proposal, the proposal number here is arbitrary.
await proxy.execute(2);  // this fails
await proxy.execute(2, {value: ethers.utils.parseEther("0.1")})  // this would work
  • 0.1 eth will be sent out, but it is sent from the msg.sender not from the timelock contract.
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: This issue was solved by removing payable keyword and call.value() method from the execute() function on GovernorAlpha.sol contract.

Commit ID: e23657c5fbeb12c7393fa49da6f350dc0bd5114e && 762cdc4cd9a8d09f29765f9e143b25af0ebe9720.

8.4 SHORT CIRCUIT IS NECESSARY FOR GAS OPTIMIZATION

// Low

Description

If votes variable is equal to zero on GovernorAlpha.sol:_castVote() method contract should short circuit itself to consume less gas. The following lines will be executed even if votes amount is zero.

GovernorAlpha.sol

if (support) {
            proposal.forVotes = add256(proposal.forVotes, votes);
        } else {
            proposal.againstVotes = add256(proposal.againstVotes, votes);
        }

        receipt.hasVoted = true;
        receipt.support = support;
        receipt.votes = votes;

        emit VoteCast(voter, proposalId, support, votes);
    }

Basically, the contract will call more functions such as add256() even it is not necessary.

The same issue also exists on Well.sol:transferFrom() function. If rawAmount parameter is equal to zero, the contract should short-circuit itself to prevent gas consume. The following lines will be executed even if rawAmount is equal to zero.

Well.sol

if (spender != src && spenderAllowance != uint96(-1)) {
            uint96 newAllowance = sub96(spenderAllowance, amount, "Well::transferFrom: transfer amount exceeds spender allowance");
            allowances[src][spender] = newAllowance;

            emit Approval(src, spender, newAllowance);
        }

        _transferTokens(src, dst, amount);
        return true;

Code Location

Vulnerable Functions

GovernorAlpha.sol:_castVote(address voter, uint proposalId, bool support)
Well.sol:transferFrom(address src, address dst, uint rawAmount)
Score
Impact: 1
Likelihood: 3
Recommendation

NOT APPLICABLE: This issue was marked as NOT APPLICABLE since the recommendation does not fit to intended behavior of Compound Protocol. Furthermore, Moonwell Team stay as close to the original contracts as possible, even if they are not completely optimal concerning gas efficiency, so that improvements to the original contracts may be adopted without significant refactoring, and the community can have better certainty that they function similarly to other contracts with the same code.

8.5 GOVERNORALPHA DOES NOT CONTROL QUEUED PROPOSALS ON CANCEL METHOD

// Low

Description

The cancel(uint proposalId) The cancel function is used to cancel the proposals. There is a check on the contract to do not cancel executed proposals. If the state of a proposal is not QUEUED yet, the contract will revert to cancel function. However, it will consume gas to achieve this. There is a missing control on the contract to only cancel QUEUED proposals.

Code Location

GovernorAlpha.sol

function cancel(uint proposalId) public {
        ProposalState state = state(proposalId);
        require(state != ProposalState.Executed, "GovernorAlpha::cancel: cannot cancel executed proposal");

        Proposal storage proposal = proposals[proposalId];
        require(msg.sender == guardian || well.getPriorVotes(proposal.proposer, sub256(block.number, 1)) < proposalThreshold(), "GovernorAlpha::cancel: proposer above threshold");

        proposal.canceled = true;
        for (uint i = 0; i < proposal.targets.length; i++) {
            timelock.cancelTransaction(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta);
        }

        emit ProposalCanceled(proposalId);
    }
Score
Impact: 1
Likelihood: 3
Recommendation

NOT APPLICABLE: This issue was marked as NOT APPLICABLE since the recommendation does not fit to intended behavior of Compound Protocol.

“A proposal is eligible to be cancelled at any time before its execution, including while queued in the Timelock, using this function.”

8.6 MISSING ZERO ADDRESS CHECKS

// Low

Description

Moonwell-Core contracts have multiple input fields on their both public and private functions. Some of these inputs are required as address variable. During the test, it has seen all of these inputs are not protected against using the address(0) as the target address. It is not recommended to use zero address as target addresses on the contracts.

Code Location

Missing Zero Address Checks

ChainlinkOracle.setAdmin(address).newAdmin (contracts/Chainlink/ChainlinkOracle.sol#88)
Comptroller._setBorrowCapGuardian(address).newBorrowCapGuardian (contracts/Comptroller.sol#968)
Comptroller._setPauseGuardian(address).newPauseGuardian (contracts/Comptroller.sol#986)
Comptroller.setWellAddress(address).newWellAddress (contracts/Comptroller.sol#1351)
MErc20.initialize(address,ComptrollerInterface,InterestRateModel,uint256,string,string,uint8).underlying_ (contracts/MErc20.sol#21)
MToken._setPendingAdmin(address).newPendingAdmin (contracts/MToken.sol#1144)
MErc20Delegator.constructor(address,ComptrollerInterface,InterestRateModel,uint256,string,string,uint8,address,address,bytes).admin_ (contracts/MErc20Delegator.sol#31)
MErc20Delegator._setImplementation(address,bool,bytes).implementation_ (contracts/MErc20Delegator.sol#60)
MErc20Immutable.constructor(address,ComptrollerInterface,InterestRateModel,uint256,string,string,uint8,address).admin_ (contracts/MErc20Immutable.sol#29)
MGlimmer.constructor(ComptrollerInterface,InterestRateModel,uint256,string,string,uint8,address).admin_ (contracts/MGlimmer.sol#27)
Reservoir.constructor(uint256,EIP20Interface,address).target_ (contracts/Reservoir.sol#32)
Timelock.constructor(address,uint256).admin_ (contracts/Timelock.sol#26)
Timelock.executeTransaction(address,uint256,string,bytes,uint256).target (contracts/Timelock.sol#81)
Unitroller._setPendingImplementation(address).newPendingImplementation (contracts/Unitroller.sol#38)
Unitroller._setPendingAdmin(address).newPendingAdmin (contracts/Unitroller.sol#85)
Score
Impact: 2
Likelihood: 2
Recommendation

RISK ACCEPTED: The Moonwell Team accepts the risk of this finding. Except the ChainlinkOracle contract, all the contracts mentioned require the new admin key to execute the _acceptPendingAdmin function, which protects against accidental attempts to set the admin or guardian to the zero address. It was decided not to make any changes.

8.7 MULTIPLE PRAGMA DEFINITION

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.8 UNUSED FUNCTION PARAMETERS

// Informational

Description

During the test, it was determined that a variable on the contract was not used for any purpose, although it was defined on the contract. This situation does not pose any risk in terms of security. But it is important for the readability and applicability of the code.

The baseRatePerYear parameter of updateJumpRateModel function on DAIInterestRateModelV3.sol contract is unused on that function.

Code Location

DAIInterestRateModelV3.sol

function updateJumpRateModel(uint baseRatePerYear, uint gapPerYear, uint jumpMultiplierPerYear, uint kink_) external {
        require(msg.sender == owner, "only the owner may call this function.");
        gapPerTimestamp = gapPerYear / timestampsPerYear;
        updateJumpRateModelInternal(0, 0, jumpMultiplierPerYear, kink_);
        poke();
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: This issue has was solved by the Moonwell Team. The DAIInterestRateModel.sol contract is not used by the Moonwell community, so this contract has been removed from the repository.

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

Results

slither1.pngslither2.pngslither3.pngslither4.png

As a result of the tests completed with the Slither tool, some results were obtained and these results were reviewed by Halborn. In line with the reviewed results, it was decided that some vulnerabilities were false-positive and these results were not included in the report. The actual vulnerabilities found by Slither are already included in the findings on the report.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.