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
Rank: 16/131
Findings: 4
Award: $525.61
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xpiken
Also found by: 0xCiphky, 0xComfyCat, 0xStalin, 0xhegel, 0xkazim, 3docSec, AM, Aymen0909, CaeraDenoir, DeFiHackLabs, Drynooo, Eigenvectors, Fulum, HALITUS, HChang26, Jiamin, Juntao, LokiThe5th, Mike_Bello90, MiloTruck, QiuhaoLi, Silvermist, SovaSlava, SpicyMeatball, T1MOH, Toshii, TrungOre, TuringConsulting, Vagner, Yanchuan, ZdravkoHr, _nd_koo, almurhasan, audityourcontracts, ayden, cartlex_, circlelooper, crunch, cu5t0mpeo, deth, erictee, ggg_ttt_hhh, gizzy, gumgumzum, hash, jasonxiale, josephdara, ke1caM, kodyvim, lanrebayode77, marqymarq10, max10afternoon, nirlin, nonseodion, osmanozdemir1, peter, radev_sw, rvierdiiev, said, serial-coder, sl1, smiling_heretic, squeaky_cactus, stackachu, tallo, trachev, zaevlad
0.0606 USDC - $0.06
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.
It has onlyController
modifier
function closeMarket() external onlyController nonReentrant { ... }
However function closeMarket()
is never called from Controller
Manual Review
Either add function to Controller, or change modifier to onlyBorrower
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.
🌟 Selected for report: 0xpiken
Also found by: 0xCiphky, 0xComfyCat, 0xStalin, 0xhegel, 0xkazim, 3docSec, AM, Aymen0909, CaeraDenoir, DeFiHackLabs, Drynooo, Eigenvectors, Fulum, HALITUS, HChang26, Jiamin, Juntao, LokiThe5th, Mike_Bello90, MiloTruck, QiuhaoLi, Silvermist, SovaSlava, SpicyMeatball, T1MOH, Toshii, TrungOre, TuringConsulting, Vagner, Yanchuan, ZdravkoHr, _nd_koo, almurhasan, audityourcontracts, ayden, cartlex_, circlelooper, crunch, cu5t0mpeo, deth, erictee, ggg_ttt_hhh, gizzy, gumgumzum, hash, jasonxiale, josephdara, ke1caM, kodyvim, lanrebayode77, marqymarq10, max10afternoon, nirlin, nonseodion, osmanozdemir1, peter, radev_sw, rvierdiiev, said, serial-coder, sl1, smiling_heretic, squeaky_cactus, stackachu, tallo, trachev, zaevlad
0.0606 USDC - $0.06
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
Function has modifier onlyController
, however setMaxTotalSupply()
is never called in Controller
function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyController nonReentrant { ... }
Manual Review
Either add function to Controller, or change modifier to onlyBorrower
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.
377.709 USDC - $377.71
Asset balance is overestimated by amount of withdrawableFees
in delinquency check in _writeState()
while collecting protocol fees.
Suppose current market state:
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
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.
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); }
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
#5 - laurenceday
2023-11-09T09:58:55Z
#6 - c4-judge
2023-11-14T12:48:03Z
MarioPoneder marked issue #500 as primary and marked this issue as a duplicate of 500
🌟 Selected for report: MiloTruck
Also found by: CaeraDenoir, T1MOH, ast3ros, elprofesor, joaovwfreire, rvierdiiev, t0x1c, trachev
137.6749 USDC - $137.67
Judge has assessed an item in Issue #66 as 2 risk. The relevant finding follows:
#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
🌟 Selected for report: J4X
Also found by: 0x3b, 0xCiphky, 0xComfyCat, 0xStalin, 0xbepresent, 3docSec, DavidGiladi, DeFiHackLabs, Drynooo, Fulum, GREY-HAWK-REACH, InAllHonesty, MatricksDeCoder, Mike_Bello90, MiloTruck, Phantom, SHA_256, T1MOH, Udsen, VAD37, YusSecurity, ZdravkoHr, ast3ros, caventa, deepkin, deth, devival, ggg_ttt_hhh, inzinko, jasonxiale, josieg_74497, karanctf, ke1caM, nisedo, nobody2018, nonseodion, osmanozdemir1, radev_sw, rvierdiiev, serial-coder, t0x1c
10.1663 USDC - $10.17
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
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.
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); ... }
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(); } }
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