Malt Finance contest - WatchPug'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: 1/32

Findings: 7

Award: $8,396.38

🌟 Selected for report: 23

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: WatchPug

Also found by: 0x0x0x, gzeon

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

993.3812 USDC - $993.38

External Links

Handle

WatchPug

Vulnerability details

The purpose of a Timelock contract is to put a limit on the privileges of the governor, by forcing a two step process with a preset delay time.

However, we found that the current implementation actually won't serve that purpose as it allows the governor to execute any transactions without any constraints.

To do that, the current governor can call Timelock#setGovernor(address _governor) and set a new governor effective immediately.

And the new governor can then call Timelock#setDelay() and change the delay to 0, also effective immediately.

The new governor can now use all the privileges without a delay, including granting minter role to any address and mint unlimited amount of MALT.

In conclusion, a Timelock contract is supposed to guard the protocol from lost private key or malicious actions. The current implementation won't fulfill that mission.

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Timelock.sol#L98-L105

  function setGovernor(address _governor)
    public
    onlyRole(GOVERNOR_ROLE, "Must have timelock role")
  {
    _swapRole(_governor, governor, GOVERNOR_ROLE);
    governor = _governor;
    emit NewGovernor(_governor);
  }

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Timelock.sol#L66-L77

  function setDelay(uint256 _delay)
    public
    onlyRole(GOVERNOR_ROLE, "Must have timelock role")
  {
    require(
      _delay >= 0 && _delay < gracePeriod,
      "Timelock::setDelay: Delay must not be greater equal to zero and less than gracePeriod"
    );
    delay = _delay;

    emit NewDelay(delay);
  }

Recommendation

Consider making setGovernor and setDelay only callable from the Timelock contract itself.

Specificaly, changing from onlyRole(GOVERNOR_ROLE, "Must have timelock role") to require(msg.sender == address(this), "...").

Also, consider changing _adminSetup(_admin) in Timelock#initialize() to _adminSetup(address(this)), so that all roles are managed by the timelock itself as well.

#0 - GalloDaSballo

2022-01-08T17:10:36Z

The warden has identified an exploit that allows to sidestep the delay for the timelock, effectively bypassing all of the timelock's security guarantees. Because of the gravity of this, I agree with the high risk severity.

Mitigation can be achieved by ensuring that all operations run under a time delay

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
3 (High Risk)
disagree with severity
sponsor confirmed

Awards

3679.1897 USDC - $3,679.19

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/AuctionBurnReserveSkew.sol#L116-L132

  function getPegDeltaFrequency() public view returns (uint256) {
    uint256 initialIndex = 0;
    uint256 index;

    if (count > auctionAverageLookback) {
      initialIndex = count - auctionAverageLookback;
    }

    uint256 total = 0;

    for (uint256 i = initialIndex; i < count; ++i) {
      index = _getIndexOfObservation(i);
      total = total + pegObservations[index];
    }

    return total * 10000 / auctionAverageLookback;
  }

When count < auctionAverageLookback, at L131, it should be return total * 10000 / count;. The current implementation will return a smaller value than expected.

The result of getPegDeltaFrequency() will be used for calculating realBurnBudget for auctions. With the result of getPegDeltaFrequency() being inaccurate, can result in an improper amount of excess Liquidity Extension balance to be used at the end of an auction.

#0 - 0xScotch

2021-12-03T16:52:57Z

I actually think this should be higher severity. This bug could manifest in liquidity extension being depleted to zero which could have catastrophic consequences downstream.

#1 - GalloDaSballo

2022-01-23T01:43:23Z

Agree with the finding, this is an incorrect logic in the protocol, which can limit it's functionality and as the sponsor says: could have catastrophic consequences downstream as such I'll increase the severity to high.

Mitigation seems to be straightforward

Findings Information

Labels

bug
duplicate
2 (Med Risk)

Awards

20.04 USDC - $20.04

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/RewardReinvestor.sol#L78-L90

function splitReinvest(uint256 rewardLiquidity) external {
    _retrieveReward(rewardLiquidity);

    uint256 rewardBalance = rewardToken.balanceOf(address(this));

    rewardToken.safeTransfer(address(dexHandler), rewardBalance.div(2));

    dexHandler.buyMalt();

    _bondAccount(msg.sender);

    emit SplitReinvest(msg.sender, rewardLiquidity);
  }

The current implementation of splitReinvest() provides no parameter for slippage control, making them vulnerable to front-run attacks.

POC

  1. Alice calls splitReinvest();
  2. Bob can send a DEX buy tx with a higher gas price to buy a large amount of MALT;
  3. Alice end up getting much less LP token than expected, due to the price impact created by Bob;
  4. Bob sells all the MALT bought before for profit.

Recommendation

Consider adding a amountOutMin parameter.

#0 - 0xScotch

2021-12-10T00:22:39Z

#219

#1 - GalloDaSballo

2022-01-09T23:58:35Z

Duplicate of #219

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1103.7569 USDC - $1,103.76

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/MovingAverage.sol#L424-L442

  function setSampleMemory(uint256 _sampleMemory)
    external
    onlyRole(ADMIN_ROLE, "Must have admin privs")
  {
    require(_sampleMemory > 0, "Cannot have sample memroy of 0");

    if (_sampleMemory > sampleMemory) {
      for (uint i = sampleMemory; i < _sampleMemory; i++) {
        samples.push();
      }
      counter = counter % _sampleMemory;
    } else {
      activeSamples = _sampleMemory;

      // TODO handle when list is smaller Tue 21 Sep 2021 22:29:41 BST
    }

    sampleMemory = _sampleMemory;
  }

In the current implementation, when sampleMemory is updated, the samples index will be malposition, making getValueWithLookback() get the wrong samples, so that returns the wrong value.

PoC

  • When initial sampleMemory is 10
  • After movingAverage.update(1e18) being called for 120 times
  • The admin calls movingAverage.setSampleMemory(118) and set sampleMemory to 118

The current movingAverage.getValueWithLookback(sampleLength * 10) returns 0.00000203312 e18, while it's expeceted to be 1e18

After setSampleMemory(), getValueWithLookback() may also return 0or revert FullMath: FULLDIV_OVERFLOW at L134.

Recommendation

Consider removing setSampleMemory function.

#0 - GalloDaSballo

2022-01-10T00:51:49Z

I agree that calling setSampleMemory will cause issues, and can cause opportunities to further extract value. However this can be triggered by an admin action. I'll think about the severity, but as of now, because it is contingent on admin privilege, will downgrade to Medium

#1 - GalloDaSballo

2022-01-30T16:38:46Z

I stand by my decision of Medium Severity. While the consequences can be troublesome, they are contingent on the admin breaking / griefing the system

Findings Information

🌟 Selected for report: danb

Also found by: WatchPug, leastwood

Labels

bug
duplicate
2 (Med Risk)

Awards

298.0144 USDC - $298.01

External Links

Handle

WatchPug

Vulnerability details

When the price of Malt is off the lowerThreshold and upperThreshold, StabilizerNode.sol will market buy/sell Malt.

However, since the market sell can be triggered by anyone, and there is no slippage control, it makes it vulnerable to flashloan sandwich attack.

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/StabilizerNode.sol#L145-L174

function stabilize() external notSameBlock {
    auction.checkAuctionFinalization();

    require(
      block.timestamp >= stabilizeWindowEnd || _stabilityWindowOverride(),
      "Can't call stabilize"
    );
    stabilizeWindowEnd = block.timestamp + stabilizeBackoffPeriod;

    rewardThrottle.checkRewardUnderflow();

    uint256 exchangeRate = maltDataLab.maltPriceAverage(priceAveragePeriod);

    if (!_shouldAdjustSupply(exchangeRate)) {
      maltDataLab.trackReserveRatio();

      lastStabilize = block.timestamp;
      return;
    }

    emit Stabilize(block.timestamp, exchangeRate);

    if (exchangeRate > maltDataLab.priceTarget()) {
      _distributeSupply();
    } else {
      _startAuction();
    }

    lastStabilize = block.timestamp;
  }

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/StabilizerNode.sol#L211-L246

function _distributeSupply() internal {
    ...
    uint256 swingAmount = swingTrader.sellMalt(tradeSize);

    ...

    malt.mint(address(dexHandler), tradeSize);
    emit MintMalt(tradeSize);
    uint256 rewards = dexHandler.sellMalt();

https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/StabilizerNode.sol#L317-L341

function _startAuction() internal {
    ...
    uint256 amountUsed = swingTrader.buyMalt(purchaseAmount);

POC

When the price is below lowerThreshold.

The attacker can:

  1. Flash borrow a large amount of DAI and buy Malt in the market;
  2. Call stabilize(), the swingTrader will buy Malt at a higher price;
  3. Dump the Malt tokens bought before for profit.

Recommendation

Consider making stabilize() function only allowed to be called by an EOA to prevent flash loan sandwich attack.

#0 - 0xScotch

2021-12-03T12:55:18Z

This bug needs to be mitigated. However, the POC example given doesn't apply as under lowerThreshold would put the protocol into recovery mode where non-whitelisted buyers would be blocked.

We can add a before transfer hook on Malt that will trigger stabilize when a Malt sell is going through above upperThreshold. This will protect against the frontrunning above peg.

Guarding stabilize to only EOA is a good idea though.

#1 - 0xScotch

2021-12-08T12:27:51Z

#56

#2 - GalloDaSballo

2022-01-09T23:54:20Z

@0xScotch I have yet to judge this finding. However be aware that a Only-EOA check (msg.sender != tx.origin) is destined to eventually fail as eventually EIP-3074 will make it so that there's no difference between a contract and an EOA

#3 - bitdeep

2022-01-12T19:33:23Z

Proposing onlyEOA via contract size check:

function isContract(address account) public view returns (bool) { uint size; assembly { size := extcodesize(account) } return size > 0; }

#4 - GalloDaSballo

2022-01-25T02:16:44Z

Proposing onlyEOA via contract size check:

function isContract(address account) public view returns (bool) { uint size; assembly { size := extcodesize(account) } return size > 0; }

@bitdeep I believe this has been proven wrong multiple times. Basicaly isContract (the check you're doing) works only for verifying that something is a contract. It doesn't guarantee that something is not a contract. That's because a contract can be written to perform operation in it's constructor, while in it's constructor, the check for size := extcodesize(account) will return 0 (because space was not yet allocated)

In summary, you can always verify if something IS a contract. And for now you can still use the msg.sender == tx.origin check, but eventually you will not have any way of proving that something is an EOA

#5 - GalloDaSballo

2022-01-25T02:25:00Z

From reading the code I believe that anyone can sell MALT at any time. This means that the token can be sold with the goal of reducing it's price. After the price reduction a call to stabilize can be performed. This allows the caller to get a small caller incentive as well as to trigger an auction. The auction effectively triggers the system to sell the min(idealAmount, currenrtlyOwnerAmoutn) with the goal of bringing back the malt price to peg.

The question that this left unsolved is how would the trader be able to get malt for a discount to then dump it for again.

I think this can be merged as duplicate of #56

As such medium severity is more correct

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