GithubHelp home page GithubHelp logo

2023-03-mute-findings's Introduction

Mute.io Contest

Unless otherwise discussed, this repo will be made public after contest completion, sponsor review, judging, and two-week issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Contest findings are submitted to this repo

Sponsors have three critical tasks in the contest process:

  1. Weigh in on severity.
  2. Respond to issues.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Please note: because wardens submit issues without seeing each other's submissions, there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Weigh in on severity

Judges have the ultimate discretion in determining severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criteria.

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

If you disagree with a finding's severity, leave the severity label intact and add the label disagree with severity, along with a comment indicating your opinion for the judges to review. You may also add questions for the judge in the comments.

Respond to issues

Label each open/primary High or Medium risk finding as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

(Note: please don't use sponsor disputed for a finding if you think it should be considered of lower or higher severity. Instead, use the label disagree with severity and add comments to recommend a different severity level -- and include your reasoning.)

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

QA and Gas Reports

For low and non-critical findings (AKA QA), as well as gas optimizations: wardens are required to submit these as bulk listings of issues and recommendations. They may only submit a single, compiled report in each category:

  • QA reports should include all low severity and non-critical findings, along with a summary statement.
  • Gas reports should include all gas optimization recommendations, along with a summary statement.

For QA and Gas reports, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • Add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

As you undertake that process, we request that you take the following steps:

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2023-03-mute-findings's People

Contributors

code423n4 avatar c4-judge avatar kartoonjoy avatar

Stargazers

小米 avatar HollaDieWaldfee avatar

Watchers

Ashok avatar  avatar

Forkers

bova242

2023-03-mute-findings's Issues

QA Report

See the markdown file with the details of this report here.

`MuteAmplifier.rescueTokens()` checks the wrong condition for `muteToken`

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L185-L191

Vulnerability details

Impact

There will be 2 impacts.

  • The reward system would be broken as the rewards can be withdrawn before starting staking.
  • Some rewards would be locked inside the contract forever as it doesn't check totalReclaimed

Proof of Concept

rescueTokens() checks the below condition to rescue muteToken.

else if (tokenToRescue == muteToken) {
    if (totalStakers > 0) {
        require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)),
            "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
        );
    }
}

But there are 2 problems.

  1. Currently, it doesn't check anything when totalStakers == 0. So some parts(or 100%) of rewards can be withdrawn before the staking period. In this case, the reward system won't work properly due to the lack of rewards.
  2. It checks the wrong condition when totalStakers > 0 as well. As we can see here, some remaining rewards are tracked using totalReclaimed and transferred to treasury directly. So we should consider this amount as well.

Tools Used

Manual Review

Recommended Mitigation Steps

It should be modified like the below.

else if (tokenToRescue == muteToken) {
    if (totalStakers > 0) { //should check totalReclaimed as well
        require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards).sub(totalReclaimed)),
            "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
        );
    }
    else if(block.timestamp <= endTime) { //no stakers but staking is still active, should maintain totalRewards
        require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards),
            "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
        );
    }
}

Malicious user can force victims to waste a lot of gas when they redeem their dMute

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L112

Vulnerability details

Proof of Concept

When redeeming, the user must iterate through all the elements of _userLock to destroy any redeemed locks.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L112

for(uint256 i = _userLocks[msg.sender].length; i > 0; i--){
          UserLockInfo memory lock_info = _userLocks[msg.sender][i - 1];

          // recently redeemed lock, destroy it
          if(lock_info.time == 0){
            _userLocks[msg.sender][i - 1] = _userLocks[msg.sender][_userLocks[msg.sender].length - 1];
            _userLocks[msg.sender].pop();
          }
        }

However, any user can add an arbitrary number of entries to a victim's _userLocks via lockTo at a negligible expense.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L81

function LockTo(uint256 _amount, uint256 _lock_time, address to) public nonReentrant {
        require(IERC20(MuteToken).balanceOf(msg.sender) >= _amount, "dMute::Lock: INSUFFICIENT_BALANCE");

        //transfer tokens to this contract
        IERC20(MuteToken).transferFrom(msg.sender, address(this), _amount);

        // calculate dTokens to mint
        uint256 tokens_to_mint = timeToTokens(_amount, _lock_time);

        require(tokens_to_mint > 0, 'dMute::Lock: INSUFFICIENT_TOKENS_MINTED');

        _mint(to, tokens_to_mint);

        _userLocks[to].push(UserLockInfo(_amount, block.timestamp.add(_lock_time), tokens_to_mint));

        emit LockEvent(to, _amount, tokens_to_mint, _lock_time);
    }

Suppose the malicious user calls lockTo 100 times, with _amount = 10 (any bare minimum amount while ensuring tokens_to_mint > 0), and _lock_time = 52 weeks (maximum duration).

These 100 entries stays in _userLocks for at least 1 year (since they can't be redeemed for a year). Any time the victim redeems within this year, they must iterate through these 100 entries, wasting a significant amount of gas.

Impact

Due to the unbounded for loop where the number of iterations can be controlled by a malicious user, the victim has to pay for a lot of gas every time they call redeem. From my elementary understanding of zksync, while it uses rollups to significantly reduce the gas fee, the price for gas is still not negligible, especially in periods of low usage. Furthermore, the attacker only has to call lockTo once to add an entry, while the victim must iterate through it every time they redeem. Therefore, this setup this far from ideal.

Tools Used

Manual Review

Recommended Mitigation Steps

From my understanding, keeping the redeemed entries in _userLocks doesn't impact the functionality since the contract reverts when it hits them, and the user can select which _userLock entries to redeem.

Therefore, a potential solution is to add a bool clean parameter to the redeemTo function, and only run the for loop when clean is true.

Attacker can steal the locked NFT in protocol because of lacking check in function `borrowToBuy()`

Lines of code

https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L436

Vulnerability details

Impact

In function borrowToBuy(), the borrower takes a loan offer and uses the funds to purchase NFT.

/* Take the loan offer. */
_takeLoanOffer(offer, signature, lienId, loanAmount, collateralTokenId);

/* Lock token. */
offer.collection.transferFrom(msg.sender, address(this), collateralTokenId);

/* Take pool funds from lender. */
pool.withdrawFrom(offer.lender, address(this), loanAmount);

/* Execute marketplace order. */
offer.collection.approve(_DELEGATE, collateralTokenId);
_EXCHANGE.execute{ value: execution.value }(execution.sell, execution.buy);

/* Send token out to buyer. */ 
///////////////////////////////////////////////////////////////////////////
// @audit execution NFT might not is offer.collection
offer.collection.transferFrom(address(this), msg.sender, execution.sell.order.tokenId);

/* Send surplus to borrower. */
SafeTransferLib.safeTransferETH(msg.sender, loanAmount - execution.value);

However, it is not guaranteed that the NFT borrower bought is from the same collection as offer.collection. As a result, an attacker can purchase a cheap NFT and steal an expensive locked NFT that has the same tokenId.

Proof of Concept

Consider the scenario

  1. Attacker saw that there is a Cryptopunk NFT currently locked in protocol as collateral. He plans to steal it.
  2. Attacker creates a loan offer with the collection is Cryptopunk.
  3. Attacker uses function borrowToBuy() to take this offer, deposit a Cryptopunks NFT and buy an NFT from Otherdeed collection that have the same id with the locked Cryptopunk NFT in step 1.

Function borrowToBuy() transfers the locked Cryptopunk to the attacker because it did not check that the collection of the purchased NFT is matched with the collection of offer.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider adding checks to ensure the offer collection and execution collection is the same.

`dripsInfo` is not correct when there is no deposit

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L419

Vulnerability details

Impact

dripsInfo is not correct when there is no deposit and returns wrong perSecondReward.

Proof of Concept

MuteAmplifier.dripsInfo calculates perSecondReward as follows:

    info.perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));

firstStakeTime is 0 when there is no deposit, so perSecondReward will be a wrong value when there is no deposit and firstStakeTime = 0.

Tools Used

Manual Review

Recommended Mitigation Steps

When firstStakeTime = 0, perSecondReward has no meaning, so it is better to return 0.

deposit() might fail to enforce the minimum ``payout`` constraint near the end of an epoch.

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

Detailed description of the impact of this finding.
deposit() might fail to enforce the minimum payout constraint near the end of an epoch.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

The deposit() function has the constraint that payout must be > 0.01 payout token (underflow protection); see L161:

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

The problem of the function is that it assumes such minimum constraint will always be met for the case of max_buy = true. This is not true.

Near the end of an Epoch, maxDeposit() might become very small to the point that maxDeposit() < ((10**18) / 100). If that happens, the deposit() function might deposit with a payOut < (10**18) / 100), which means, the minimum deposit constraint is violated.

Tools Used

VSCode

Recommended Mitigation Steps

Move the minimum constraint check outside to cover both cases; we also remove the check ``payout <= maxPayout" since it implied by the next check.

uint payout = payoutFor( value );
        if(max_buy == true){
          value = maxPurchaseAmount();
          payout = maxDeposit();
        } else {
          // safety checks for custom purchase
-          require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection )
-          require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage
          require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
        }

+       require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( u+

Amplifier users might not get all the LP fees they are entitled to

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L111

Vulnerability details

Proof of Concept

Observe that there is only one place that the amplifier is calling claimFees, and it's inside an if statement of the update modifier, requiring _mostRecentValueCalcTime < endTime.

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L111

modifier update() {
        if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }

        uint256 totalCurrentStake = totalStake();

        if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) {
            uint256 value = 0;
            uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
            uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));

            if (block.timestamp < endTime) {
                value = sinceLastCalc.mul(perSecondReward);
            } else {
                uint256 sinceEndTime = block.timestamp.sub(endTime);
                value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward);
            }

            _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));

            _mostRecentValueCalcTime = block.timestamp;

            (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees();

            _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake));
            _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake));

            totalFees0 = totalFees0.add(fee0);
            totalFees1 = totalFees1.add(fee1);
        }

        _;
    }

Consider the following situation. An user X has staked a large amount of LP tokens, and a user Y has staked a normal amount.

Y withdraws as soon as the staking period ends (block.timestamp > endTime), triggering the update modifier, which sets _mostRecentValueCalcTime = block.timestamp > endTime. Observe that after this point, the amplifier will never call claimFees again since _mostRecentValueCalcTime < endTime will forever be false.

Meanwhile, X forgot about it, and doesn't withdraw until say 2 weeks after endTime. When X calls withdraw, X won't get the LP fees for those 2 weeks. In fact, nobody will - they are trapped inside the mute switch pair forever since the amplifier won't call claim.

Impact

Some LP fees can be trapped inside the mute switch pair when it should really be going to the amplifier users.

Tools Used

Manual Review

Recommended Mitigation Steps

I believe it's best to move the LP fee calculation out of the if statement.

A staker might be still be able to stake after staking is over.

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L203-L223

Vulnerability details

Impact

Detailed description of the impact of this finding.
A staker might be still be able to stake after staking is over.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

When nobody stakes during the whole staking period, then the first staker can still stake, even after the staking period is already over.

This is because of the following faulty logic:

 if (firstStakeTime == 0) {
            firstStakeTime = block.timestamp;
        } else {
            require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
        }

So when firstStakeTime == 0 and block.timestamp > endTime, it is still possible to stake. In other words, the function never check for the first staker whether the staking is over or not, it always allows the first staker to stake.

Tools Used

VScode

Recommended Mitigation Steps

if (firstStakeTime == 0) {
            firstStakeTime = block.timestamp;
} 
- else {
            require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
-        }

MuteBond.sol: deposit function reverts if remaining payout is very small due to >0 check in dMute.LockTo function

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

I will show in this report how the MuteBond.deposit function can experience a temporary DOS.

The attacker or just any other user by mistake or by not knowing about it can receive a payout from the deposit function that puts the payoutTotal of the current epoch just below the maxPayout (up to 52 Wei below maxPayout as we will see).

This means that the next calls to deposit can at a max receive a payout of this small amount. However if the dMute.LockTo function is called with such a small amount it reverts in this line since it rounds the dMute tokens to mint to 0 which is not allowed.

This means that the MuteBond.deposit function cannot be called anymore and a new epoch cannot be entered.

This would be of High severity if it wasn't possible for the owner to increase maxPayout and resolve the DOS.

However the current behavior is certainly not intended and takes some time to resolve so there is definitely an impact to the availability of the MuteBond.deposit function which should be available at all times. So I determine that this issue is of Medium severity.

