The Wildcat Protocol - T1MOH's results

Banking, but worse - a protocol for fixed-rate, undercollateralised credit facilities.

General Information

Platform: Code4rena

Start Date: 16/10/2023

Pot Size: $60,500 USDC

Total HM: 16

Participants: 131

Period: 10 days

Judge: 0xTheC0der

Total Solo HM: 3

Id: 296

League: ETH

Wildcat Protocol

Findings Distribution

Researcher Performance

Rank: 16/131

Findings: 4

Award: $525.61

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142

Vulnerability details

Impact

Function WildcatMarket.closeMarket() can be called only by Controller. However there isn't function in Controller to call it.

As a result, borrower can't close market.

Proof of Concept

It has onlyController modifier

  function closeMarket() external onlyController nonReentrant {
    ...
  }

However function closeMarket() is never called from Controller

Tools Used

Manual Review

Either add function to Controller, or change modifier to onlyBorrower

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-10-27T07:24:50Z

minhquanym marked the issue as duplicate of #147

#1 - c4-judge

2023-11-07T14:05:32Z

MarioPoneder marked the issue as partial-50

#2 - c4-judge

2023-11-07T14:16:53Z

MarioPoneder changed the severity to 3 (High Risk)

#3 - MarioPoneder

2023-11-14T20:18:13Z

Thank your for your comment in the discussion!
The present report identified 1 of 2 impacts of the primary issue #431, therefore 50% credit was awarded.

Lines of code

https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L134

Vulnerability details

Impact

WildcatMarketConfig.setMaxTotalSupply() is used to increase maxTotalSupply in market. It can be called by Controller, however Controller never calls this function

As a result, borrower can't increase maxTotalSupply

Proof of Concept

Function has modifier onlyController, however setMaxTotalSupply() is never called in Controller

  function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyController nonReentrant {
    ...
  }

Tools Used

Manual Review

Either add function to Controller, or change modifier to onlyBorrower

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-10-27T06:23:49Z

minhquanym marked the issue as duplicate of #162

#1 - c4-pre-sort

2023-10-27T06:58:05Z

minhquanym marked the issue as duplicate of #147

#2 - c4-judge

2023-11-07T13:49:03Z

MarioPoneder marked the issue as partial-50

#3 - c4-judge

2023-11-07T14:16:53Z

MarioPoneder changed the severity to 3 (High Risk)

#4 - MarioPoneder

2023-11-14T20:18:45Z

Thank your for your comment in the discussion!
The present report identified 1 of 2 impacts of the primary issue #431, therefore 50% credit was awarded.

Findings Information

🌟 Selected for report: MiloTruck

Also found by: T1MOH, deepkin, deth, yumsec

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
sufficient quality report
edited-by-warden
duplicate-500

Awards

377.709 USDC - $377.71

External Links

Lines of code

https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L106-L107

Vulnerability details

Impact

Asset balance is overestimated by amount of withdrawableFees in delinquency check in _writeState() while collecting protocol fees.

Suppose current market state:

  1. assetBalance = 1000
  2. accruedProtocolFees = 200
  3. requiredReserves = 600
  4. unclaimedWithdrawals = 300

Obviously after repaying protocolFee market should be marked delinquent because 900 > 800 is true However it will perform check 900 > 1000 is false, leaving market non-delinquent

Proof of Concept

Here it firstly updates state.accruedProtocolFees, than writes state, than transfers asset. Note that asset transfer is after _writeState.

  function collectFees() external nonReentrant {
    ...
    state.accruedProtocolFees -= withdrawableFees;
    _writeState(state);
    asset.safeTransfer(feeRecipient, withdrawableFees);
    emit FeesCollected(withdrawableFees);
  }

_writeState() checks whether market is delinquent:

  function _writeState(MarketState memory state) internal {
@>  bool isDelinquent = state.liquidityRequired() > totalAssets();
    state.isDelinquent = isDelinquent;
    _state = state;
    emit StateUpdated(state.scaleFactor, isDelinquent);
  }

Let's have a look on what values are checked. Asset balance is checked against liabilities. Note that state.accruedProtocolFees was previously reduced by withdrawableFees totalAssets() is current balance of asset:

  function totalAssets() public view returns (uint256) {
    return IERC20(asset).balanceOf(address(this));
  }

  function liquidityRequired(
    MarketState memory state
  ) internal pure returns (uint256 _liquidityRequired) {
    uint256 scaledWithdrawals = state.scaledPendingWithdrawals;
    uint256 scaledRequiredReserves = (state.scaledTotalSupply - scaledWithdrawals).bipMul(
      state.reserveRatioBips
    ) + scaledWithdrawals;
    return
      state.normalizeAmount(scaledRequiredReserves) +
      state.accruedProtocolFees +
      state.normalizedUnclaimedWithdrawals;
  }

Issue is that above check occurs before asset transfer. In fact asset balance is higher by the amount of withdrawableFees than actual balance during delinquency check

  function collectFees() external nonReentrant {
    ...
    state.accruedProtocolFees -= withdrawableFees;
    _writeState(state);
    asset.safeTransfer(feeRecipient, withdrawableFees);
    emit FeesCollected(withdrawableFees);
  }

That's how asset is overestimated and can not mark market delinquent.

Tools Used

Manual Review

Check it after transfer

  function collectFees() external nonReentrant {
    MarketState memory state = _getUpdatedState();
    if (state.accruedProtocolFees == 0) {
      revert NullFeeAmount();
    }
    uint128 withdrawableFees = state.withdrawableProtocolFees(totalAssets());
    if (withdrawableFees == 0) {
      revert InsufficientReservesForFeeWithdrawal();
    }
    state.accruedProtocolFees -= withdrawableFees;
-   _writeState(state);
    asset.safeTransfer(feeRecipient, withdrawableFees);
+   _writeState(state);
    emit FeesCollected(withdrawableFees);
  }

And change it in borrow() for future, despite now there is no issue

  function borrow(uint256 amount) external onlyBorrower nonReentrant {
    MarketState memory state = _getUpdatedState();
    if (state.isClosed) {
      revert BorrowFromClosedMarket();
    }
    uint256 borrowable = state.borrowableAssets(totalAssets());
    if (amount > borrowable) {
      revert BorrowAmountTooHigh();
    }
-   _writeState(state);
    asset.safeTransfer(msg.sender, amount);
+   _writeState(state);
    emit Borrow(amount);
  }

Assessed type

Error

#0 - c4-pre-sort

2023-10-27T15:05:52Z

minhquanym marked the issue as primary issue

#1 - c4-pre-sort

2023-10-27T15:05:57Z

minhquanym marked the issue as sufficient quality report

#2 - c4-sponsor

2023-11-01T05:52:36Z

d1ll0n (sponsor) confirmed

#3 - c4-judge

2023-11-07T15:14:33Z

MarioPoneder marked the issue as satisfactory

#4 - c4-judge

2023-11-07T15:16:44Z

MarioPoneder marked the issue as selected for report

#6 - c4-judge

2023-11-14T12:48:03Z

MarioPoneder marked issue #500 as primary and marked this issue as a duplicate of 500

Findings Information

🌟 Selected for report: MiloTruck

Also found by: CaeraDenoir, T1MOH, ast3ros, elprofesor, joaovwfreire, rvierdiiev, t0x1c, trachev

Labels

2 (Med Risk)
satisfactory
duplicate-497

Awards

137.6749 USDC - $137.67

External Links

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

  1. Low. setAnnualInterestBips() will decrease reserveRatio for the next 2 weeks if it was above 90%

#0 - c4-judge

2023-11-14T20:58:44Z

MarioPoneder marked the issue as primary issue

#1 - c4-judge

2023-11-14T20:58:57Z

MarioPoneder marked the issue as duplicate of #497

#2 - c4-judge

2023-11-14T20:59:06Z

MarioPoneder marked the issue as satisfactory

Awards

10.1663 USDC - $10.17

Labels

bug
downgraded by judge
grade-b
primary issue
QA (Quality Assurance)
sponsor disputed
sufficient quality report
Q-40

External Links

Lines of code

https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L197-L213 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L112-L126

Vulnerability details

Impact

Authorized lenders can deposit to Market. In deposit, Market uses cashed value of lender authorization instead of fetching current state from Controller. Inconsistency arises when Borrower deauthorizes lender, lender still can deposit until somebody explicitly calls WildcatMarketController.updateLenderAuthorization()

As a result, lender still can deposit after deauthorization because market cashes this value instead of fetching.

Proof of Concept

