Malt Finance contest - cmichel's results

Yield farmable, incentive-centric algorithmic stable coin.

General Information

Platform: Code4rena

Start Date: 25/11/2021

Pot Size: $80,000 USDC

Total HM: 35

Participants: 32

Period: 7 days

Judge: GalloDaSballo

Total Solo HM: 27

Id: 59

League: ETH

Malt Finance

Findings Distribution

Researcher Performance

Rank: 4/32

Findings: 8

Award: $6,373.90

🌟 Selected for report: 10

πŸš€ Solo Findings: 4

Findings Information

Labels

bug
duplicate
2 (Med Risk)

Awards

20.04 USDC - $20.04

External Links

Handle

cmichel

Vulnerability details

The contracts are missing slippage checks which can lead to being vulnerable to sandwich attacks.

A common attack in DeFi is the sandwich attack. Upon observing a trade of asset X for asset Y, an attacker frontruns the victim trade by also buying asset Y, lets the victim execute the trade, and then backruns (executes after) the victim by trading back the amount gained in the first trade. Intuitively, one uses the knowledge that someone’s going to buy an asset, and that this trade will increase its price, to make a profit. The attacker’s plan is to buy this asset cheap, let the victim buy at an increased price, and then sell the received amount again at a higher price afterwards.

See UniswapHandler.buyMalt:

router.swapExactTokensForTokens(
  rewardBalance,
  // @audit allows any out amount, can trade at bad price
  0, // amountOutMin
  path,
  address(this),
  now
);

Impact

Trades can happen at a bad price and lead to receiving fewer Malt tokens than at a fair market price. The attacker's profit is the protocol's loss.

Add minimum return amount checks. Accept a function parameter that can be chosen by the transaction sender, then check that the actually received amount is above this parameter.

Alternatively, check if it's feasible to send these transactions directly to a miner (flashbots) such that they are not visible in the public mempool.

#0 - 0xScotch

2021-12-10T00:17:10Z

#219

#1 - GalloDaSballo

2022-01-09T22:13:55Z

Duplicate of #219

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

1103.7569 USDC - $1,103.76

External Links

Handle

cmichel

Vulnerability details

The MovingAverage.sol contract defines several variables that in the end make the samples array act as a ring buffer:

  • sampleMemory: The total length (buffer size) of the samples array. samples is initialized with sampleMemory zero observations.
  • counter: The pending sample index (modulo sampleMemory)

The _getFirstSample function computes the first sample as (counter + 1) % sampleMemory which returns the correct index only if the ring buffer is full, i.e., it wraps around. (in the counter + 1 >= sampleMemory).

If the samples array does not wrap around yet, the zero index should be returned instead.

Impact

Returning counter + 1 if counter + 1 < sampleMemory returns a zero initialized samples observation index. This then leads to a wrong computation of the TWAP.

Add an additional check for if (counter + 1 < sampleMemory) return 0 in _getFirstSample.

#0 - 0xScotch

2021-12-08T12:54:34Z

Funds aren't directly at risk. I believe this is medium severity

#1 - GalloDaSballo

2022-01-10T01:04:09Z

Personally am not sure the first sample after wrapping would be counter + 1 (why not counter % sampleMemory)

That said, I do agree that before wrapping, the first item in the array is at the 0 index

I agree that the protocol is not behaving properly so I can understand the high severity, that said I also agree with the Sponsor's statement, the worst case would be a leak of value, so Medium severity seems the most appropriate

Findings Information

🌟 Selected for report: cmichel

Also found by: gzeon

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

496.6906 USDC - $496.69

External Links

Handle

cmichel

Vulnerability details

The UniswapHandler.maltMarketPrice function returns a tuple of the price and the decimals of the price. However, the returned decimals do not match the computed price for the else if (rewardDecimals < maltDecimals) branch:

else if (rewardDecimals < maltDecimals) {
  uint256 diff = maltDecimals - rewardDecimals;
  price = (rewardReserves.mul(10**diff)).mul(10**rewardDecimals).div(maltReserves);
  decimals = maltDecimals;
}

Note that rewardReserves are in reward token decimals, maltReserves is a malt balance amount (18 decimals). Then, the returned amount is in rewardDecimals + diffDecimals + rewardDecimals - maltDecimals = maltDecimals + rewardDecimals - maltDecimals = rewardDecimals. However decimals = maltDecimals is wrongly returned.

Impact

Callers to this function will receive a price in unexpected decimals and might inflate or deflate the actual amount. Luckily, the AuctionEscapeHatch decides to completely ignore the returned decimals and as all prices are effectively in rewardDecimals, even if stated in maltDecimals, it currently does not seem to lead to an issue.

Recommendation

Fix the function by returning rewardDecimals instead of maltDecimals in the rewardDecimals < maltDecimals branch.

#0 - GalloDaSballo

2022-01-11T23:31:37Z

Finding is valid, because the returned value is unused, I agree with the medium severity

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1103.7569 USDC - $1,103.76

External Links

Handle

cmichel

Vulnerability details

The Permissions.reassignGlobalAdmin function is supposed to only be run with the TIMELOCK_ROLE role, see onlyRole(TIMELOCK_ROLE, "Only timelock can assign roles").

However, the TIMELOCK_ROLE is not the admin of all the reassigned roles and the revokeRole(role, oldAccount) calls will fail as it requires the ADMIN_ROLE.

The idea might have been that only the TIMELOCK should be able to call this function, and usually it is also an admin, but the function strictly does not work if the caller only has the TIMELOCK roll and will revert in this case. Maybe governance decided to remove the admin role from the Timelock, which makes it impossible to call reassignGlobalAdmin anymore as both the timelock and admin are locked out.

#0 - GalloDaSballo

2022-01-23T01:50:38Z

The warden has identified a flaw in the roles implementation, while the system seems to work when the timelock has multiple roles, the name of the roles implies a different functionality than what can actually be done. The sponsor confirms. While I believe the impact in the demo setup to be fairly minor, because the finding shows a flow in the role setup, and the sponsor confirmed, I agree with medium severity

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1103.7569 USDC - $1,103.76

External Links

Handle

cmichel

Vulnerability details

Certain ERC20 tokens make modifications to their ERC20's transfer or balanceOf functions. One type of these tokens is deflationary tokens that charge a certain fee for every transfer() or transferFrom().

Impact

The Bonding._bond() function will revert in the _balanceCheck when transferring a fee-on-transfer token as it assumes the entire amount was received.

To support fee-on-transfer tokens, measure the asset change right before and after the asset-transferring calls and use the difference as the actual bonded amount.

#0 - GalloDaSballo

2022-01-23T01:54:06Z

Agree with the finding, the check will revert on a token that takes fees as the system assumes that amount is the amount that will be received

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1103.7569 USDC - $1,103.76

External Links

Handle

cmichel

Vulnerability details

When adding liquidity through UniswapHandler.addLiquidity, the entire contract balances are used to add liquidity and the min amounts are set to 95% of these balances. If the balances in this contract are unbalanced (the ratio is not similar to the current Uniswap pool reserve ratios) then this function will revert and no liquidity is added.

See UniswapHandler.buyMalt:

(maltUsed, rewardUsed, liquidityCreated) = router.addLiquidity(
  address(malt),
  address(rewardToken),
  maltBalance, // @audit-info amountADesired
  rewardBalance,
  // @audit assumes that whatever is in this contract is already balanced. good assumption?
  maltBalance.mul(95).div(100), // @audit-info amountAMin
  rewardBalance.mul(95).div(100),
  msg.sender, // transfer LP tokens to sender
  now
);

Impact

If the contract has unbalanced balances, then the router.addLiquidity call will revert. Note that an attacker could even send tokens to this contract to make them unbalanced and revert, resulting in a griefing attack.

It needs to be ensured that the balances in the contract are always balanced and match the current reserve ratio. It might be better to avoid directly using the balances which can be manipulated by transferring tokens to the contract and accepting parameters instead of how many tokens to provide liquidity with from the caller side.

#0 - GalloDaSballo

2022-01-26T13:34:09Z