Proof of Concept

I tried in Remix with the following function how big the _amount parameter in the LockTo function can be such that the result which is tokens_to_mint is rounded to 0:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;
contract Test  {
    uint256 depositFee = 1000;

    function timeToTokens(uint256 _amount) public pure returns (uint256){
        uint256 week_time = 1 weeks;
        uint256 max_lock = 52 weeks;
        uint256 _lock_time = 1 weeks;

        require(_lock_time >= week_time, "dMute::Lock: INSUFFICIENT_TIME_PARAM");
        require(_lock_time <= max_lock, "dMute::Lock: INSUFFICIENT_TIME_PARAM");

        // amount * % of time locked up from min to max
        uint256 base_tokens = (_amount * (_lock_time * 10**18 / max_lock)) / (10**18);
        // apply % min max bonus
        //uint256 boosted_tokens = base_tokens.mul(lockBonus(lock_time)).div(10**18);

        return base_tokens;
    }
}

_amount can be as big as 52 Wei. Beginning from 53 Wei it won't be rounded to 0.

So let's assume to make the calculations simpler that the following conditions apply:

maxPayout = 1e18
startPrice = 1e18
block.timestamp == epochStart (start price is the current price)

When we now call MuteBond.deposit with value=1e18 - 1 the payout is also 1e18 - 1.

This means the remaining payout to be made in this epoch is 1 Wei.

This will cause a revert in the dMute.LockTo function.

Tools Used

VSCode

Recommended Mitigation Steps

I recommend that if after a call to MuteBond.deposit the difference between terms[epoch].payoutTotal and maxPayout is very small (e.g. 0.01 * 10**18) the new epoch should be entered even without reaching the exact maxPayout amount. This ensures that the dMute.LockTo function can never revert.

Fix:

diff --git a/contracts/bonds/MuteBond.sol b/contracts/bonds/MuteBond.sol
index 96ee755..d900848 100644
--- a/contracts/bonds/MuteBond.sol
+++ b/contracts/bonds/MuteBond.sol
@@ -190,7 +190,7 @@ contract MuteBond {
           epochStart = block.timestamp;
 
         // exhausted this bond, issue new one
-        if(terms[epoch].payoutTotal == maxPayout){
+        if(maxPayout - terms[epoch].payoutTotal < (1e16)){
             terms.push(BondTerms(0,0,0));
             epochStart = block.timestamp;
             epoch++;

Attacker can front-run Bond buyer and make them buy it for a lower payout than expected

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L185-L187

Vulnerability details

The MuteBond contract contains a feature in which after each purchase the epochStart increases by 5% of the time passed since epochStart, this (in most cases) lowers the bond's price (i.e. buyer gets less payout) for future purchases.
An attacker can exploit this feature to front-run a deposit/purchase tx and lower the victim's payout.
This can also happen by innocent users purchasing before the victim's tx is included in the blockchain.

Another (less likely) scenario in which this can happen is when the owner changes the config in a way that lowers the price (e.g. lowering max price, extending epoch duration), if the owner tx executes while a user's deposit() tx is in the mempool the user would end up with less payout than intended.

Side note: the term 'bond price' might be confusing since it refers to the payout the buyer gets divided by the value the buyer pays, so a higher price is actually in favor of the buyer.

Impact

User ends up buying bond for a lower payout than intended.

Proof of Concept

In the PoC below, an attacker manages to make the buyer purchase a bond at a price lower by 32% than intended.

File: test/bonds.ts

  it('Front run PoC', async function () {
    // let price reach the max price
    await time.increase(60 * 60 * 24 * 7)

    // price when victim sends out the tx to the mempool
    var expectedPrice = await bondContract.bondPrice()

    const startPrice = new BigNumber(100).times(Math.pow(10,18))
    let minPurchasePayout = new BigNumber(Math.pow(10,16));
    // using dynamic price didn't work out so I'm using the lowest price
    var minPurchaseValue = minPurchasePayout.times(1e18).div(startPrice).plus(1);

    // attacker buys the lowest amount 20 times
    for(let i = 0; i< 20; i++){
      await bondContract.connect(buyer1).deposit(minPurchaseValue.toFixed(), buyer1.address, false)
    }

    var init_dmute = await dMuteToken.GetUnderlyingTokens(buyer1.address)
    let depositValue = new BigNumber(10).times(Math.pow(10,18)).toFixed();
    var price = await bondContract.connect(buyer1).deposit(depositValue, buyer1.address, false)
    var post_dmute = await dMuteToken.GetUnderlyingTokens(buyer1.address)

    var dmute_diff = new BigNumber(post_dmute.toString()).minus(init_dmute.toString());
    var actualPrice = dmute_diff.times(1e18).div(depositValue);

    var receipt = (await price.wait())
    // compare the expected price with the actual price
    // expected price = 200; actual price = 135.8; meaning actual price is ~68% of expected price
    console.log({expectedPrice, actualPrice:actualPrice.toString()});
  })

Recommended Mitigation Steps

Add a min payout parameter so that users can specify the expected payout. The tx should revert if the actual payout is lower than expected.

An edge case in amplifier allows user to stake after end time, causing reward to be locked in the contract

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212

Vulnerability details

Proof of Concept

Observe that if nobody has staked after the period has ended, it's still possible for a single user to stake even though the period has ended.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212

        if (firstStakeTime == 0) {
            firstStakeTime = block.timestamp;
        } else {
            require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
        }

The staker can't get any of the rewards because the update modifier won't drip the rewards (since _mostRecentValueCalcTime = firstStakeTime >= endTime).
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L89-L95

        if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }

        uint256 totalCurrentStake = totalStake();

        if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) {
            ...
        }

At the same time, the protocol can't withdraw the rewards with rescueToken either since there is a staker, and no reward has been claimed yet (so the following check fails).
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L187

        else if (tokenToRescue == muteToken) {
            if (totalStakers > 0) {
                require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)),
                    "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
                );
            }
        }

Impact

Suppose the staking period ends and nobody has staked. The admin would like to withdraw the rewards. A malicious user can front-run the rescueTokens call with a call to stake to lock all the rewards inside the contract indefinitely.

Tools Used

Manual Review

Recommended Mitigation Steps

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212
The require shouldn't be inside the else block.

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

There is a race condition betweeen MuteBond#setEpochDuration() and MuteBond#deposit()

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L129-L133

Vulnerability details

Impact

Detailed description of the impact of this finding.
There is a race condition between MuteBond#setEpochDuration() and MuteBond#deposit(). The issue is that when a new EpochDuration is set, it will take effect immediately, which will affect the bond price. As a result, depending on the order of these two functions, users who deposit before or after MuteBond#setEpochDuration() will have two totally different bond prices - a race condition.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Let's analyze a race condition between MuteBond#setEpochDuration() and MuteBond#deposit():

1) ``MuteBond#setEpochDuration()`` will change ``epochDuration``, which will take effect immediately;

2) The bond price will be changed due to the change of ``epochDuration``, see implementation of [BondPrice()](https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L227-L235)

3) The deposit() function will use the BondPrice() to calculate the amount of payout. As a result, depositors right before or after ``MuteBond#setEpochDuration()`` will use two totally bond prices to calculate such payouts. This is unfair to depositors. 

4) When the ``epochDuration`` is prolonged, a user might front-run ``MuteBond#setEpochDuration()`` to get a better bond price (before price), while users who fail to do so have to use the price after ``MuteBond#setEpochDuration()``, a worse price. 

Tools Used

VSCode

Recommended Mitigation Steps

The new set epochDuration should only take effect in the next epoch.

MuteAmplifier.sol: multiplier calculation is incorrect which leads to loss of rewards for almost all stakers

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L473-L499
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L366-L388
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L417-L460

Vulnerability details

Impact

This report deals with how the calculation of the multiplier in the MuteAmplifier contract is not only different from how it is displayed in the documentation on the website but it is also different in a very important way.

The calculation on the website shows a linear relationship between the dMUTE / poolSize ratio and the APY. The dMUTE / poolSize ratio is also called the tokenRatio.
By "linear" I mean that when a user increases his tokenRatio from 0 to 0.1 this has the same effect as when increasing it from 0.9 to 1.

The implementation in the MuteAmplifier.calculateMultiplier function does not have this linear relationship between tokenRatio and APY.
An increase in the tokenRatio from 0 to 0.1 is worth much less than an increase from 0.9 to 1.

As we will see this means that all stakers that do not have a tokenRatio of exactly equal 0 or exactly equal 1 lose out on rewards that they should receive according to the documentation.

I estimate this to be of "High" severity because the issue affects nearly all stakers and results in a partial loss of rewards.

Proof of Concept

Let's first look at the multiplier calculation from the documentation:

multiplier

multiplier_example

The example calculation shows that the amount that is added to $APY_{base}$ (5%) is scaled linearly by the $\dfrac{user_{dmute}}{pool_{rewards}}$ ratio which I called tokenRatio above.

This means that when a user increases his tokenRatio from say 0 to 0.1 he gets the same additional reward as when he increases the tokenRatio from say 0.9 to 1.

Let's now look at how the reward and thereby the multiplier is calculated in the code.

The first step is to calculate the multiplier which happens in the MuteAmplifier.calculateMultiplier function:

Link

function calculateMultiplier(address account, bool enforce) public view returns (uint256) {
    require(account != address(0), "MuteAmplifier::calculateMultiplier: missing account");


    uint256 accountDTokenValue;


    // zkSync block.number = L1 batch number. This at times is the same for a few minutes. To avoid breaking the call to the dMute contract
    // we take the previous block into account
    uint256 staked_block =  _userStakedBlock[account] == block.number ? _userStakedBlock[account] - 1 : _userStakedBlock[account];


    if(staked_block != 0 && enforce)
        accountDTokenValue = IDMute(dToken).getPriorVotes(account, staked_block);
    else
        accountDTokenValue = IDMute(dToken).getPriorVotes(account, block.number - 1);


    if(accountDTokenValue == 0){
        return _stakeDivisor;
    }


    uint256 stakeDifference = _stakeDivisor.sub(10 ** 18);


    // ratio of dMute holdings to pool
    uint256 tokenRatio = accountDTokenValue.mul(10**18).div(totalRewards);


    stakeDifference = stakeDifference.mul(clamp_value(tokenRatio, 10**18)).div(10**18);


    return _stakeDivisor.sub(stakeDifference);
}

The multiplier that is returned is then used to calculate the reward:

Link

reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true));

Let's write the formula in a more readable form:

$\dfrac{lpTokenOut * weightDifference}{stakeDivisor - tokenRatio * (stakeDivisor - 1)}$

$stakeDivisor$ can be any number $&gt;=1$ and has the purpose of determining the percentage of rewards a user with $tokenRatio=0$ gets.

For the sake of this argument we can assume that all numbers except $tokenRatio$ are constant.

Let's just say $stakeDivisor=2$ which means that a user with $tokenRatio=0§ would receive $\dfrac{1}{2}=50%$ of the maximum rewards.

Further let's say that $lpTokenOut * weightDifference = 1$, so 100% of the possible rewards would be $1$.

We can then write the formula like this:

$\dfrac{1}{2 - tokenRatio}$

So let's compare the calculation from the documentation with the calculation from the code by looking at a plot:

functions

plot

x-axis: tokenRatio
y-axis: percentage of maximum rewards

We can see that the green curve is non-linear and below the blue curve.

So the rewards as calculated in the code are too low.

Tools Used

VSCode

Recommended Mitigation Steps

I recommend to change the reward calculation to this:

$(lpTokenOut * weightDifference) * (percentage_{min} + clamp(\dfrac{user_{dmute}}{pool_{rewards}},1) * (1 - percentage_{min}))$

Instead of setting the stakeDivisor upon initialization, the percentageMin should be set which can be in the interval [0,1e18].

Fix:

diff --git a/contracts/amplifier/MuteAmplifier.sol b/contracts/amplifier/MuteAmplifier.sol
index 9c6fcb5..1c86f5c 100644
--- a/contracts/amplifier/MuteAmplifier.sol
+++ b/contracts/amplifier/MuteAmplifier.sol
@@ -48,7 +48,7 @@ contract MuteAmplifier is Ownable{
 
     uint256 private _mostRecentValueCalcTime; // latest update modifier timestamp
 
-    uint256 public _stakeDivisor; // divisor set in place for modification of reward boost
+    uint256 public _percentageMin; // minimum percentage set in place for modification of reward boost
 
     uint256 public management_fee; // lp withdrawal fee
     address public treasury; // address that receives the lp withdrawal fee
@@ -131,8 +131,8 @@ contract MuteAmplifier is Ownable{
      *  @param _mgmt_fee uint
      *  @param _treasury address
      */
-    constructor (address _lpToken, address _muteToken, address _dToken, uint256 divisor, uint256 _mgmt_fee, address _treasury) {
-        require(divisor >= 10 ** 18, "MuteAmplifier: invalid _stakeDivisor");
+    constructor (address _lpToken, address _muteToken, address _dToken, uint256 percentageMin, uint256 _mgmt_fee, address _treasury) {
+        require(_percentageMin <= 10 ** 18, "MuteAmplifier: invalid _percentageMin");
         require(_lpToken != address(0), "MuteAmplifier: invalid lpToken");
         require(_muteToken != address(0), "MuteAmplifier: invalid muteToken");
         require(_dToken != address(0), "MuteAmplifier: invalid dToken");
@@ -142,7 +142,7 @@ contract MuteAmplifier is Ownable{
         lpToken = _lpToken;
         muteToken = _muteToken;
         dToken = _dToken;
-        _stakeDivisor = divisor;
+        _percentageMin = percentageMin;
         management_fee = _mgmt_fee; //bps 10k
         treasury = _treasury;
 
@@ -368,7 +368,7 @@ contract MuteAmplifier is Ownable{
         require(lpTokenOut > 0, "MuteAmplifier::_applyReward: no coins staked");
 
         // current rewards based on multiplier
-        reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true));
+        reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(10 ** 18).mul(calculateMultiplier(account, true)).div(10 ** 18);
         // max possible rewards
         remainder = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(10**18);
         // calculate left over rewards
@@ -442,7 +442,7 @@ contract MuteAmplifier is Ownable{
             uint256 _totalWeightFee1Local = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake));
 
             // current rewards based on multiplier
-            info.currentReward = totalUserStake(user).mul(totWeightLocal.sub(_userWeighted[user])).div(info.multiplier_last);
+            info.currentReward = totalUserStake(user).mul(totWeightLocal.sub(_userWeighted[user])).div(10 ** 18).mul(info.multiplier_last).div(10 ** 18);
             // add back any accumulated rewards
             info.currentReward = info.currentReward.add(_userAccumulated[user]);
 
@@ -452,7 +452,7 @@ contract MuteAmplifier is Ownable{
 
         } else {
           // current rewards based on multiplier
-          info.currentReward = totalUserStake(user).mul(_totalWeight.sub(_userWeighted[user])).div(info.multiplier_last);
+          info.currentReward = totalUserStake(user).mul(_totalWeight.sub(_userWeighted[user])).div(10 ** 18).mul(info.multiplier_last).div(10 ** 18);
           // add back any accumulated rewards
           info.currentReward = info.currentReward.add(_userAccumulated[user]);
         }
@@ -485,17 +485,17 @@ contract MuteAmplifier is Ownable{
           accountDTokenValue = IDMute(dToken).getPriorVotes(account, block.number - 1);
 
         if(accountDTokenValue == 0){
-          return _stakeDivisor;
+          return _percentageMin;
         }
 
-        uint256 stakeDifference = _stakeDivisor.sub(10 ** 18);
+        uint256 percentageDifference = (uint256(10 ** 18)).sub(_percentageMin);
 
         // ratio of dMute holdings to pool
         uint256 tokenRatio = accountDTokenValue.mul(10**18).div(totalRewards);
 
-        stakeDifference = stakeDifference.mul(clamp_value(tokenRatio, 10**18)).div(10**18);
+        uint256 additionalPercentage = percentageDifference.mul(clamp_value(tokenRatio, 10**18)).div(10**18);
 
-        return _stakeDivisor.sub(stakeDifference);
+        return _percentageMin.add(additionalPercentage);
     }

Bond max-buyer might end up buying the max buy of the next epoch

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L156-L158

Vulnerability details

The MuteBond.deposit() function allows users to specify the amount of value they want to purchase bonds for or to set max_buy to true.
If max_buy is set to true the amount specified in the value parameter is ignored and instead the maximum amount available for purchase in the current epoch is used.
This can lead to a scenario where a user intends to purchase the remaining amount of current epoch, but till the tx is included in the blockchain a new epoch starts (either by an innocent user or by an attacker) and the user ends up buying the entire amount of the next epoch.

Impact

A. The user ends up buying a much higher amount than intended
B. The user ends up buying it for a lower price than intended (i.e. less payout for the buyer)

Proof of Concept

The PoC below shows how maxPurchaseAmount() increases when a new era starts.

File: test/bonds.ts

  it('Max buy PoC', async function () {

    // buy 99% of amount available for purchase in current epoch
    let maxValue = await bondContract.maxPurchaseAmount();
    let depositValue = maxValue.mul(99).div(100);
    await bondContract.connect(buyer1).deposit(depositValue, buyer1.address, false);
    
    // The amount available when the victim sends out the tx
    var expectedDeposit = await bondContract.maxPurchaseAmount()

    await bondContract.connect(buyer1).deposit('0', buyer1.address, true);

    // The amount available when the victims's tx is included in the blockchain
    var actualDeposit = await bondContract.maxPurchaseAmount();    

    // expected deposit = 1 wad
    // actual deposit = 100 wad
    console.log({expectedDeposit, actualDeposit});
  })

The following snippet shows that when a user sets max_buy to true the value used is the maxPurchaseAmount()

        if(max_buy == true){
          value = maxPurchaseAmount();
          payout = maxDeposit();
        } else {

Recommended Mitigation Steps

Require the user to specify the epoch number when doing a 'max buy', and revert if it doesn't match the current epoch (it might be a good idea to refactor the code to 2 external functions for normal buy and max buy, where they both share an internal function to make the actual deposit)

Side note: this is similar to another bug I've reported regarding getting a lower price than expected, however the root cause, impact, and mitigation are different and therefore I've reported this separately.

QA Report

See the markdown file with the details of this report here.

MuteBond.sol: When maxPayout is lowered the contract can end up DOSed

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L119-L123
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

The maxPayout variable in the MuteBond contract specifies the amount of MUTE that is paid out in one epoch before the next epoch is entered.

The variable is initialized in the constructor and can then be changed via the setMaxPayout function.

The issue occurs when maxPayout is lowered.

So say maxPayout is currently 10,000 MUTE and the owner wants to reduce it to 5,000 MUTE.

Before this transaction to lower maxPayout is executed, another transaction might be executed which increases the current payout to > 5,000 MUTE.

This means that calls to MuteBond.deposit revert and no new epoch can be entered. Thereby the MuteBond contracts becomes unable to provide bonds.

The DOS is not permanent. The owner can increase maxPayout such that the current payout is smaller than maxPayout again and the contract will work as intended.

So the impact is a temporary DOS of the MuteBond contract.

The issue can be solved by requiring in the setMaxPayout function that maxPayout must be bigger than the payout in the current epoch.

Proof of Concept

Add the following test to the bonds.ts test file:

it('POC maxPayout below current payout causes DOS', async function () {
    // owner wants to set maxPayout to 9 * 10**18 
    // However a transaction is executed first that puts the payout in the current epoch above that value
    // all further deposits revert

    // make a payout
    await bondContract.connect(owner).deposit(new BigNumber(10).times(Math.pow(10,18)).toFixed(), owner.address, false)
    
    // set maxPayout below currentPayout
    await bondContract.connect(owner).setMaxPayout(new BigNumber(9).times(Math.pow(10,18)).toFixed())

    // deposit reverts due to underflow
    await bondContract.connect(owner).deposit("0", owner.address, true)
  })

Tools Used

VSCode

Recommended Mitigation Steps

I recommend that the setMaxPayout function checks that maxPayout is set to a value bigger than the payout in the current epoch:

diff --git a/contracts/bonds/MuteBond.sol b/contracts/bonds/MuteBond.sol
index 96ee755..4af01d7 100644
--- a/contracts/bonds/MuteBond.sol
+++ b/contracts/bonds/MuteBond.sol
@@ -118,6 +118,7 @@ contract MuteBond {
      */
     function setMaxPayout(uint _payout) external {
         require(msg.sender == customTreasury.owner());
+        require(_payout > terms[epoch].payoutTotal, "_payout too small");
         maxPayout = _payout;
         emit MaxPayoutChanged(_payout);
     }

A malicious frontrunner can make the `Mutebond` contract broken when the owner decreases `maxPayout`

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L119

Vulnerability details

Impact

The Mutebond contract might stop working after the owner decreased maxPayout by a malicious frontrunner.

Proof of Concept

setMaxPayout() can be used to reset maxPayout.

    function setMaxPayout(uint _payout) external {
        require(msg.sender == customTreasury.owner());
        maxPayout = _payout;
        emit MaxPayoutChanged(_payout);
    }

And in deposit(), epoch is increased only when terms[epoch].payoutTotal == maxPayout.

File: MuteBond.sol
193:         if(terms[epoch].payoutTotal == maxPayout){
194:             terms.push(BondTerms(0,0,0));
195:             epochStart = block.timestamp;
196:             epoch++;
197:         }

So when the owner is going to decrease maxPayout for some reason, the below scenario would be possible by a malicious user.

  1. Currently, epoch = 1, maxPayout = 1000, terms[epoch].payoutTotal = 500
  2. The owner decided to decrease maxPayout to 800 for some reason and called setMaxPayout() with _payout = 800.
  3. After noticing it, a malicious user frontruns deposit() with value = 400.
  4. After deposit() is called, terms[epoch].payoutTotal = 900 and maxPayout will be 800.
  5. Then that, deposit() will revert because maxDeposit() reverts due to uint underflow.
    function maxDeposit() public view returns (uint) {
        return maxPayout.sub(terms[epoch].payoutTotal);
    }

As a result, the contract doesn't work until the owner increases back maxPayout.

It would be more serious when it takes certain time to execute the admin functions as it should be approved by DAO.

Tools Used

Manual Review

Recommended Mitigation Steps

setMaxPayout() should check if the updated maxPayout isn't less than the current epoch's payoutTotal.

    function setMaxPayout(uint _payout) external {
        require(msg.sender == customTreasury.owner());
        require(_payout >= terms[epoch].payoutTotal); //++++++++++++++++++++
        
        maxPayout = _payout;
        emit MaxPayoutChanged(_payout);
    }

Award is still distributed when there aren't any stakers, allowing users to get reward without staking

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L95-L118

Vulnerability details

Proof of Concept

Consider the update modifier for the amplifier.

modifier update() {
        if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }

        uint256 totalCurrentStake = totalStake();

        if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) {
            uint256 value = 0;
            uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
            uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));

            if (block.timestamp < endTime) {
                value = sinceLastCalc.mul(perSecondReward);
            } else {
                uint256 sinceEndTime = block.timestamp.sub(endTime);
                value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward);
            }

            _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));

            _mostRecentValueCalcTime = block.timestamp;

            (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees();

            _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake));
            _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake));

            totalFees0 = totalFees0.add(fee0);
            totalFees1 = totalFees1.add(fee1);
        }

        _;
    }

Suppose there's been a period with totalCurrentStake = 0, and a user stakes and immediately withdraws in the same transaction.
When the user stakes, update doesn't do anything (including updating _mostRecentValueCalcTime) since totalCurrentStake = 0, and _userWeighted[account] gets set to _totalWeight (which hasn't been updated) in the stake function.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L349

function _stake(uint256 lpTokenIn, address account) private {
        ...
        _userWeighted[account] = _totalWeight;
        ...
    }

When the user withdraws, totalCurrentStake is no longer zero. Since _mostRecentValueCalcTime wasn't updated when the user staked (since totalCurrentStake was 0), the reward from the period with no stakers gets added to _totalWeight.

uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
value = sinceLastCalc.mul(perSecondReward);
_totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));
(These are lines in the update modifier)

As a result, this user who staked and immediately withdrew, got all the reward from the period with no stakers. See the reward calculation:
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L371

function _applyReward(address account) private returns (uint256 lpTokenOut, uint256 reward, uint256 remainder, uint256 fee0, uint256 fee1) {
        ...
        reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true));
        ...
    }

Observe that the exploit condition is met as soon as the staking period starts (as long as nobody stakes immediately). The code attempts to prevent this situation setting _mostRecentValueCalcTime to firstStakeTime the first time that update is invoked (which must be a stake call).
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L89-L91

if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }

However, this doesn't do anything since the user can first set the firstStakeTime by staking as soon as the staking period starts, and then make totalCurrentStake 0 by immediately withdrawing. In fact, observe that this takes away from other staker's rewards since perSecondReward is now lower.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L98
uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));

Please add the following test to the "advance to start time" context in amplifier.ts (add it here), and run with npm run test-amplifier.

it("get reward without staking", async function () {
        await this.lpToken.transfer(this.staker1.address, staker1Initial.toFixed(), {from: this.owner.address});
        await this.lpToken.connect(this.staker1).approve(
          this.amplifier.address,
          staker1Initial.toFixed()
        );
        
        console.log("dmute balance before: " + await this.dMuteToken.balanceOf(this.staker1.address))
        await this.amplifier.connect(this.staker1).stake(1);
        await this.amplifier.connect(this.staker1).withdraw();
        await time.increaseTo(staking_end-10);
        await this.amplifier.connect(this.staker1).stake(1);
        await this.amplifier.connect(this.staker1).withdraw();
        console.log("dmute balance after: " + await this.dMuteToken.balanceOf(this.staker1.address))
      });

      it("get reward with staking", async function () {
        await this.lpToken.transfer(this.staker1.address, staker1Initial.toFixed(), {from: this.owner.address});
        await this.lpToken.connect(this.staker1).approve(
          this.amplifier.address,
          staker1Initial.toFixed()
        );
        console.log("dmute balance before: " + await this.dMuteToken.balanceOf(this.staker1.address))
        await this.amplifier.connect(this.staker1).stake(1);
        //await this.amplifier.connect(this.staker1).withdraw();
        await time.increaseTo(staking_end-10);
        //await this.amplifier.connect(this.staker1).stake(1);
        await this.amplifier.connect(this.staker1).withdraw();
        console.log("dmute balance after: " + await this.dMuteToken.balanceOf(this.staker1.address))
      });

Result on my side:

advance to start time
        ✓ reverts without tokens approved for staking
dmute balance before: 0
dmute balance after: 576922930658129099273
        ✓ get reward without staking
dmute balance before: 0
dmute balance after: 576922912276064160247
        ✓ get reward with staking

This POC is a bit on the extreme side to get the point across. In the first test, the user stakes and then immediately unstakes, while in the second test, the user stakes for the entire period. In the end, the user gets roughly the same amount of reward.

Impact

After periods with no stakers, users can get reward without staking. This is also possible at the beginning of the staking period, and doing so then will reduce the reward for other users in the process.

Tools Used

Manual review, Hardhat

Recommended Mitigation Steps

Possible solution 1: set a minimum duration that a user must stake for (prevent them from staking and immediately withdrawing)
Possible solution 2: always update _mostRecentValueCalcTime (regardless totalCurrentStake). i.e. move the following line out of the if statement.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L109

Keep in mind that with solution 2, no one gets the rewards in periods with no stakers - this means that the rescueTokens function needs to be updated to get retrieve these rewards.

MuteBond.sol: deposit function allows no control for payout and value which leads to unexpected purchases of bonds

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

The MuteBond.deposit function allows the user to purchase a bond with LP tokens and receive MUTE tokens in return.

The bondPrice increases linearly over time (which I should mention means the bond gets cheaper; the naming is a bit confusing). There is another mechanic that changes the bondPrice: Whenever a bond is purchased the bondPrice decreases (making the bond more expensive; meaning the user gets less MUTE for the LP tokens he provides).

The user may also provide a max_buy=true parameter which means he will purchase the remaining MUTE such that a new epoch is entered.

There are several scenarios possible that lead to unexpected unintended outcomes for the user (Essentially a loss of funds). I decided to put all of this into a single report since all scenarios come down to this:

  1. When the user calls the deposit function he cannot specify how many MUTE tokens he wants to get out at a minimum. The user cannot know if and how much the bondPrice changes in between the time he creates the transaction and the time the transaction is processed. The bondPrice can also be changed by the owner by setting startPrice, maxPrice or epochDuration within an ongoing epoch.

  2. Also if he sets max_buy=true and a new epoch is entered by the time the transaction is processed or the owner has increased maxPayout, the user ends up paying a lot more of his LP tokens than intended. Sure he may only approve a certain number. But many users will just approve the maximum amount.

I think the first set of scenarios where the bondPrice changes is more severe because even a user that does not make the "mistake" of approving type(uint256).max is prone to it.

So I focus on this first set of scenarios in my proof of concept. I provide a solution for both of these problems in the recommendations section.

Proof of Concept

Add the following test to the bonds.ts test file.

it('POC unexpected price', async function () {
    // halfway into the duration
    var set_time = (await time.latest()).plus(60 * 60 * 24 * 3.5)
    await time.increaseTo(set_time)

    // Buyer1 intends to call "deposit" at the current price
    console.log("Intended bond price");
    console.log(await bondContract.bondPrice());

    // Other transactions are executed before his
    // They decrease "bondPrice"
    await bondContract.connect(owner).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), owner.address, false)
    
    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(owner).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), owner.address, false)
    
    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(owner).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), owner.address, false)
    // This means the user receives less MUTE than expected.
    // He may not want his transaction to be executed at such a bad price
    console.log("Actual bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(10).times(Math.pow(10,18)).toFixed(), buyer1.address, false)
  })

Due to the other transactions executing before the user's, the bondPrice drops which means the user gets less MUTE than expected. Essentially purchasing MUTE at a worse price than expected which is a loss of funds. The user might be better off by just keeping his LP tokens.

Tools Used

VSCode

Recommended Mitigation Steps

How can the first set of scenarios be mitigated?
The user should be able to provide a minPayout parameter that specifies how many MUTE tokens he wants to receive at a minimum. Thereby if the bondPrice changes and he would receive less MUTE than intended, he is protected.

For the second set of scenarios (where max_buy=true) I propose that the already existing value parameter should specify a maxValue, i.e. how many LP tokens the user is willing to pay at a maximum.

Both fixes together then look like this:

diff --git a/contracts/bonds/MuteBond.sol b/contracts/bonds/MuteBond.sol
index 96ee755..407e9e6 100644
--- a/contracts/bonds/MuteBond.sol
+++ b/contracts/bonds/MuteBond.sol
@@ -150,11 +150,13 @@ contract MuteBond {
      *  @param _depositor address
      *  @param max_buy bool
      */
-    function deposit(uint value, address _depositor, bool max_buy) external returns (uint) {
+    function deposit(uint value, address _depositor, bool max_buy, uint minPayout) external returns (uint) {
         // amount of mute tokens
         uint payout = payoutFor( value );
         if(max_buy == true){
+          uint maxValue = value;
           value = maxPurchaseAmount();
+          require(value <= maxValue, "Bond too large");
           payout = maxDeposit();
         } else {
           // safety checks for custom purchase
@@ -163,7 +165,7 @@ contract MuteBond {
           require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
         }
 
-
+        require(payout >= minPayout, "Payout too small");
         // total debt is increased
         totalDebt = totalDebt.add( value );
         totalPayoutGiven = totalPayoutGiven.add(payout); // total payout increased

QA Report

See the markdown file with the details of this report here.

Division-before-multiplication precision loss issue for update()

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L88-L121

Vulnerability details

Impact

Detailed description of the impact of this finding.
There is a division-before-multiplication precision loss issue for update().

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

The update() function has a division-before-multiplication precision loss issue.

First, the calculation of perSecondReward uses a division:

uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));

Then, the calculation of value uses a multiplication.

if (block.timestamp < endTime) {
                value = sinceLastCalc.mul(perSecondReward);
            } else {
                uint256 sinceEndTime = block.timestamp.sub(endTime);
                value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward);
            }

Suppose that the total reward time is one year = 31,536,000 seconds, then the precision loss could be up to 31,536,000, which is significant for perSecondReward.

Tools Used

VScode

Recommended Mitigation Steps

Use the multiplication-after-division pattern

Mitigation: use multiplication-before-division instead

modifier update() {
        if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }

        uint256 totalCurrentStake = totalStake();

        if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) {
            uint256 value = 0;
            uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
-            uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));
+            uint256 rewardPeriod = endTime.sub(firstStakeTime);

            if (block.timestamp < endTime) {
-                value = sinceLastCalc.mul(perSecondReward);
+                 value = sinceLastCalc.mul(totalRewards).div(rewardPeriod);
            } else {
                uint256 sinceEndTime = block.timestamp.sub(endTime);
-                value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward);
+                value = (sinceLastCalc.sub(sinceEndTime)).mul(totalRewards).div(rewardPeriod);
            }

            _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));

            _mostRecentValueCalcTime = block.timestamp;

            (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees();

            _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake));
            _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake));

            totalFees0 = totalFees0.add(fee0);
            totalFees1 = totalFees1.add(fee1);
        }

        _;
    }

Upgraded Q -> 2 from #13 [1680615156614]

Judge has assessed an item in Issue #13 as 2 risk. The relevant finding follows:

Lines of code
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details
Impact
The MuteBond.deposit function allows the user to purchase a bond with LP tokens and receive MUTE tokens in return.

The bondPrice increases linearly over time (which I should mention means the bond gets cheaper; the naming is a bit confusing). There is another mechanic that changes the bondPrice: Whenever a bond is purchased the bondPrice decreases (making the bond more expensive; meaning the user gets less MUTE for the LP tokens he provides).

The user may also provide a max_buy=true parameter which means he will purchase the remaining MUTE such that a new epoch is entered.

There are several scenarios possible that lead to unexpected unintended outcomes for the user (Essentially a loss of funds). I decided to put all of this into a single report since all scenarios come down to this:

When the user calls the deposit function he cannot specify how many MUTE tokens he wants to get out at a minimum. The user cannot know if and how much the bondPrice changes in between the time he creates the transaction and the time the transaction is processed. The bondPrice can also be changed by the owner by setting startPrice, maxPrice or epochDuration within an ongoing epoch.

Logic for RescueTokens is incorrect for muteTokens

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L185-L191

Vulnerability details

Proof of Concept

The logic for RescueTokens doesn't take into account the reward remainders.

I wanted to write a POC but I'm in a bit of a time crunch. So, imagine the following situation:
totalRewards = 100, and staker A, B (the only stakers) staked for the first and second half (respectively) of the staking duration with multiplier 1/2.

Remainder and reward for staker A should both be 25.

        reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true));
        // max possible rewards
        remainder = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(10**18);
        // calculate left over rewards
        remainder = remainder.sub(reward);

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L371-L375

When A withdraws, the remainder gets added to totalReclaimed and transferred to the treasury, while the reward gets added to totalClaimedRewards and locked in dMute.

        if(remainder > 0){
          totalReclaimed = totalReclaimed.add(remainder);
          IERC20(muteToken).transfer(treasury, remainder);
        }
        // payout rewards
        if (reward > 0) {
            uint256 week_time = 60 * 60 * 24 * 7;
            IDMute(dToken).LockTo(reward, week_time ,msg.sender);

            userClaimedRewards[msg.sender] = userClaimedRewards[msg.sender].add(
                reward
            );
            totalClaimedRewards = totalClaimedRewards.add(reward);

            emit Payout(msg.sender, reward, remainder);
        }

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L240-L255

So, after A withdraws, the mute balance of the contract is 50, totalRewards is still 100, and totalClaimedRewards is 25.
Assume that 20 mute tokens got transferred to the contract by mistake and need to be rescued. Observe as long as staker B doesn't withdraw (totalStakers > 0), rescueTokens for mute will fail due to arithmetic underflow (balance - (totalRewards - totalClaimedRewards) = 70 - (100-25) = -5 < 0) :

        else if (tokenToRescue == muteToken) {
            if (totalStakers > 0) {
                require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)),
                    "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
                );
            }
        }

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L185-L191

Impact

RescueTokens cannot necessarily rescue all the mute tokens.

Tools Used

Manual Review

Recommended Mitigation Steps

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L187
Instead of ^, RescueTokens should check amount <= balance - (totalRewards - totalClaimedRewards - totalReclaimed)

In `MuteBond.deposit()`, users might deposit more LPs than they expected by a malicious user

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L156

Vulnerability details

Impact

Users might deposit more LPs unexpectedly if a malicious user increases an epoch by frontrunning.

Proof of Concept

deposit() has a max_buy param to purchase all remaining amounts.

    function deposit(uint value, address _depositor, bool max_buy) external returns (uint) {
        // amount of mute tokens
        uint payout = payoutFor( value );
        if(max_buy == true){
          value = maxPurchaseAmount();
          payout = maxDeposit();
        } else {
          // safety checks for custom purchase
          require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection )
          require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage
          require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
        }
        ...
    }

But this function is frontrunable and the below situation would be possible.

  1. Currently, maxPayout = 1000, epoch = 1, maxDeposit() = 100. An honest user called deposit() with max_buy = true to purchase bonds for 100 payouts.
  2. After noticing this, a malicious user frontruns deposit() with max_buy = true, and the epoch was increased to 2 now. Without the malicious user, it would be possible during the normal purchases.
  3. So for the honest user, epoch = 2, payout = maxDeposit() = 10000 and it will try to transfer LP tokens for 10000 payouts from the user.
  4. If the honest user doesn't have enough LP balance or didn't approve that amount, the transfer will revert which is not so bad.
  5. But if the honest user has enough balance and approved type(uint256).max like this test, he will be suffered to use more LPs than his expectations.

Tools Used

Manual Review

Recommended Mitigation Steps

I think deposit() should have an epoch or max_value_to_pay param to protect users when max_buy = true.

And it should revert if the epoch was increased or the final value is greater than the upper limit of value(max_value_to_pay).

Owner lowering max payout might break the MuteBonds contract

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L119-L123

Vulnerability details

The maxPayout variable can be changed by the owner at any time.
In case the owner lowers the maxPayout and the payoutTotal of the current epoch is greater than the new maxPayout the contract will be broken - no further deposit can be made, and most of the view functions will be broken too.
This can be done either by an attacker front running the owner's call, by an innocent user, or by the owner not noticing the payoutTotal is already high.

Impact

The MuteBonds contract will be unavailable for some time.

This can be patched by the owner increasing it back to the original value, however the owner might be a multisig or a governance token which takes some time to pass a new vote / sign it all.
Additionally, lowering maxPayout back as intended might be complicated, esp. if there's an attacker involved.

Proof of Concept

File: test/bonds.ts

  it('Max buy PoC', async function () {

    // buy 99% of max payout
    let maxValue = await bondContract.maxPurchaseAmount();
    let maxPayout = await bondContract.maxPayout();
    let depositValue = maxValue.mul(99).div(100);
    await bondContract.connect(buyer1).deposit(depositValue, buyer1.address, false);

    // owner lowers it max payout to 98% of current max payout 
    let newMaxPayout = maxPayout.mul(98).div(100);
    await bondContract.connect(owner).setMaxPayout(newMaxPayout);

    // `maxDeposit()` will revert, since deposit depends on it it'll revert too 
    try {
      maxValue = await bondContract.maxDeposit();
    } catch (e) {
      console.log(e);
    }


  })

Recommended Mitigation Steps

At setMaxPayout() check if the total payout of the current epoch is equal-or-greater than the new max payout and start a new era in that case.

An attacker can front-run setMaxPayout() and freeze deposit() and the whole protocol from progressing in epochs.

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L119-L123

Vulnerability details

Impact

Detailed description of the impact of this finding.
When the owner calls setMaxPayout() to decrease maxPayout to newMaxPayout, an attacker can front-run it and deposit so that terms[epoch].payoutTotal <= maxPayout but terms[epoch].payoutTotal > newMaxPayout. This will freeze deposit() and the whole protocol all together.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Let's see how an attacker can front-run setMaxPayout() to freeze the whole protocol:

  1. Suppose we have terms[epoch].payoutTotal = 899,999e18 and maxPayout = 1,000,000e18.

  2. Suppose the owner wants to call setMaxPayout(900,000e18) so that maxPayout can be set to 900,000e18.

  3. An attacker front-runs the call setMaxPayout(900,000e18) and calls deposit() to deposit with a payout of 2e18. As a result, we have terms[epoch].payoutTotal = 900,001e18.

  4. Now setMaxPayout(900,000e18) gets executed, with maxPayout set to 900,000e18. As a result, we have terms[epoch].payoutTotal = 900,001e18 > maxPayout.

  5. The deposit() function will always call maxDeposit(), which will always fail due to an underflow:

   function maxDeposit() public view returns (uint) {
        return maxPayout.sub(terms[epoch].payoutTotal);
    }
  1. Epoch will never be progressed since the following block inside deposit() will never get executed due to failure of deposit(). Besides, the condition of the if-statement will never be true.
  if(terms[epoch].payoutTotal == maxPayout){
            terms.push(BondTerms(0,0,0));
            epochStart = block.timestamp;
            epoch++;
        }
  1. Due to the front-running, deposit() will always fail, no epoch can be progressed, the system is frozen.

Tools Used

VScode

Recommended Mitigation Steps

When maxDeposit() is called, the new maxPayout will only be in effect in the next Epoch:

 function setMaxPayout(uint _payout) external {
        require(msg.sender == customTreasury.owner());
-        maxPayout = _payout;
+        newMaxPayout = _payout;
        emit MaxPayoutChanged(_payout);
    }

function deposit(uint value, address _depositor, bool max_buy) external returns (uint) {
        // amount of mute tokens
        uint payout = payoutFor( value );
        if(max_buy == true){
          value = maxPurchaseAmount();
          payout = maxDeposit();
        } else {
          // safety checks for custom purchase
          require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection )
          require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage
          require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
        }


        // total debt is increased
        totalDebt = totalDebt.add( value );
        totalPayoutGiven = totalPayoutGiven.add(payout); // total payout increased

        customTreasury.sendPayoutTokens(payout);
        TransferHelper.safeTransferFrom(lpToken, msg.sender, address(customTreasury), value ); // transfer principal bonded to custom treasury

        // indexed events are emitted
        emit BondCreated(value, payout, _depositor, block.timestamp);

        bonds.push(Bonds(value, payout, _depositor, block.timestamp));
        // redeem bond for user, mint dMute tokens for duration of vest period
        IDMute(dMuteToken).LockTo(payout, bond_time_lock, _depositor);

        terms[epoch].payoutTotal = terms[epoch].payoutTotal + payout;
        terms[epoch].bondTotal = terms[epoch].bondTotal + value;
        terms[epoch].lastTimestamp = block.timestamp;

        // adjust price by a ~5% premium of delta
        uint timeElapsed = block.timestamp - epochStart;
        epochStart = epochStart.add(timeElapsed.mul(5).div(100));
        // safety check
        if(epochStart >= block.timestamp)
          epochStart = block.timestamp;

        // exhausted this bond, issue new one
        if(terms[epoch].payoutTotal == maxPayout){
+           if(newMaxPayout !=0 ) maxPayout = newMaxPayout; // @audit: there is a change
            terms.push(BondTerms(0,0,0));
            epochStart = block.timestamp;
            epoch++;
+           newMaxPayout = 0;
        }

        return payout;
    }

QA Report

See the markdown file with the details of this report here.

MuteAmplifier.sol: rescueTokens function does not prevent fee tokens from being transferred

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L180-L194

Vulnerability details

Impact

The MuteAmplifier.rescueTokens function allows the owner to withdraw tokens that are not meant to be in this contract.

The contract does protect tokens that ARE meant to be in the contract by not allowing them to be transferred:

Link

function rescueTokens(address tokenToRescue, address to, uint256 amount) external virtual onlyOwner nonReentrant {
    if (tokenToRescue == lpToken) {
        require(amount <= IERC20(lpToken).balanceOf(address(this)).sub(_totalStakeLpToken),
            "MuteAmplifier::rescueTokens: that Token-Eth belongs to stakers"
        );
    } else if (tokenToRescue == muteToken) {
        if (totalStakers > 0) {
            require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)),
                "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
            );
        }
    }


    IERC20(tokenToRescue).transfer(to, amount);
}

You can see that lpToken and muteToken cannot be transferred unless there is an excess amount beyond what is needed by the contract.

So stakers can be sure that not even the contract owner can mess with their stakes.

The issue is that lpToken and muteToken are not the only tokens that need to stay in the contract.

There is also the fee0 token and the fee1 token.

So what can happen is that the owner can withdraw fee0 or fee1 tokens and users cannot payout rewards or withdraw their stake because the transfer of fee0 / fee1 tokens reverts due to the missing balance.

Users can of course send fee0 / fee1 tokens to the contract to restore the balance. But this is not intended and certainly leaves the user worse off.

Proof of Concept

Assume that when an update occurs via the update modifier there is an amount of fee0 tokens claimed:

Link

    modifier update() {
        if (_mostRecentValueCalcTime == 0) {
            _mostRecentValueCalcTime = firstStakeTime;
        }


        uint256 totalCurrentStake = totalStake();


        if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) {
            uint256 value = 0;
            uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
            uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));


            if (block.timestamp < endTime) {
                value = sinceLastCalc.mul(perSecondReward);
            } else {
                uint256 sinceEndTime = block.timestamp.sub(endTime);
                value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward);
            }


            _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));


            _mostRecentValueCalcTime = block.timestamp;


            (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees();


            _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake));
            _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake));


            totalFees0 = totalFees0.add(fee0);
            totalFees1 = totalFees1.add(fee1);
        }


        _;
    }

We can see that _totalWeightFee0 is updated such that when a user's rewards are calculated the fee0 tokens will be paid out to the user.

What happens now is that the owner calls rescueTokens which transfers the fee0 tokens out of the contract.

We can see that when the fee0 to be paid out to the user is calculated in the _applyReward function, the calculation is solely based on _totalWeightFee0 and does not take into account if the fee0 tokens still exist in the contract.

Link

fee0 = lpTokenOut.mul(_totalWeightFee0.sub(_userWeightedFee0[account])).div(10**18);

So when the fee0 tokens are attempted to be transferred to the user that calls payout or withdraw, the transfer reverts due to insufficient balance.

Tools Used

VSCode

Recommended Mitigation Steps

The MuteAmplifier.rescueTokens function should check that only excess fee0 / fee1 tokens can be paid out. Such that tokens that will be paid out to stakers need to stay in the contract.

Fix:

diff --git a/contracts/amplifier/MuteAmplifier.sol b/contracts/amplifier/MuteAmplifier.sol
index 9c6fcb5..b154d81 100644
--- a/contracts/amplifier/MuteAmplifier.sol
+++ b/contracts/amplifier/MuteAmplifier.sol
@@ -188,6 +188,18 @@ contract MuteAmplifier is Ownable{
                     "MuteAmplifier::rescueTokens: that muteToken belongs to stakers"
                 );
             }
+        } else if (tokenToRescue == address(IMuteSwitchPairDynamic(lpToken).token0())) {
+            if (totalStakers > 0) {
+                require(amount <= IERC20(IMuteSwitchPairDynamic(lpToken).token0()).balanceOf(address(this)).sub(totalFees0.sub(totalClaimedFees0)),
+                    "MuteAmplifier::rescueTokens: that token belongs to stakers"
+                );
+            }
+        } else if (tokenToRescue == address(IMuteSwitchPairDynamic(lpToken).token1())) {
+            if (totalStakers > 0) {
+                require(amount <= IERC20(IMuteSwitchPairDynamic(lpToken).token1()).balanceOf(address(this)).sub(totalFees1.sub(totalClaimedFees1)),
+                    "MuteAmplifier::rescueTokens: that token belongs to stakers"
+                );
+            }
         }
 
         IERC20(tokenToRescue).transfer(to, amount);

The issue discussed in this report also ties in with the fact that the fee0 <= totalFees0 && fee1 <= totalFees1 check before transferring fee tokens always passes. It does not prevent the scenario that the sponsor wants to prevent which is when there are not enough fee tokens to be transferred the transfer should not block the function.

So in addition to the above changes I propose to add these changes as well:

diff --git a/contracts/amplifier/MuteAmplifier.sol b/contracts/amplifier/MuteAmplifier.sol
index 9c6fcb5..39cd75b 100644
--- a/contracts/amplifier/MuteAmplifier.sol
+++ b/contracts/amplifier/MuteAmplifier.sol
@@ -255,7 +255,7 @@ contract MuteAmplifier is Ownable{
         }
 
         // payout fee0 fee1
-        if ((fee0 > 0 || fee1 > 0) && fee0 <= totalFees0 && fee1 <= totalFees1) {
+        if ((fee0 > 0 || fee1 > 0) && fee0 < IERC20(IMuteSwitchPairDynamic(lpToken).token0()).balanceOf(address(this)) && fee1 < IERC20(IMuteSwitchPairDynamic(lpToken).token1()).balanceOf(address(this))) {
             address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(msg.sender, fee0);
             address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(msg.sender, fee1);
 
@@ -295,7 +295,7 @@ contract MuteAmplifier is Ownable{
         }
 
         // payout fee0 fee1
-        if ((fee0 > 0 || fee1 > 0) && fee0 <= totalFees0 && fee1 <= totalFees1) {
+        if ((fee0 > 0 || fee1 > 0) && fee0 < IERC20(IMuteSwitchPairDynamic(lpToken).token0()).balanceOf(address(this)) && fee1 < IERC20(IMuteSwitchPairDynamic(lpToken).token1()).balanceOf(address(this))) {
             address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(msg.sender, fee0);
             address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(msg.sender, fee1);
 
@@ -331,7 +331,7 @@ contract MuteAmplifier is Ownable{
             }
 
             // payout fee0 fee1
-            if ((fee0 > 0 || fee1 > 0) && fee0 <= totalFees0 && fee1 <= totalFees1) {
+            if ((fee0 > 0 || fee1 > 0) && fee0 < IERC20(IMuteSwitchPairDynamic(lpToken).token0()).balanceOf(address(this)) && fee1 < IERC20(IMuteSwitchPairDynamic(lpToken).token1()).balanceOf(address(this))) {
                 address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(account, fee0);
                 address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(account, fee1);

An attacker can lower the price of another depositor() by frontrunning

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

Detailed description of the impact of this finding.
The deposit() function will bump bond price back by 5% after purchase based on current delta.
However, this function can be executed unlimited number of times in the same block and as a result, one can exploit this vulnerability to attack another depositor: he can lower the price of another depositor() to the lowest by frontrunning.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

We show how an attacker can lower the price of another depositor() by frontrunning:

  1. The bondPrice will increase the price of the bond from startPrice to maxPrice linearly in terms of the amount of elapsed time since epochStart.
function bondPrice() public view returns (uint) {
        uint timeElapsed = block.timestamp - epochStart;
        uint priceDelta = maxPrice - startPrice;

        if(timeElapsed > epochDuration)
          timeElapsed = epochDuration;

        return timeElapsed.mul(priceDelta).div(epochDuration).add(startPrice);
    }
  1. Meanwhile, the deposit() function will bump bond price back by 5% after purchase based on current delta.
    // adjust price by a ~5% premium of delta
        uint timeElapsed = block.timestamp - epochStart;
        epochStart = epochStart.add(timeElapsed.mul(5).div(100));
        // safety check
        if(epochStart >= block.timestamp)
          epochStart = block.timestamp;
  1. However, there is no restriction in terms how many times deposit() can be called in one block. So an attacker can call it many times (say 50 times) to lower the price of the bond (0.95^50 = 0.0762).

  2. Suppose Alice sees a good price of the bond, so she decides she will buy the bond.

  3. Attacker Bob comes a long and call deposit() 50 times (with the minimum 0.01e18 payout) and reduce the price near startPrice() (see implementation of bondPrice()).

  4. As a result, Alice purchased the bond with a very lower price and she is not happy with the result.

Tools Used

VSCode

Recommended Mitigation Steps

  1. We will make sure within each block the price can be bumped back at most once.

  2. We need to have some slippage control for the depositor so that the depositor will get the minium amount of payout for the input lptoken.

Attacker can take a loan offer without providing the NFT from requested collection by using function `borrowerRefinance()`

Lines of code

https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L343

Vulnerability details

Impact

Function borrowerRefinance() allows the borrower to repay the previous loan and take a different loan offer.

In the codebase, there is no check to ensure that collateral collection of previous loan and new loan offer are the same. It can be abused by an attacker to take a loan offer that requires a different NFT collection without depositing.

function borrowerRefinance(
    Lien calldata lien,
    uint256 lienId,
    uint256 loanAmount,
    LoanOffer calldata offer,
    bytes calldata signature
) external validateLien(lien, lienId) {
    if (lien.borrower != msg.sender) {
        revert CallerIsNotBorrower();
    }

    _takeLoanOffer(offer, signature, lienId, loanAmount, lien.tokenId);

    uint256 debt = computeCurrentDebt(lien.amount, lien.rate, lien.startTime);
    
    // @audit did not check offer take the same collection
    _liens[lienId] = keccak256(
        abi.encode(
            Lien({
                lender: offer.lender,
                borrower: lien.borrower,
                collection: lien.collection,
                tokenId: lien.tokenId,
                amount: loanAmount,
                startTime: block.timestamp,
                rate: offer.rate,
                auctionStartBlock: 0
            })
        )
    );
    ...
}

Proof of Concept

Consider the scenario

  1. Attacker creates a loan offer with collateral token is a trash NFT. He uses a clone account to take this offer on his own.
  2. Attacker calls function borrowerRefinance() to take a offer from a victim. It is possible since it does not have any validation, just assume the previous collateral (trash NFT) could be used as collateral for a new offer.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider adding checks to ensure the previous lien and new offer have the same collection.

A user can 'borrow' dMute balance for a single block to increase their amplifier APY

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L482-L486

Vulnerability details

The amplifier's APY is calculated based on the user's dMute balance (delegation balance to be more accurate) - the more dMute the user holds the higher APY they get.
However, the contract only checks the user's dMute balance at staking, the user doesn't have to hold that balance at any time but at the staking block.

This let's the user to bribe somebody to delegate them their dMute for a single block and stake at the same time.

Since the balance checked is the delegation balance (getPriorVotes) this even makes it easier - since the 'lender' doesn't even need to trust the 'borrower' to return the funds, all the 'lender' can cancel the delegation on their own on the next block.

The lender can also create a smart contract to automate and decentralize this 'service'.

Impact

Users can get a share of the rewards that are supposed to incentivize them to hold dMute without actually holding them.
In other words, the same dMute tokens can be used to increase simultaneous staking of different users.

Proof of Concept

The following code shows that only a single block is being checked to calculate the accountDTokenValue at calculateMultiplier():

        if(staked_block != 0 && enforce)
          accountDTokenValue = IDMute(dToken).getPriorVotes(account, staked_block);
        else
          accountDTokenValue = IDMute(dToken).getPriorVotes(account, block.number - 1);

The block checked when rewarding is the staked block, since enforce is true when applying reward:

        reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true));

Recommended Mitigation Steps

Make sure that the user holds the dMute for a longer time, one way to do it might be to sample a few random blocks between staking and current blocks and use the minimum balance as the user's balance.

The first stake is possible after endTime

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L208-L212

Vulnerability details

Impact

Users can stake after endTime due to the wrong check.

Proof of Concept

When a user stakes LP tokens using MuteAmplifier.stake, stake is not allowed after endTime which is set in initializeDeposit by an admin.

    require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");

But it doesn't check for the first stake, so the first stake can be done after the endTime and it is not correct.

Tools Used

Manual Review

Recommended Mitigation Steps

We should check the endTime for the first stake.

    if (firstStakeTime == 0) {
        firstStakeTime = block.timestamp;
    }
    require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");

dMute.sol: Attacker can push lock items to victim's array such that redemptions are forever blocked

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/dao/dMute.sol#L90-L129
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/dao/dMute.sol#L135-L139

Vulnerability details

Impact

This report deals with how an attacker can abuse the fact that he can lock MUTE tokens for any other user and thereby push items to the array of UserLockInfo structs of the user.

There are two functions in the dMute contract that iterate over all items in this array (RedeemTo and GetUnderlyingTokens).

Thereby if the attacker pushes sufficient items to the array of a user, he can make the above two functions revert since they require more Gas than the Block Gas Limit.

According to the zkSync documentation the block gas limit is currently 12.5 million (Link).

The attack is of "High" impact for the RedeemTo function since this function needs to succeed in order for the user to redeem his MUTE tokens.

The user might have a lot of MUTE tokens locked and the attacker can make it such that they can never be redeemed. The attacker cannot gain a profit from this attack, i.e. he cannot steal anything, but due to the possibility of this attack users will not lock their tokens, especially not a lot of them.

This is all the more severe because the MuteBond and MuteAmplifier contracts also rely on the locking functionality so those upstream features can also not be used securely.

In the Mitigation section I will show how the GetUnderlyingTokens function can be made to run in $O(1)$ time instead of $O(lock:array:length)$.

The RedeemTo function can be made to run in $O(indexes:array:length)$ instead of $O(lock:array:length)$. The length of the indexes array is determined by the user and simply tells how many locked items to redeem. So there is no possibility of DOS.

Proof of Concept

Note: a redemption costs ~7 million Gas when 1000 items are locked. So when running on the zkSync network even 2000 items should be enough. The hardhat tests use a local Ethereum network instead of a fork of zkSync so in order to hit 30 million Gas (which is the Ethereum block gas limit) we need to add more items to the queue.

You can add the following test to the dao.ts test file:

it('Lock DOS', async function () {
    var tx = await muteToken.approve(dMuteToken.address, MaxUint256)


    let lock_time_week = new BigNumber(60 * 60 * 24 * 7);
    let max_lock = lock_time_week.times(52);

    let lock_amount = new BigNumber(1).times(Math.pow(10,2))

    // @audit fill up array
    for(let i=0;i<5000;i++) {
        tx = await dMuteToken.LockTo(lock_amount.toFixed(0), lock_time_week.toFixed(),owner.address)
    }

    await time.increase(60 * 60 * 24 * 7)

    tx = await dMuteToken.Redeem([0])
})

It adds 5000 lock items to the array of the owner address. When the owner then tries to redeem even a single lock the transaction fails due to an out of gas error.

(Sometimes it reverts with TransactionExecutionError: Transaction ran out of gas error sometimes it reverts due to timeout. If you try a few times it should revert with the out of gas error.)

The amount of MUTE tokens that the attacker loses to execute this attack is negligible. As you can see in the test 100 Wei * 5000 = 500,000 Wei is sufficient (There needs to be some amount of MUTE such that the LockTo function does not revert). The only real cost comes down to Gas costs which are cheap on zkSync.

Tools Used

VSCode

Recommended Mitigation Steps

First for the GetUnderlyingTokens function: The contract should keep track of underlying token amounts for each user in a mapping that is updated with every lock / redeem call. The GetUnderlyingTokens function then simply needs to return the value from this mapping.

Secondly, fixing the issue with the RedeemTo function is a bit harder. I discussed this with the sponsor and I have been told they don't want this function to require an already sorted lock_index array as parameter. So the lock_index array can contain indexes in random order.

This means it must be sorted internally. Depending on the expected length of the lock_index array different sorting algorithms may be used. I recommend to use an algorithm like quick sort to allow for many indexes to be specified at once.

I will use a placeholder for the sorting algorithm for now so the sponsor may decide which one to use.

The proposed fixes for both functions are then like this:

diff --git a/contracts/dao/dMute.sol b/contracts/dao/dMute.sol
index 59f95b7..11d21fb 100644
--- a/contracts/dao/dMute.sol
+++ b/contracts/dao/dMute.sol
@@ -18,6 +18,7 @@ contract dMute is dSoulBound {
     }
 
     mapping(address => UserLockInfo[]) public _userLocks;
+    mapping(address => uint256) public _amounts;
 
     uint private unlocked = 1;
 
@@ -79,6 +80,7 @@ contract dMute is dSoulBound {
         _mint(to, tokens_to_mint);
 
         _userLocks[to].push(UserLockInfo(_amount, block.timestamp.add(_lock_time), tokens_to_mint));
+        _amounts[to] = _amounts[to] + _amount;
 
         emit LockEvent(to, _amount, tokens_to_mint, _lock_time);
     }
@@ -91,8 +93,14 @@ contract dMute is dSoulBound {
         uint256 total_to_redeem = 0;
         uint256 total_to_burn = 0;
 
-        for(uint256 i; i < lock_index.length; i++){
-          uint256 index = lock_index[i];
+        ///////////////////////////////////////////////
+        //                                           //
+        // sort lock_index array in ascending order //
+        //                                          //
+        //////////////////////////////////////////////
+
+        for(uint256 i = lock_index.length; i > 0; i--){
+          uint256 index = lock_index[i - 1];
           UserLockInfo memory lock_info = _userLocks[msg.sender][index];
 
           require(block.timestamp >= lock_info.time, "dMute::Redeem: INSUFFICIENT_LOCK_TIME");
@@ -102,23 +110,14 @@ contract dMute is dSoulBound {
           total_to_redeem = total_to_redeem.add(lock_info.amount);
           total_to_burn = total_to_burn.add(lock_info.tokens_minted);
 
-          _userLocks[msg.sender][index] = UserLockInfo(0,0,0);
+          _userLocks[msg.sender][index] = _userLocks[msg.sender][_userLocks[msg.sender].length - 1];
+          _userLocks[msg.sender].pop();
         }
 
         require(total_to_redeem > 0, "dMute::Lock: INSUFFICIENT_REDEEM_AMOUNT");
         require(total_to_burn > 0, "dMute::Lock: INSUFFICIENT_BURN_AMOUNT");
 
-
-        for(uint256 i = _userLocks[msg.sender].length; i > 0; i--){
-          UserLockInfo memory lock_info = _userLocks[msg.sender][i - 1];
-
-          // recently redeemed lock, destroy it
-          if(lock_info.time == 0){
-            _userLocks[msg.sender][i - 1] = _userLocks[msg.sender][_userLocks[msg.sender].length - 1];
-            _userLocks[msg.sender].pop();
-          }
-        }
-
+        _amounts[msg.sender] = _amounts[msg.sender] + total_to_redeem;
         //redeem tokens to user
         IERC20(MuteToken).transfer(to, total_to_redeem);
         //burn dMute
@@ -133,8 +132,6 @@ contract dMute is dSoulBound {
     }
 
     function GetUnderlyingTokens(address account) public view returns(uint256 amount) {
-        for(uint256 i; i < _userLocks[account].length; i++){
-          amount = amount.add(_userLocks[account][i].amount);
-        }
+        return _amounts[account];
     }
 }

Upgraded Q -> 2 from #17 [1680620718364]

Judge has assessed an item in Issue #17 as 2 risk. The relevant finding follows:

[L-05] Check that staking cannot occur when endTime is reached
The MuteAmplifier.stake function should require that the current timestamp is smaller than endTime even when the call to stake is the first that ever occurred.
Currently the check is only made in the case that the call to stake is not the first.
The check should be made in both cases.
This is because when staking occurs when block.timestamp >= endTime, no rewards will be paid out. Additionally the user needs to pay the management fee on his LP token stake. So there is really no point in allowing users to do it because it only hurts them.

Fix:

diff --git a/contracts/amplifier/MuteAmplifier.sol b/contracts/amplifier/MuteAmplifier.sol
index 9c6fcb5..460c408 100644
--- a/contracts/amplifier/MuteAmplifier.sol
+++ b/contracts/amplifier/MuteAmplifier.sol
@@ -202,13 +202,12 @@ contract MuteAmplifier is Ownable{
*/
function stake(uint256 lpTokenIn) external virtual update nonReentrant {
require(lpTokenIn > 0, "MuteAmplifier::stake: missing stake");

  •    require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
       require(block.timestamp >= startTime && startTime !=0, "MuteAmplifier::stake: not live yet");
       require(IERC20(muteToken).balanceOf(address(this)) > 0, "MuteAmplifier::stake: no reward balance");
    
       if (firstStakeTime == 0) {
           firstStakeTime = block.timestamp;
    
  •    } else {
    
  •        require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
       }
    
       lpToken.safeTransferFrom(msg.sender, address(this), lpTokenIn);
    

Upgraded Q -> 2 from #17 [1681332048307]

Judge has assessed an item in Issue #17 as 2 risk. The relevant finding follows:

[L-07] First user that stakes again after a period without stakers receives too many rewards
The MuteAmplifier contract pays out rewards on a per second basis.
Let's assume there is only 1 staker which is Bob.

Say Bob calls stake at timestamp 0 and calls withdraw at timestamp 10. He receives rewards for 10 seconds of staking.

At timestsamp 30 Bob calls stake again (there were no stakers from timestamp 10 to timestamp 30).
If Bob calls withdraw at say timestamp 40, he receives not only rewards for the 10 seconds he has staked but for 30 seconds (timestamp 10 to timestamp 40).

This means that whenever there are temporarily no stakers, whoever first stakes again receives all the rewards from the previous period without stakers.

This is due to how the update modifier works.

When someone stakes and there were no other stakers, the if block is not entered and the _mostRecentValueCalcTime variable is not updated.

So when the update modifier is executed again the staker also receives the rewards from the period when there were no stakers.

I just want to make the sponsor aware of this behavior. The sponsor may decide that this is unintended and needs to change. I think this might even be a beneficial behavior because it incentivises users to stake if there are no stakers because they will get more rewards.

Function `takeBid()` allows attacker to sell any collateral NFT that deposited through function `borrowToBuy()`

Lines of code

https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L432
https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L519

Vulnerability details

Impact

Function borrowToBuy() is used by the borrower to take a loan offer and uses the funds to purchase NFT. However, even though it sends ETH along when calling function execute() to buy the requested NFT, it approves the collateral NFT to Blur for no reason.

/* Execute marketplace order. */
offer.collection.approve(_DELEGATE, collateralTokenId);
_EXCHANGE.execute{ value: execution.value }(execution.sell, execution.buy);

As a result, given the approval is there, the attacker can use function takeBid() to sell any locked NFT that is previously approved to Blur.

/* Execute marketplace order. */
lien.collection.approve(_DELEGATE, lien.tokenId);

// @audit can sell token of other loan because it's approved in borrowToBuy()
_EXCHANGE.execute(execution.sell, execution.buy); 

Proof of Concept

  1. Alice (victim) uses the function borrowToBuy() to buy an NFT with a Cryptopunk NFT. This Cryptopunk is locked as collateral for her loan and also be approved to _DELEGATE.
  2. Attacker calls takeBid() with Execution data to sell Alice's Cryptopunk. Since the approval is given in step 1, it will not fail and successfully sell Alice's collateral NFT.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider removing unnecessary approval of collateral tokens.

MuteBond is susceptible to DOS

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L179
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L75-L77
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L57

Vulnerability details

Proof of Concept

Observe that if timeToTokens is called with _lock_time = 1 week, _amount < 52, it will return 0.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L57

function timeToTokens(uint256 _amount, uint256 _lock_time) internal pure returns (uint256){
        uint256 week_time = 1 weeks;
        uint256 max_lock = 52 weeks;

        require(_lock_time >= week_time, "dMute::Lock: INSUFFICIENT_TIME_PARAM");
        require(_lock_time <= max_lock, "dMute::Lock: INSUFFICIENT_TIME_PARAM");

        // amount * % of time locked up from min to max
        uint256 base_tokens = _amount.mul(_lock_time.mul(10**18).div(max_lock)).div(10**18);
        // apply % min max bonus
        //uint256 boosted_tokens = base_tokens.mul(lockBonus(lock_time)).div(10**18);

        return base_tokens;
    }

This causes lockTo to revert.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L75-L77

 function LockTo(uint256 _amount, uint256 _lock_time, address to) public nonReentrant {
        require(IERC20(MuteToken).balanceOf(msg.sender) >= _amount, "dMute::Lock: INSUFFICIENT_BALANCE");

        //transfer tokens to this contract
        IERC20(MuteToken).transferFrom(msg.sender, address(this), _amount);

        // calculate dTokens to mint
        uint256 tokens_to_mint = timeToTokens(_amount, _lock_time);

        require(tokens_to_mint > 0, 'dMute::Lock: INSUFFICIENT_TOKENS_MINTED');

        _mint(to, tokens_to_mint);

        _userLocks[to].push(UserLockInfo(_amount, block.timestamp.add(_lock_time), tokens_to_mint));

        emit LockEvent(to, _amount, tokens_to_mint, _lock_time);
    }

The deposit function of muteBond calls lockTo with _amount = payout.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L179
IDMute(dMuteToken).LockTo(payout, bond_time_lock, _depositor);

Observe that regardless what the inputs are, payout <= maxDeposit() is always satisfied after the following code segment.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L155-L164

        uint payout = payoutFor( value );
        if(max_buy == true){
          value = maxPurchaseAmount();
          payout = maxDeposit();
        } else {
          // safety checks for custom purchase
          require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection )
          require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage
          require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
        }

So, if an attacker manipulates the muteBond to get maxDeposit() < 52, deposit will always fail.
Please add the following test case to bonds.ts and run it with npm run test-bond
Note that if the bond price is too high (> 52e18), then this won't always be possible (because payout will change by bondPrice every time we increment/decrement value). So in my POC, I set the price range to be (1e18 - 2e18), which I believe are reasonable values as well.

it('Bond DOS', async function () {

    await bondContract.setStartPrice(new BigNumber(1).times(Math.pow(10,18)).toFixed())
    await bondContract.setMaxPrice(new BigNumber(2).times(Math.pow(10,18)).toFixed())
    await bondContract.setMaxPayout(new BigNumber(100).times(Math.pow(10,18)).toFixed())
    
    // ideally, the following line is what I had in mind
    // var val = new BigNumber((await bondContract.maxPurchaseAmount()).toString()).minus(1).toFixed()
    // but due to timing issues I couldn't get it to work (I'm not very familiar with hardhat)

    // so I just ran this to get the value for the next line
    // await time.increase(1)
    // console.log(await bondContract.maxPurchaseAmount());


    var val = new BigNumber("99998511926905849653").toFixed();
    
    console.log("before:")
    console.log(await bondContract.maxDeposit())
    /*
    console.log(await bondContract.payoutFor(val))
    console.log(await bondContract.maxPayout())
    console.log((await bondContract.maxPurchaseAmount()))
    */
    
    await bondContract.connect(buyer1).deposit(val, buyer1.address, false)

    console.log("after:")
    console.log(await bondContract.maxDeposit())
    /*
    console.log(await bondContract.payoutFor(val))
    console.log(await bondContract.maxPayout())
    console.log((await bondContract.maxPurchaseAmount()))
    */

    await expect(
      bondContract.connect(buyer1).deposit(1, buyer1.address, false)
    ).to.be.reverted;
    
    
    await expect(
      bondContract.connect(buyer1).deposit(1, buyer1.address, true)
    ).to.be.reverted;
    

    await expect(
      bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), buyer1.address, false)
    ).to.be.reverted;

    await expect(
      bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), buyer1.address, true)
    ).to.be.reverted;
  })

Impact

This vulnerability causes deposit to fail indefinitely. That being said, the contract itself doesn't seem to store funds, and it looks like there are ways for the admin to manually fix the DOS (e.g. deploy a new contract, set startPrice / maxPrice). So overall, I would say it warrants a medium severity.

Tools Used

Manual Review, Hardhat

Recommended Mitigation Steps

Start a new epoch if maxDeposit() is smaller than a certain threshold.

Params of Lien struct are not emitted when lien is created making it difficult to track

Lines of code

https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L306
https://github.com/code-423n4/2023-03-contest225/blob/af270a1fb366c8dda0fbbdd7d6ddaf6f0011992f/contracts/Core.sol#L188

Vulnerability details

Impact

Protocol does not store any information about Lien. When users want to interact, they have to send the whole Lien struct along with lienId, and the protocol will verify if this data is correct by hash.

This approach reduces onchain storage and can save a lot of gas. However, it also creates difficulty to interact with protocol. The issue here is some params of lien are not emitted in the event when the lien is created or updated. As a result, users cannot find the correct state of the lien and hence are unable to interact with protocol.

Below is an example where startTime is not emitted when lien is updated in function refinanceAuction()

/* Reset the lien with the new lender and interest rate. */
_liens[lienId] = keccak256(
    abi.encode(
        Lien({
            lender: msg.sender,
            borrower: lien.borrower,
            collection: lien.collection,
            tokenId: lien.tokenId,
            amount: debt,
            startTime: block.timestamp, // @audit not emit event, hard to track
            rate: rate,
            auctionStartBlock: 0
        })
    )
);

/* Repay the initial loan. */
pool.transferFrom(msg.sender, lien.lender, debt);

emit Refinance(lienId, address(lien.collection), msg.sender, debt, lien.rate);

Proof of Concept

Another example is auctionStartBlock is not emitted in function startAuction()

/* Add auction start block to lien. */
_liens[lienId] = keccak256(
    abi.encode(
        Lien({
            lender: lien.lender,
            borrower: lien.borrower,
            collection: lien.collection,
            tokenId: lien.tokenId,
            amount: lien.amount,
            startTime: lien.startTime,
            rate: lien.rate,
            auctionStartBlock: block.number // @audit not emitted
        })
    )
);

emit StartAuction(lienId, address(lien.collection));

Advanced users can still track this param by looking onchain to find the timestamp and block number of their transactions. However, this is error-prone and not recommended.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider emitting all the data of Lien when it is updated or created.

Upgraded Q -> 2 from #17 [1680620822176]

Judge has assessed an item in Issue #17 as 2 risk. The relevant finding follows:

[L-10] It is possible in theory that stakes get locked due to call to LockTo with very small reward amount
I pointed out and explained in my report #7 MuteBond.sol: deposit function reverts if remaining payout is very small due to >0 check in dMute.LockTo function how the MuteBond.LockTo function reverts when it is called with an amount <= 52 Wei.

While in the MuteBond contract an attacker can actively make this situation occur and cause a temporary DOS, this is not possible in the MuteAmplifier contract.

The MuteAmplifier contract makes two calls to MuteBond.LockTo:

Link

if (reward > 0) {
uint256 week_time = 60 * 60 * 24 * 7;
IDMute(dToken).LockTo(reward, week_time ,msg.sender);

userClaimedRewards[msg.sender] = userClaimedRewards[msg.sender].add(
    reward
);
totalClaimedRewards = totalClaimedRewards.add(reward);


emit Payout(msg.sender, reward, remainder);

}
Link

if (reward > 0) {
uint256 week_time = 1 weeks;
IDMute(dToken).LockTo(reward, week_time ,msg.sender);

userClaimedRewards[msg.sender] = userClaimedRewards[msg.sender].add(
    reward
);
totalClaimedRewards = totalClaimedRewards.add(reward);

}
In theory there exists the possibility that the rewards that are paid out to a user are > 0 Wei and <= 52 Wei.

If at the endTime this is the case, the rewards will not increase anymore, making it impossible for the staker to withdraw his staked funds, which results in a complete loss of funds.

However with any reasonable value of totalRewards this is not going to occur. Actually it's a real challenge to make the contract output a reward of > 0 Wei and <= 52 Wei.

It might be beneficial to implement the following changes just to be safe:

diff --git a/contracts/amplifier/MuteAmplifier.sol b/contracts/amplifier/MuteAmplifier.sol
index 9c6fcb5..37adc7f 100644
--- a/contracts/amplifier/MuteAmplifier.sol
+++ b/contracts/amplifier/MuteAmplifier.sol
@@ -242,7 +242,7 @@ contract MuteAmplifier is Ownable{
IERC20(muteToken).transfer(treasury, remainder);
}
// payout rewards

  •    if (reward > 0) {
    
  •    if (reward > 52) {
           uint256 week_time = 60 * 60 * 24 * 7;
           IDMute(dToken).LockTo(reward, week_time ,msg.sender);
    

@@ -284,7 +284,7 @@ contract MuteAmplifier is Ownable{
IERC20(muteToken).transfer(treasury, remainder);
}
// payout rewards

  •    if (reward > 0) {
    
  •    if (reward > 52) {
           uint256 week_time = 1 weeks;
           IDMute(dToken).LockTo(reward, week_time ,msg.sender);
    

In case rewards are <= 52 Wei they will be lost. But they are worthless anyway.

`MuteAmplifier.rescueTokens()` should check conditions for fee tokens(token0/token1) as well

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/amplifier/MuteAmplifier.sol#L180

Vulnerability details

Impact

rescueTokens() can be used to withdraw fee tokens without any validations.

As a result, the reward logic would be broken due to the lack of fee tokens.

Proof of Concept

rescueTokens() doesn't validate anything for the fee tokens.

So if some fee tokens were withdrawn, the reward withdrawal logic will revert as the contract doesn't have enough balance.

File: MuteAmplifier.sol
257:         // payout fee0 fee1
258:         if ((fee0 > 0 || fee1 > 0) && fee0 <= totalFees0 && fee1 <= totalFees1) {
259:             address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(msg.sender, fee0);
260:             address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(msg.sender, fee1);
261: 
262:             totalClaimedFees0 = totalClaimedFees0.add(fee0);
263:             totalClaimedFees1 = totalClaimedFees1.add(fee1);
264: 
265:             emit FeePayout(msg.sender, fee0, fee1);
266:         }

Tools Used

Manual Review

Recommended Mitigation Steps

rescueTokens() should validate for the fee tokens as well.

    function rescueTokens(address tokenToRescue, address to, uint256 amount) external virtual onlyOwner nonReentrant {
        address token0 = IMuteSwitchPairDynamic(lpToken).token0();
        address token1 = IMuteSwitchPairDynamic(lpToken).token1();

        ...
        ...
        else if (tokenToRescue == token0) {
            require(amount <= IERC20(token0).balanceOf(address(this)).sub(totalFees0.sub(totalClaimedFees0)),
                "MuteAmplifier::rescueTokens: that token0 belongs to stakers"
            );
        }
        else if (tokenToRescue == token1) {
            require(amount <= IERC20(token1).balanceOf(address(this)).sub(totalFees1.sub(totalClaimedFees1)),
                "MuteAmplifier::rescueTokens: that token1 belongs to stakers"
            );
        }

        IERC20(tokenToRescue).transfer(to, amount);
    }

DOS attack to RedeemTo() and GetUnderlyingTokens(), leading to loss of funds.

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/dao/dMute.sol#L135-L139

Vulnerability details

Impact

Detailed description of the impact of this finding.
An attacker can launch a DOS attack to RedeemTo() and GetUnderlyingTokens() so that it will always fail for a particular account, say Bob. In this way, Bob will not be able to redeem the MuteToken
locked under him. He will lose funds.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

We show how an attacker, Alice, can launch a DOS attack to RedeemTo() and GetUnderlyingTokens():

  1. call LockTo(1, 52 weeks, Bob) repeatedly. As a result, the length of the array _userLocks[Bob] will be very large. The attacker only needs to spend 1wei of MuteToken.

  2. When Bob calls RedeemTo(), the function will always fail due the the following for-loop as it iterates through each element in the array _userLocks[Bob]. When the length of the array is too large, this loop will run out of gas.

 for(uint256 i = _userLocks[msg.sender].length; i > 0; i--){
          UserLockInfo memory lock_info = _userLocks[msg.sender][i - 1];

          // recently redeemed lock, destroy it
          if(lock_info.time == 0){
            _userLocks[msg.sender][i - 1] = _userLocks[msg.sender][_userLocks[msg.sender].length - 1];
            _userLocks[msg.sender].pop();
          }
        }
  1. GetUnderlyingTokens(Bob) will always fail due to the large length of the array _userLocks[Bob] since the function needs to iterate through each element in the array. It will run out of gas.

Tools Used

VScode

Recommended Mitigation Steps

  1. Use mapping instead of array, avoiding iterating through an array who length can be very large
  2. set a minimum lock value to increase the cost for attacker.

MuteBond.sol: price discount can be manipulated which undermines its purpose of reflecting demand

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L185-L187
https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L161

Vulnerability details

Impact

The bondPrice in the MuteBond contract increases linearly during the epochDuration from startPrice in the beginning to maxPrice in the end.

The bondPrice determines how many MUTE tokens a user receives for bonding his LP tokens. The higher the bondPrice the more MUTE tokens a user receives for bonding LP tokens (Link). This is to incentivise bonding.

In order to understand this issue it is important to understand a second mechanism that impacts the bondPrice: Every call to MuteBond.deposit increases epochStart by 5% of timeElapsed:

Link

// adjust price by a ~5% premium of delta
uint timeElapsed = block.timestamp - epochStart;
epochStart = epochStart.add(timeElapsed.mul(5).div(100));

The result of this is that the price discount, i.e. how much the bondPrice is above startPrice, is reduced by 5%. The reason I call it discount even though it's an increase is that it discounts the price of MUTE relative to LP tokens.

I discussed this with the sponsor and the reason they implemented this mechanism is to reflect supply and demand. The more bonds people create (higher demand) the higher the price they need to pay (discount is lowered which means they get less MUTE).
Similarly if the demand is low the discount is not lowered and people are incentivised to create bonds. The calculation does not need to be exact it's just a rough mechanism.

Now, the issue is that the minimum amount of LP tokens to call the deposit function with is 0.01 * 1e18, i.e. one hundreth of a token. (Assuming that bondPrice is 1e18 since you need to multiply by bondPrice to get from LP token amount to MUTE token amount)

At the current value of the LP token of the MUTE / ETH pool this is 0.01 * $88 = $0.88.

As of now, 1 LP token can withdraw 39.78 MUTE and 0.02513 ETH which is ~$88.

mute_eth_pool

This means that with a deposit of only $0.88 an attacker can influence this mechanism that should reflect supply and demand.

By depositing such a small amount of LP tokens we cannot speak of actual supply and demand determining the price. The attacker is just manipulating the discount, preventing it to go up.

The sponsor agrees with this and suggested to increase the minimum deposit such that a deposit reflects an actual demand.

Proof of Concept

Add the following test to the dao.ts test file.

it('Purchase exact amount of bond', async function () {
    // halfway into the duration
    var set_time = (await time.latest()).plus(60 * 60 * 24 * 3.5)
    await time.setNextBlockTime(set_time)

    // legitimate transaction to buy a bond
    await bondContract.connect(buyer1).deposit(new BigNumber(10).times(Math.pow(10,18)).toFixed(), buyer1.address, false)

    // now the attacker makes the price drop; meaning the bond becomes more expensive
    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)

    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)
    
    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)

    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)

    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)
    
    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)

    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)

    console.log("Bond price");
    console.log(await bondContract.bondPrice());
    await bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,16)).toFixed(), buyer1.address, false)
  })

The attacker makes the price drop from 145125992063492063492 to 133172949735449735449 without any real deposit volume occurring.

The attacker deposited 0.01 LP tokens 8 times. As explained above the price should only drop (making the bond more expensive) when there is actual demand.

Tools Used

VSCode

Recommended Mitigation Steps

I discussed this issue with the sponsor and they suggested that the minimum payout amount should be 10% of maxPayout. I agree that a payout of >=10%*maxPayout reflects actual demand and therefore is a reasonable value.

Therefore the suggested fix looks like this:

diff --git a/contracts/bonds/MuteBond.sol b/contracts/bonds/MuteBond.sol
index 96ee755..d16a1b0 100644
--- a/contracts/bonds/MuteBond.sol
+++ b/contracts/bonds/MuteBond.sol
@@ -159,6 +159,7 @@ contract MuteBond {
         } else {
           // safety checks for custom purchase
           require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection )
+          require( payout >= (maxPayout / 10), "Bond too small" ); 
           require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage
           require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage
         }

The sponsor may also explore ways to adjust the price by an amount that is weighted by the payout amount. I.e. lower payout -> less price adjustment, higher payout -> more price adjustment

QA Report

See the markdown file with the details of this report here.

No slippage control for deposit() with the impact that a user deposits with expected high bond price might end up a deposit with the lowest bond price.

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

Vulnerability details

Impact

Detailed description of the impact of this finding.

There is no slippage control for deposit().

Impact: a user deposits with expected high bond price might end up a deposit with the lowest bond price.

Scenario: a depositor waits for the end of an epoch, expecting to enjoy a good price near maxPrice. When he finally calls deposit(), another user front-runs the transaction with deposit() and push the first depositor into a new epoch, with the lowest price for bond, the startPrice. The first depositor ends up a deposit with the lowest price startPrice instead - a big surprise and disappointment.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

  1. The deposit() function uses the bondPrice() to calculate the payout for each depositor. The price of the bond will increase from startPrice to maxPrice with slight fallback after each deposit.

https://github.com/code-423n4/2023-03-mute/blob/4d8b13add2907b17ac14627cfa04e0c3cc9a2bed/contracts/bonds/MuteBond.sol#L153-L200

  1. For each epoch, there is a total maxPayout limit, when it is reached, the protocol enters into a new epoch, and the bond price starts from startPrice again.
   if(terms[epoch].payoutTotal == maxPayout){
            terms.push(BondTerms(0,0,0));
            epochStart = block.timestamp;
            epoch++;
        }
  1. The problem lies in the possibility of the following front-running: a depositor waits for the end of an epoch, expecting to enjoy a good price near maxPrice. When he finally calls deposit(), another user front-runs the transaction with deposit() and push the first depositor into a new epoch, with the lowest price for bond, the startPrice.

  2. While we cannot prevent front-running, we should give the first depositor a slippage control, so that he will not buy the bond with a price lower than he expected. He expected a price near maxPrice, not a price near startPrice.

Tools Used

VSCode

Recommended Mitigation Steps

Add a slippage control to deposit() so that a user will only deposit with the bond price he expected.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.