Lender will be removed from authorized lenders in Controller. And it requires to submit second transaction to remove lender authorization from specific market:

  function deauthorizeLenders(address[] memory lenders) external onlyBorrower {
    for (uint256 i = 0; i < lenders.length; i++) {
      address lender = lenders[i];
      if (_authorizedLenders.remove(lender)) {
        emit LenderDeauthorized(lender);
      }
    }
  }

  function updateLenderAuthorization(address lender, address[] memory markets) external {
    for (uint256 i; i < markets.length; i++) {
      address market = markets[i];
      if (!_controlledMarkets.contains(market)) {
        revert NotControlledMarket();
      }
      WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender));
    }
  }

In Market it doesn't fetch Approval from Controller if approval was set previously:

  function _getAccountWithRole(
    address accountAddress,
    AuthRole requiredRole
  ) internal returns (Account memory account) {
    account = _getAccount(accountAddress);
    // If account role is null, see if it is authorized on controller.
    //@audit Will always be falls if lender previously interacted with protocol
@>  if (account.approval == AuthRole.Null) {
      if (IWildcatMarketController(controller).isAuthorizedLender(accountAddress)) {
        account.approval = AuthRole.DepositAndWithdraw;
        emit AuthorizationStatusUpdated(accountAddress, AuthRole.DepositAndWithdraw);
      }
    }
    ...
  }

Here lender can bypass authorization check due to old cashed value:

  function depositUpTo(
    uint256 amount
  ) public virtual nonReentrant returns (uint256 /* actualAmount */) {
    ...

    // Cache account data and revert if not authorized to deposit.
    Account memory account = _getAccountWithRole(msg.sender, AuthRole.DepositAndWithdraw);
    
    ...
  }

Tools Used

Manual Review

Remove methods WildcatMarketController.updateLenderAuthorization(), WildcatMarketConfig.updateAccountAuthorization() Remove field approval from struct Account

Fetch this value from controller:

  function _getAccountWithRole(
    address accountAddress,
    AuthRole requiredRole
  ) internal returns (Account memory account) {
    account = _getAccount(accountAddress);
    // If account role is null, see if it is authorized on controller.
-   if (account.approval == AuthRole.Null) {
-     if (IWildcatMarketController(controller).isAuthorizedLender(accountAddress)) {
-       account.approval = AuthRole.DepositAndWithdraw;
-       emit AuthorizationStatusUpdated(accountAddress, AuthRole.DepositAndWithdraw);
-     }
-   }
+   if (IWildcatMarketController(controller).isAuthorizedLender(accountAddress)) {
+     if (account.approval != AuthRole.DepositAndWithdraw) {
+     account.approval = AuthRole.DepositAndWithdraw;
+     emit AuthorizationStatusUpdated(accountAddress, AuthRole.DepositAndWithdraw);
+     }
+   } else {
+     if (account.approval != AuthRole.WithdrawOnly) {
+       account.approval = AuthRole.WithdrawOnly;
+       emit AuthorizationStatusUpdated(accountAddress, AuthRole.WithdrawOnly)
+     }
+   }
    // If account role is insufficient, revert.
    if (uint256(account.approval) < uint256(requiredRole)) {
      revert NotApprovedLender();
    }
  }

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-10-27T09:28:03Z

minhquanym marked the issue as primary issue

#1 - c4-pre-sort

2023-10-27T09:30:24Z

minhquanym marked the issue as sufficient quality report

#2 - laurenceday

2023-10-31T21:44:22Z

Not a bug, expected behaviour - easily dealt with in a single multicall deauthorizeLenders([x]); updateLenderAuthorization(x, {all_markets_on_controller}) from the borrower utilising a UI, as is planned.

Functions are separate for the sake of atomicity - would be messy to have one function update a struct within the controller and a user account within the market.

#3 - c4-sponsor

2023-10-31T21:44:34Z

laurenceday (sponsor) disputed

#4 - c4-judge

2023-11-07T18:41:12Z

MarioPoneder changed the severity to QA (Quality Assurance)

#5 - MarioPoneder

2023-11-07T18:44:12Z

UX is confusing since the method name of deauthorizeLenders(...) suggest the deauthorization is final.
However, I cannot see any malicious impacts and it can be easily avoided with a multiicall, therefore QA.

#6 - c4-judge

2023-11-09T15:04:27Z

MarioPoneder marked the issue as grade-b

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