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: 29/131
Findings: 5
Award: $340.65
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xbepresent, Aymen0909, InAllHonesty, QiuhaoLi, TrungOre, ggg_ttt_hhh, hash, nonseodion, rvierdiiev
304.1365 USDC - $304.14
After the market closes, users are unable to receive their rightful assets, resulting in a loss of funds for them.
The WildcatMarket.closeMarket()
function is used to close the market. Specifically, it sets the market's Annual Percentage Rate (APR) to 0% and marks the market as closed. Additionally, it performs the necessary asset transfers between the borrower and the market to ensure that the market is settled with no surplus funds left in the contract.
function closeMarket() external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); state.annualInterestBips = 0; state.isClosed = true; state.reserveRatioBips = 0; if (_withdrawalData.unpaidBatches.length() > 0) { revert CloseMarketWithUnpaidWithdrawals(); } uint256 currentlyHeld = totalAssets(); uint256 totalDebts = state.totalDebts(); if (currentlyHeld < totalDebts) { // Transfer remaining debts from the borrower asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); } else if (currentlyHeld > totalDebts) { // Transfer excess assets to the borrower asset.safeTransfer(borrower, currentlyHeld - totalDebts); } _writeState(state); emit MarketClosed(block.timestamp); }
However, a flaw arises in this function. While the function correctly resets the value of annualInterestBips
to 0
, it does not address the delinquencyFeeBips
, which remains unchanged. Consequently, if the market is closed and state.timeDelinquent
surpasses the delinquencyGracePeriod
, the interest continues to accrue, causing the scaleFactor
to grow. As a result, the total assets in the market may not be sufficient for all users, as their corresponding assets continue to increase even after the market is closed.
To illustrate this issue, we can place the following test to WildMarket.t.sol
function test_closeMarket_CloseMarketBugInterestStillIncrease() external asAccount(address(controller)) { // both alice and bob deposit 5e17 tokens _deposit(alice, 5e17); _deposit(bob, 5e17); assertEq(market.balanceOf(alice), market.balanceOf(bob)); // borrower borrows 80% _borrow(8e17); // market become delinquent fastForward(10); market.updateState(); assertEq(market.previousState().isDelinquent, true); // Advance time past the `delinquencyGracePeriod` to commence accruing the `delinquentFee` fastForward(market.delinquencyGracePeriod() * 2); market.updateState(); // close market asset.mint(borrower, 2e18); _approve(borrower, address(market), type(uint256).max); market.closeMarket(); market.collectFees(); // collect fee for the protocol // expected amount that alice and bob should receive when making a withdrawal uint expectedAmountOfAlice = market.balanceOf(alice); uint expectedAmountOfBob = market.balanceOf(bob); // deliquentFee is still != 0 --> scaleFactor increases as time passes uint oldScaleFactor = market.previousState().scaleFactor; fastForward(market.previousState().timeDelinquent); market.updateState(); assertGt(market.previousState().scaleFactor, oldScaleFactor); // oldScaleFactor < newScaleFactor // alice makes a withdrawal uint receivedAmountOfAlice = _oreWithdraw(alice); // bob makes a withdrawal uint receivedAmountOfBob = _oreWithdraw(bob); // assertion here assertGt(receivedAmountOfAlice, receivedAmountOfBob); // alice receive more than bob assertGt(receivedAmountOfAlice, expectedAmountOfAlice); // alice receive more than expected assertLt(receivedAmountOfBob, expectedAmountOfBob); // bob receive less than expected } function _oreWithdraw(address user) internal asAccount(user) returns (uint receivedAmount) { uint balanceBefore = asset.balanceOf(user); market.queueWithdrawal(market.balanceOf(user)); uint32 expiry = market.previousState().pendingWithdrawalExpiry; fastForward(parameters.withdrawalBatchDuration); market.executeWithdrawal(user, expiry); receivedAmount = asset.balanceOf(user) - balanceBefore; }
In this test, both Alice and Bob initially deposit the same amount of assets into the market. It demonstrates that the scaleFactor
continues to increase even after the market is closed (line 36). When Alice and Bob decide to withdraw their funds, it becomes apparent that:
Manual review
Consider transforming the delinquencyFeeBips
into a regular variable (non-immutable) and resetting it to 0 upon the invocation of the closeMarket()
function.
Other
#0 - c4-pre-sort
2023-10-28T14:08:33Z
minhquanym marked the issue as duplicate of #592
#1 - c4-judge
2023-11-07T15:40:58Z
MarioPoneder marked the issue as satisfactory
🌟 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
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L136-L139
The inability to close the market results in the continued accrual of interest, even after the borrower has fully repaid the debt, leading to financial losses for the borrower.
The WildcatMarket.closeMarket()
function serves the purpose of closing the market, specifically by performing the following actions:
state.isClosed
flag to true
/// link = function closeMarket() external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); state.annualInterestBips = 0; state.isClosed = true; state.reserveRatioBips = 0; if (_withdrawalData.unpaidBatches.length() > 0) { revert CloseMarketWithUnpaidWithdrawals(); } uint256 currentlyHeld = totalAssets(); uint256 totalDebts = state.totalDebts(); if (currentlyHeld < totalDebts) { // Transfer remaining debts from the borrower asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); } else if (currentlyHeld > totalDebts) { // Transfer excess assets to the borrower asset.safeTransfer(borrower, currentlyHeld - totalDebts); } _writeState(state); emit MarketClosed(block.timestamp); } /// link = modifier onlyController() { if (msg.sender != controller) revert NotController(); _; }
Notably, it's crucial to note that this function can only be triggered by the controller
, which represents an object of the WildcatMarketController
contract. However, it's apparent that there is no method within the WildcatMarketController
contract that can initiate the closeMarket()
function.
The absence of a trigger method for the closeMarket()
function in the controller
could result in the market being unable to close properly. This failure to close the market might prevent the APR from resetting to 0%, even after the borrower has repaid all the debt. Consequently, this situation could lead to financial losses for borrowers since they would still be obligated to pay the interest.
Manual review
Modify the WildcatMarket.closeMarket()
function to permit invocation by the borrower
.
Access Control
#0 - c4-pre-sort
2023-10-27T07:31:09Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T13:53:20Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T14:08:19Z
MarioPoneder marked the issue as partial-50
#3 - c4-judge
2023-11-07T14:16:53Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: 0xStalin
Also found by: 0xCiphky, 0xComfyCat, 0xbepresent, 3docSec, Eigenvectors, Fulum, HChang26, Infect3d, QiuhaoLi, SandNallani, SovaSlava, TrungOre, YusSecurity, ZdravkoHr, ast3ros, ayden, bdmcbri, cu5t0mpeo, elprofesor, gizzy, jasonxiale, kodyvim, marqymarq10, max10afternoon, nisedo, nobody2018, radev_sw, rvierdiiev, serial-coder, xeros
13.1205 USDC - $13.12
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L164-L180 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketToken.sol#L72-L78
Sanctioned account can prevent funds from being sent to escrow
When an account is sanctioned by the ChainalysisSanctionsList
contract, the account can be assigned the Blocked
role if someone triggers the WildMarketConfig.nukeFromOrbit()
function in the market. However, there are several reasons why this function may not be regularly called:
Consequently, the protocol needs to develop a mechanism to prevent sanctioned accounts from absconding with tokens without requiring the triggering of the WildMarketConfig.nukeFromOrbit()
function for these accounts. As a solution, every time an account triggers WildcatMarketWithdrawals.executeWithdrawal()
to retrieve their tokens, the function executes an external call IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)
to check if the accountAddress
is sanctioned, rather than simply checking the account's role. If the account is sanctioned, the funds are then locked in escrow until the account is unblocked.
// link = https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L164-L180 function executeWithdrawal( address accountAddress, uint32 expiry ) external nonReentrant returns (uint256) { ... if (IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { _blockAccount(state, accountAddress); address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(asset) ); asset.safeTransfer(escrow, normalizedAmountWithdrawn); emit SanctionedAccountWithdrawalSentToEscrow( accountAddress, escrow, expiry, normalizedAmountWithdrawn ); } else { asset.safeTransfer(accountAddress, normalizedAmountWithdrawn); } ... }
However, a sanctioned account can easily circumvent this mechanism and prevent funds from being locked in escrow by utilizing another address. Here is the complete strategy for an attacker to follow:
x
shares of a market in wallet A
.A
is added to the sanctioned list of the ChainalysisSanctionsList
contract.B
.A
to transfer x
shares to wallet B
.B
to trigger the function WildcatMarketController.updateLenderAuthorization() -> WildcatMarketConfig.updateAccountAuthorization()
. Since wallet B
has not yet been added to _authorizedLenders
, it receives the WithdrawOnly
role.B
to call queueWithdrawal()
-> executeWithdrawal()
. As wallet B
is not sanctioned, the tokens are not transferred to the escrow contract, allowing the attacker to immediately take control of the tokens.Manual review
Consider implementing an external call to IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)
within the WildcatMarketToken._transfer()
function to verify whether the accountAddress
is sanctioned before making any balance modifications.
Other
#0 - c4-pre-sort
2023-10-28T14:06:22Z
minhquanym marked the issue as duplicate of #54
#1 - c4-judge
2023-11-07T14:36:22Z
MarioPoneder changed the severity to 3 (High Risk)
#2 - c4-judge
2023-11-07T14:47:26Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: 0xStalin
Also found by: 0xCiphky, 0xComfyCat, 0xbepresent, 3docSec, Eigenvectors, Fulum, HChang26, Infect3d, QiuhaoLi, SandNallani, SovaSlava, TrungOre, YusSecurity, ZdravkoHr, ast3ros, ayden, bdmcbri, cu5t0mpeo, elprofesor, gizzy, jasonxiale, kodyvim, marqymarq10, max10afternoon, nisedo, nobody2018, radev_sw, rvierdiiev, serial-coder, xeros
13.1205 USDC - $13.12
An address with the Null role can withdraw tokens from the market.
As stated in the third point under "Main Invariants" in the contest README:
- The assets of a market should never be able to be withdrawn by anyone that is not the borrower or a lender with either the
WithdrawOnly
orDepositAndWithdraw
role.
This implies that the withdrawal cannot be carried out by an account with the Null
or Blocked
role. However, an address with the Null
role can easily bypass this invariant by utilizing the WildcatMarketController.updateLenderAuthorization()
function.
/// link = https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L182-L190 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)); } } /// link = https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L112-L126 function updateAccountAuthorization( address _account, bool _isAuthorized ) external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); Account memory account = _getAccount(_account); if (_isAuthorized) { account.approval = AuthRole.DepositAndWithdraw; } else { account.approval = AuthRole.WithdrawOnly; } _accounts[_account] = account; _writeState(state); emit AuthorizationStatusUpdated(_account, account.approval); }
As seen, for each pair of (lender, market), the WildcatMarketController.updateLenderAuthorization()
function makes an external call to the WildcatMarketConfig.updateAccountAuthorization()
function to update the account's authorization. Since the account with the Null
role is not included in the _authorizedLenders
set, the input _isAuthorized
of the updateAccountAuthorization
function will be set as false. Consequently, the account's role will change from Null
to WithdrawOnly
. Thus, the account can now execute the withdrawal and break the invariant.
Manual review
Consider limiting the caller of the WildcatMarketController.updateLenderAuthorization()
function to only the borrower.
Access Control
#0 - c4-pre-sort
2023-10-27T08:49:54Z
minhquanym marked the issue as duplicate of #400
#1 - c4-pre-sort
2023-10-27T08:51:25Z
minhquanym marked the issue as duplicate of #155
#2 - c4-judge
2023-11-07T12:55:51Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2023-11-14T13:59:00Z
MarioPoneder changed the severity to 3 (High Risk)
#4 - c4-judge
2023-11-14T14:00:06Z
MarioPoneder marked the issue as partial-50
#5 - c4-judge
2023-11-14T14:02:38Z
MarioPoneder marked the issue as duplicate of #266
🌟 Selected for report: YusSecurity
Also found by: 0xAsen, 0xCiphky, 0xDING99YA, 0xKbl, 0xSwahili, 0xbepresent, 3docSec, AS, Aymen0909, DeFiHackLabs, GREY-HAWK-REACH, KeyKiril, MiloTruck, QiuhaoLi, Silvermist, SovaSlava, TrungOre, VAD37, Vagner, Yanchuan, ZdravkoHr, ast3ros, cartlex_, d3e4, deth, ggg_ttt_hhh, gizzy, kodyvim, nirlin, nobody2018, rvierdiiev, serial-coder, sl1, tallo, xeros
6.6715 USDC - $6.67
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L166-L170 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L172-L176
The WildcatSanctionsSentinel.createEscrow(borrower, account, asset)
function is utilized for generating a fresh WildcatSanctionsEscrow
contract dedicated to the specified borrower
, account
, and asset
. The order of passing parameters indicates that the borrower
address is expected before the account
address.
/// link = https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L95-L99 function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) { ... }
However, during the execution of the WildcatMarketWithdrawals.executeWithdrawal()
function, when the accountAddress
is sanctioned, the escrow
is created with the following code:
/// link = https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L166-L170 address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(asset) );
The issue lies in the fact that the accountAddress
is placed before the borrower
address in the createEscrow()
function, contrary to the expected order. Consequently, this incorrect arrangement results in the IMMUTABLE variables for the newly created WildcatSanctionsEscrow
as described below:
sentinel = msg.sender borrower = accountAddress account = borrower asset = address(asset)
This flawed implementation can lead to a serious predicament if, when the accountAddress
is unsanctioned and triggers WildcatSanctionsEscrow.releaseEscrow()
to retrieve the tokens, the amount is received by the borrower
instead of the rightful accountAddress
. This occurs because the WildcatSanctionsEscrow.account
has become the borrower
due to the incorrect parameter order.
Manual review
Modify the call to createEscrow
as follows:
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( borrower, accountAddress, address(asset) );
Other
#0 - c4-pre-sort
2023-10-27T02:27:51Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:54:25Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: 3docSec
Also found by: 0xCiphky, 0xbepresent, 0xbrett8571, Eigenvectors, MiloTruck, Toshii, Tricko, TrungOre, ZdravkoHr, b0g0, caventa, cu5t0mpeo, deth, ggg_ttt_hhh, gizzy, joaovwfreire, josephdara, serial-coder, smiling_heretic, stackachu
16.6643 USDC - $16.66
The function WildcatMarketController.setAnnualInterestBips()
is employed to adjust the interest rate for a market.
function setAnnualInterestBips( address market, uint16 annualInterestBips ) external virtual onlyBorrower onlyControlledMarket(market) { // If the borrower is reducing the interest rate, increase the reserve // ratio for the next two weeks. if (annualInterestBips < WildcatMarket(market).annualInterestBips()) { TemporaryReserveRatio storage tmp = temporaryExcessReserveRatio[market]; if (tmp.expiry == 0) { tmp.reserveRatioBips = uint128(WildcatMarket(market).reserveRatioBips()); // Require 90% liquidity coverage for the next 2 weeks WildcatMarket(market).setReserveRatioBips(9000); } tmp.expiry = uint128(block.timestamp + 2 weeks); } WildcatMarket(market).setAnnualInterestBips(annualInterestBips); }
The issue arises when the new annualInterestBips
value is not verified against the boundaries of [MinimumAnnualInterestBips, MaximumAnnualInterestBips]
. This absence of validation checks violates point 1 in the "Main Invariants" section of the contest README.
Consequently, this vulnerability allows the borrower to set the interest rate to an extremely low value, resulting in a profitable scenario for the borrower when borrowing assets from the market.
Manual review
Consider checking the new annualInterestBips
within the bound [MinimumAnnualInterestBips, MaximumAnnualInterestBips]
Other
#0 - c4-pre-sort
2023-10-27T14:22:03Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:32:57Z
MarioPoneder marked the issue as satisfactory