Mochi contest - WatchPug's results

Next-Gen Decentralized Digital Currency Backed By Long-Tail Cryptoassets.

General Information

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

Mochi

Findings Distribution

Researcher Performance

Rank: 2/15

Findings: 9

Award: $13,651.80

🌟 Selected for report: 12

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: jonah1005

Also found by: WatchPug

Labels

bug
duplicate
3 (High Risk)

Awards

0.4373 ETH - $1,820.32

External Links

Handle

WatchPug

Vulnerability details

In the current implementation, new borrows will be charged a 0.5% interest right away. Making the borrower be recorded a 100.5% amount of debt.

However, when a position got liquidated, the unrealized interest may still remain in the position while the collateral already got seized because triggerLiquidation will only reduce the debt of the position by the amount of vault.currentDebt(_nftId), and currentDebt excludes the unrealized part of the pre-charged interest.

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/liquidator/DutchAuctionLiquidator.sol#L69-L76

function triggerLiquidation(address _asset, uint256 _nftId)
    external
    override
{
    IMochiVault vault = engine.vaultFactory().getVault(_asset);
    Auction storage auction = auctions[auctionId(_asset, _nftId)];
    require(auction.startedAt == 0 || auction.boughtAt != 0, "on going");
    uint256 debt = vault.currentDebt(_nftId);

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/vault/MochiVault.sol#L79-L83

function currentDebt(uint256 _id) public view override returns (uint256) {
        require(details[_id].status != Status.Invalid, "invalid");
        uint256 newIndex = liveDebtIndex();
        return (details[_id].debt * newIndex) / details[_id].debtIndex;
    }

Furthermore, when this happens, the position will actually remain liquidatable unexpectedly and can be liquidated again. It will be pretty much just a waste of gas through.

PoC

Given:

  • A fresh vault was created;
  • The collateral factor of the asset is 80%;
  • Alice deposited $1250 worth of collateral;
  • Alice borrowed a 1000 USDM loan, the debt of the position is now 1005 USDM;

If the price of the collateral asset goes down, and Alice's position got liquidated right after.

The debt of Alice's position will now be 5 USDM, while all the collateral assets got seized and the collateral is now 0.

If Alice deposits to this position again rather than create a new position, this unfair amount of debt will be added to the position.

Recommendation

Change to:

function triggerLiquidation(address _asset, uint256 _nftId)
    external
    override
{
    IMochiVault vault = engine.vaultFactory().getVault(_asset);
    Auction storage auction = auctions[auctionId(_asset, _nftId)];
    require(auction.startedAt == 0 || auction.boughtAt != 0, "on going");
    uint256 currentDebt = vault.currentDebt(_nftId);
    (, uint256 collateral, uint256 debt, , ) = vault.details(_nftId);

    vault.liquidate(_nftId, collateral, debt);

    auction.nftId = _nftId;
    auction.vault = address(vault);
    auction.startedAt = block.number;
    auction.boughtAt = 0;
    auction.collateral = collateral;
    auction.debt = currentDebt;

    uint256 liquidationFee = currentDebt.multiply(
        engine.mochiProfile().liquidationFee(address(_asset))
    );
    emit Triggered(auctionId(_asset, _nftId), currentDebt + liquidationFee);
}

#0 - r2moon

2021-10-29T16:53:13Z

#1 - ghoul-sol

2021-11-02T01:19:03Z

duplicate of #72

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

0.9718 ETH - $4,045.16

External Links

Handle

WatchPug

Vulnerability details

distributeMochi() will call _buyMochi() to convert mochiShare to Mochi token and call _shareMochi() to send Mochi to vMochi Vault and veCRV Holders. It wont touch the treasuryShare.

However, in the current implementation, treasuryShare will be reset to 0. This is unexpected and will cause the protocol fee can not be properly accounted for and collected.

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/feePool/FeePoolV0.sol#L79-L95

function _shareMochi() internal {
    IMochi mochi = engine.mochi();
    uint256 mochiBalance = mochi.balanceOf(address(this));
    // send Mochi to vMochi Vault
    mochi.transfer(
        address(engine.vMochi()),
        (mochiBalance * vMochiRatio) / 1e18
    );
    // send Mochi to veCRV Holders
    mochi.transfer(
        crvVoterRewardPool,
        (mochiBalance * (1e18 - vMochiRatio)) / 1e18
    );
    // flush mochiShare
    mochiShare = 0;
    treasuryShare = 0;
}

Impact

Anyone can call distributeMochi() and reset treasuryShare to 0, and then call updateReserve() to allocate part of the wrongfuly resetted treasuryShare to mochiShare and call distributeMochi().

Repeat the steps above and the treasuryShare will be consumed to near zero, profits the vMochi Vault holders and veCRV Holders. The protocol suffers the loss of funds.

Recommendation

Change to:

function _buyMochi() internal {
    IUSDM usdm = engine.usdm();
    address[] memory path = new address[](2);
    path[0] = address(usdm);
    path[1] = address(engine.mochi());
    usdm.approve(address(uniswapRouter), mochiShare);
    uniswapRouter.swapExactTokensForTokens(
        mochiShare,
        1,
        path,
        address(this),
        type(uint256).max
    );
    // flush mochiShare
    mochiShare = 0;
}

function _shareMochi() internal {
    IMochi mochi = engine.mochi();
    uint256 mochiBalance = mochi.balanceOf(address(this));
    // send Mochi to vMochi Vault
    mochi.transfer(
        address(engine.vMochi()),
        (mochiBalance * vMochiRatio) / 1e18
    );
    // send Mochi to veCRV Holders
    mochi.transfer(
        crvVoterRewardPool,
        (mochiBalance * (1e18 - vMochiRatio)) / 1e18
    );
}

Findings Information

🌟 Selected for report: WatchPug

Also found by: pauliax

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

0.4373 ETH - $1,820.32

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/feePool/ReferralFeePoolV0.sol#L28-L42

function claimRewardAsMochi() external {
    IUSDM usdm = engine.usdm();
    address[] memory path = new address[](2);
    path[0] = address(usdm);
    path[1] = uniswapRouter.WETH();
    path[2] = address(engine.mochi());
    usdm.approve(address(uniswapRouter), reward[msg.sender]);
    // we are going to ingore the slippages here
    uniswapRouter.swapExactTokensForTokens(
        reward[msg.sender],
        1,
        path,
        address(this),
        type(uint256).max
    );

In ReferralFeePoolV0.sol#claimRewardAsMochi(), path is defined as an array of length 2 while it should be length 3.

As a result, at L33, an out-of-bound exception will be thrown and revert the transaction.

Impact

claimRewardAsMochi() will not work as expected so that all the referral fees cannot be claimed but stuck in the contract.

Findings Information

🌟 Selected for report: harleythedog

Also found by: WatchPug

Labels

bug
duplicate
3 (High Risk)

Awards

0.4373 ETH - $1,820.32

External Links

Handle

WatchPug

Vulnerability details

MochiVault.sol#withdraw() is using the wait() modifier to prevent withdraw within delay duration from lastDeposit.

However, MochiVault.sol#deposit() allows anyone to deposit to a specific position. This enables the attacker to initiate a griefing attack by depositing 0 to the target's position and preventing the target to withdraw from the position for engine.mochiProfile().delay().

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/vault/MochiVault.sol#L47-L54

modifier wait(uint256 _id) {
    require(
        lastDeposit[_id] + engine.mochiProfile().delay() <= block.timestamp,
        "!wait"
    );
    accrueDebt(_id);
    _;
}

Recommendation

Consider making MochiVault.sol#deposit() limited to the owner of the positions.

#0 - r2moon

2021-10-29T15:28:26Z

Findings Information

🌟 Selected for report: WatchPug

Also found by: jonah1005

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

0.1312 ETH - $546.10

External Links

Handle

WatchPug

Vulnerability details

In the current implementation, a liquidated position can be used for depositing and borrowing again.

However, if there is a liquidation auction ongoing, even if the position is now liquidatable, the call of triggerLiquidation() will still fail.

The liquidator must settleLiquidation first.

If the current auction is not profitable for the liquidator, say the value of the collateral can not even cover the gas cost, the liquidator may be tricked and not liquidate the new loan at all.

Considering if the liquidator bot is not as small to handle this situation (take the profit of the new liquidation and the gas cost loss of the current auction into consideration), a malicious user can create a dust amount position trigger the liquidation by themself.

Since the collateral of this position is so small that it can not even cover the gas cost, liquidators will most certainly ignore this auction.

The malicious user will then deposit borrow the actual loan.

When this loan becomes liquidatable, liquidators may:

  1. confuse the current dust auction with the liquidatable position;
  2. unable to proceed with such a complex liquidation.

As a result, the malicious user can potentially escape liquidation.

Recommendation

Consider making liquidated positions unable to be used (for depositing and borrowing) again.

Findings Information

🌟 Selected for report: loop

Also found by: WatchPug, cmichel, defsec, gzeon, leastwood, nikitastupin, pants

Labels

bug
duplicate
2 (Med Risk)

Awards

0.0174 ETH - $72.55

External Links

Handle

WatchPug

Vulnerability details

ERC20 implementations are not always consistent. Some implementations of transfer could return ‘false’ on failure instead of reverting. It is safer to wrap such calls into require() statements to these failures. Unsafe transfer calls were found in the following locations:

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-core/contracts/liquidator/DutchAuctionLiquidator.sol#L107-L107

asset.transfer(msg.sender, _collateral);

Recommendation

Check the return value and revert on 0/false or use OpenZeppelin’s SafeERC20 wrapper functions.

Findings Information

🌟 Selected for report: nikitastupin

Also found by: WatchPug, cmichel, defsec, leastwood

Labels

bug
duplicate
2 (Med Risk)

Awards

0.0383 ETH - $159.24

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-10-mochi/blob/8458209a52565875d8b2cefcb611c477cefb9253/projects/mochi-cssr/contracts/adapter/ChainlinkAdapter.sol#L43-L65

function getPrice(address _asset)
        public
        view
        override
        returns (float memory)
    {
        (, int256 price, , , ) = feed[_asset].latestRoundData();
        uint256 decimalSum = feed[_asset].decimals() +
            IERC20Metadata(_asset).decimals();
        if (decimalSum > 18) {
            return
                float({
                    numerator: uint256(price),
                    denominator: 10**(decimalSum - 18)
                });
        } else {
            return
                float({
                    numerator: uint256(price) * 10**(18 - decimalSum),
                    denominator: 1
                });
        }
    }

On ChainlinkAdapter.sol, we are using latestRoundData, but there is no check if the return value indicates stale data. This could lead to stale prices according to the Chainlink documentation:

Recommendation

Consider adding missing checks for stale data.

For example:

(uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound) = feed[_asset].latestRoundData();
require(price > 0, "Chainlink price <= 0"); 
require(answeredInRound >= roundID, "Stale price");
require(timeStamp != 0, "Round not complete");

#0 - r2moon

2021-10-27T15:31:46Z

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter