Platform: Code4rena
Start Date: 28/03/2023
Pot Size: $23,500 USDC
Total HM: 12
Participants: 5
Period: 6 days
Judge: Picodes
Total Solo HM: 3
Id: 227
League: ETH
Rank: 3/5
Findings: 5
Award: $0.00
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xA5DF
Also found by: HollaDieWaldfee, hansfriese
Data not available
Users might deposit more LPs unexpectedly if a malicious user increases an epoch by frontrunning.
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.
maxPayout = 1000, epoch = 1, maxDeposit() = 100
. An honest user called deposit()
with max_buy = true
to purchase bonds for 100 payouts.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.epoch = 2, payout = maxDeposit() = 10000
and it will try to transfer LP tokens for 10000 payouts from the user.type(uint256).max
like this test, he will be suffered to use more LPs than his expectations.Manual Review
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
).
#0 - c4-judge
2023-04-04T13:23:22Z
Picodes marked the issue as duplicate of #25
#1 - c4-judge
2023-04-07T17:44:36Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-10T22:54:10Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: hansfriese
Also found by: evan
Data not available
There will be 2 impacts.
totalReclaimed
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.
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.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.Manual Review
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" ); } }
#0 - c4-judge
2023-04-04T14:06:21Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-04-07T15:26:34Z
mattt21 marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-10T22:10:04Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-10T22:10:07Z
Picodes marked the issue as selected for report
🌟 Selected for report: evan
Also found by: HollaDieWaldfee, chaduke, hansfriese
Data not available
Users can stake after endTime due to the wrong check.
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.
Manual Review
We should check the endTime for the first stake.
if (firstStakeTime == 0) { firstStakeTime = block.timestamp; } require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over");
#0 - c4-judge
2023-04-04T14:13:33Z
Picodes marked the issue as duplicate of #23
#1 - c4-judge
2023-04-10T22:07:55Z
Picodes marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: evan, hansfriese
Data not available
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.
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: }
Manual Review
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); }
#0 - c4-judge
2023-04-04T14:26:19Z
Picodes marked the issue as primary issue
#1 - c4-judge
2023-04-04T14:28:14Z
Picodes marked the issue as duplicate of #18
#2 - c4-judge
2023-04-10T22:02:35Z
Picodes marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xA5DF, chaduke, hansfriese
Data not available
The Mutebond
contract might stop working after the owner decreased maxPayout
by a malicious frontrunner.
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.
epoch = 1, maxPayout = 1000, terms[epoch].payoutTotal = 500
maxPayout
to 800 for some reason and called setMaxPayout()
with _payout = 800
.deposit()
with value = 400
.deposit()
is called, terms[epoch].payoutTotal = 900
and maxPayout
will be 800.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.
Manual Review
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); }
#0 - c4-judge
2023-04-04T13:07:06Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-04-06T15:47:42Z
mattt21 marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-10T22:11:30Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-10T22:12:06Z
Picodes marked issue #14 as primary and marked this issue as a duplicate of 14
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xA5DF, chaduke, evan, hansfriese
Data not available
MuteAmplifier.rescueTokens()
should check pre/post balances for 2 addresses tokensIt's safe to check pre/post balances for 2 addresses tokens.
// payout fee0 fee1 if ((fee0 > 0 || fee1 > 0) && fee0 <= totalFees0 && fee1 <= totalFees1) { address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(msg.sender, fee0); address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(msg.sender, fee1); totalClaimedFees0 = totalClaimedFees0.add(fee0); totalClaimedFees1 = totalClaimedFees1.add(fee1); emit FeePayout(msg.sender, fee0, fee1); }
When it checks for fee payouts, it doesn't consider totalClaimedFees0/1
at all. It should be checked like the below.
// payout fee0 fee1 if ((fee0 > 0 || fee1 > 0) && totalClaimedFees0 + fee0 <= totalFees0 && totalClaimedFees1 + fee1 <= totalFees1) { address(IMuteSwitchPairDynamic(lpToken).token0()).safeTransfer(msg.sender, fee0); address(IMuteSwitchPairDynamic(lpToken).token1()).safeTransfer(msg.sender, fee1); totalClaimedFees0 = totalClaimedFees0.add(fee0); totalClaimedFees1 = totalClaimedFees1.add(fee1); emit FeePayout(msg.sender, fee0, fee1); }
MuteBond.setMaxPrice()
and MuteBond.setStartPrice()
should check if maxPrice >= startPrice
function setMaxPrice(uint _price) external { require(msg.sender == customTreasury.owner()); maxPrice = _price; emit MaxPriceChanged(_price); } /** * @notice sets the start price for the LP token * @param _price uint */ function setStartPrice(uint _price) external { require(msg.sender == customTreasury.owner()); startPrice = _price; emit StartPriceChanged(_price); }
When the above functions are called by the owner, maxPrice >= startPrice
should be checked like here.
Otherwise, this one will revert during deposit.
MuteBond.setEpochDuration()
should check if epochDuration > 0
epochDuration
should be greater than 0, otherwise, this line will revert during deposit.
MuteBond.setStartPrice()
should check if startPrice > 0
If startPrice = 0
, bondPrice might be 0 at the start time of epoch and this line will revert during deposit.
>
instead of >=
require(lock_info.amount >= 0 , "dMute::Redeem: INSUFFICIENT_AMOUNT"); require(lock_info.tokens_minted >= 0 , "dMute::Redeem: INSUFFICIENT_MINT_AMOUNT");
Currently, the above conditions will be true with the empty info. There is no serious impact but it will work without reverting for the wrong indices. It should use >
instead.
#0 - c4-judge
2023-04-04T15:05:43Z
Picodes marked the issue as grade-b