Interestingly the warden highlights the other side of the "missing slippage check" argument. Slippage checks in general need to be calculated offChain (as you will get frontrun in the mempool, so the slippage check is a risk minimization tool more than anything else)

The warden also specified a griefing attack that can be used because of the hardcoded check. The sponsor confirms, I think medium severity is appropriate

Findings Information

🌟 Selected for report: hyh

Also found by: 0x1f8b, WatchPug, cmichel, jayjonah8, tabish

Labels

bug
duplicate
1 (Low Risk)

Awards

36.2087 USDC - $36.21

External Links

Handle

cmichel

Vulnerability details

Some parameters of functions are not checked for invalid values:

  • AbstractRewardMine._initialSetup: _rewardToken, _miningService are not verified to be non-zero or contracts.

Impact

Wrong user input or wallets defaulting to the zero addresses for a missing input can lead to the contract needing to redeploy or wasted gas.

Validate the parameters.

#0 - 0xScotch

2021-12-10T01:14:36Z

#74

#1 - GalloDaSballo

2022-01-24T01:44:34Z

Duplicate of #74

Findings Information

🌟 Selected for report: cmichel

Also found by: 0x1f8b, Meta0xNull, WatchPug, defsec, jayjonah8, leastwood, stonesandtrees

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

21.9968 USDC - $22.00

External Links

Handle

cmichel

Vulnerability details

The initialize function that initializes important contract state can be called by anyone. See:

  • ERC20VestedMine.initialize
  • AuctionPool.initialize
  • all contracts that extend Permissions

Impact

The attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract. In the best case for the victim, they notice it and have to redeploy their contract costing gas.

Use the constructor to initialize non-proxied contracts. For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.

#0 - GalloDaSballo

2022-01-22T14:24:30Z

Knowing that the contracts are not upgradeable, I fully agree with the finding. Because of the lack of upgradeability I'd recommend the sponsor to have a constructor giving privileged initialization permissions the the deployer. Or as they already agreed to, just use a constructor (which will enable the use of immutable, saving gas)

Findings Information

🌟 Selected for report: WatchPug

Also found by: cmichel

Labels

bug
duplicate
1 (Low Risk)

Awards

165.5635 USDC - $165.56

External Links

Handle

cmichel

Vulnerability details

The StabilizerNode._shouldAdjustSupply function divides the lowerThreshold by the rewardToken decimals leading to it having wrong decimals.

uint256 lowerThreshold = priceTarget.mul(lowerStabilityThreshold).div(10**decimals);

Note that the priceTarget is in 18 decimals (otherwise, other functions fail) and lowerStabilityThreshold = 1e18 / 100 = 1e16. If rewardToken has less than 18 decimals, for example 6 for USDC, then lowerThreshold = 1e18 * 1e16 / 10**rewardTokenDecimals = 1e34 / 1e6 = 1e28.

Impact

The priceTarget.sub(lowerThreshold) that follows will underflow and revert the transaction. The stabilize calls will always revert for rewardToken that don't happen to have 18 decimals.

Recommendation

The developers probably wanted to divide by 1e18 here, the lowerStabilityThreshold percentage baseline, to arrive at a fraction of priceTarget.

#0 - 0xScotch

2021-12-08T12:53:03Z

#293

#1 - GalloDaSballo

2022-01-25T02:34:38Z

As demonstrated in #293 the issue can be sidestepped, finding is valid, but am downgrading to low severity as this is a duplicate of #293

Findings Information

🌟 Selected for report: cmichel

Also found by: WatchPug, defsec

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

99.3381 USDC - $99.34

External Links

Handle

cmichel

Vulnerability details

The ERC20.approve() function returns a boolean value indicating success. This parameter needs to be checked for success. Some tokens do not revert if the transfer failed but return false instead.

In addition, some tokens (like USDT L199) do not work when changing the allowance from an existing non-zero allowance value. They must first be approved by zero and then the actual allowance must be approved.

IERC20(token).safeApprove(address(operator), 0);
IERC20(token).safeApprove(address(operator), amount);

This issue exists for example in AuctionParticipant.purchaseArbitrageTokens:

auctionRewardToken.approve(address(auction), balance);

As well as in UniswapHandler.buyMalt:

rewardToken.approve(address(router), rewardBalance);

Impact

Tokens that don't correctly implement the latest EIP20 spec, by either returning false on failure or reverting if approved from a non-zero value, will be unusable in the protocol as they revert the transaction because of the missing return value.

We recommend using OpenZeppelin’s SafeERC20 versions with the safeApprove(0) functions that handle the return value check as well as non-standard-compliant tokens.

#0 - GalloDaSballo

2022-01-24T01:21:27Z

Agree with the finding, not sure if this should be raised to medium severity, either way the finding is valid

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

367.919 USDC - $367.92

External Links

Handle

cmichel

Vulnerability details

There's code in UniswapHandler.removeLiquidity that attempts to transfer left-over LP tokens after they all have been burned in the router.removeLiqudity call:

(amountMalt, amountReward) = router.removeLiquidity(..., lpToken.balanceOf(address(this)), ...);

if (amountMalt == 0 || amountReward == 0) {
  liquidityBalance = lpToken.balanceOf(address(this));
  lpToken.safeTransfer(msg.sender, liquidityBalance);
  return (amountMalt, amountReward);
}

A router.removeLiqudity call transfers all specified LP tokens to the pool contract and then calls pool.burn which burns all of them. This code is not needed and might indicate that something else was intended by the developers.

#0 - 0xScotch

2021-12-04T20:39:02Z

This is correct. Taking another look at the uniswap router contract shows it reverts if anything goes wrong. So this code won't ever run. Keeping this doesn't hurt though in the case of using a different implementation of a uniswap style router contract

#1 - GalloDaSballo

2022-01-24T01:28:39Z

Agree with the finding and the severity given the "deadcode" scenario

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

367.919 USDC - $367.92

External Links

Handle

cmichel

Vulnerability details

The RewardReinvestor.splitReinvest splits the reward token in half and trades half of it to malt, then provides liquidity in the same pool.

This is not optimal as the trade comes with fees and slippage and the reserve ratios don't exactly equal the reward / malt ratio used to provide LP.

For more info, see here.

#0 - 0xScotch

2021-12-04T21:00:53Z

This should be improved but will likely come in a future update unless we have the time before launch to implement this correctly. Not highest priority

#1 - GalloDaSballo

2022-01-24T01:31:36Z

Finding is valid and surprisingly deep

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

367.919 USDC - $367.92

External Links

Handle

cmichel

Vulnerability details

The AbstractRewardMine contract states the following about the two variables:

totalReleasedReward and totalDeclaredReward will often be the same. However, in the case of vesting rewards they are different. In that case totalDeclaredReward is total reward, including unvested. totalReleasedReward is just the rewards that have completed the vesting schedule.

According to the description, it should be the case that totalDeclaredReward >= totalReleasedReward at all times. But it's not true as totalReleasedReward = totalDeclaredReward + _globalWithdrawn >= totalDeclaredReward.

I don't think the implementation in the abstract contract makes sense, the derived contracts all correctly overwrite it. Maybe it should be a virtual function without an implementation that needs to be overridden.

#0 - 0xScotch

2021-12-06T13:35:01Z

The AbstractRewardMine implementation is indeed incorrect. The solution is to add + _globalWithdrawn to the totalDeclaredReward method.

As stated in the issue above, the 2 places that inherit this contract correctly override the methods in question.

#1 - GalloDaSballo

2022-01-24T01:44:06Z

Will mark the finding valid as the logic detailed was incorrect, as for the inheritance discussion, probably best left for a different issue

Findings Information

🌟 Selected for report: WatchPug

Also found by: ScopeLift, cmichel, pauliax

Labels

bug
duplicate
G (Gas Optimization)

Awards

15.2616 USDC - $15.26

External Links

Handle

cmichel

Vulnerability details

The UniswapHandler.addLiquidity function performs a safe math computation as rewardBalance.sub(rewardUsed) but the if condition already verified that rewardUsed < rewardBalance and therefore an unsafe subtraction can be used.

#0 - 0xScotch

2021-12-08T18:33:15Z

#307

AuditHub

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

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter