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: 46/131
Findings: 5
Award: $134.84
🌟 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
The WildcatMarket::closeMarket()
cannot be executed by a borrower because the WildcatMarketController
contract does not implement any function calling it.
The closeMarket()
is supposed to be called by a borrower to close their market, set the market's annualInterestBips
(APR) to 0%, disallow lenders from depositing asset tokens to the market, and transfer the remaining debts to the market for all lenders to retrieve.
A borrower must execute the closeMarket()
through the WildcatMarketController
contract, as the function is attached to the onlyController
modifier. However, I discovered that the WildcatMarketController
contract does not implement any function calling the closeMarket()
.
Therefore, a borrower cannot execute the closeMarket()
to close their market.
@> 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 borrower asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); } else if (currentlyHeld > totalDebts) { // Transfer excess assets to borrower asset.safeTransfer(borrower, currentlyHeld - totalDebts); } _writeState(state); emit MarketClosed(block.timestamp); }
A borrower will be unable to execute the closeMarket()
to close their market. Consequently, the borrower cannot disallow lenders from depositing asset tokens (via the depositUpTo()
and deposit()
) to the market, and the borrower will be forced to pay the lending interest.
Manual Review
Implement a function (in the WildcatMarketController
contract) for handling the execution of the WildcatMarket::closeMarket()
.
Other
#0 - c4-pre-sort
2023-10-27T07:11:19Z
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:01:53Z
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/WildcatMarketController.sol#L182 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L188 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L121 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketToken.sol#L36 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L77 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L137
The current implementation of the WildcatMarketController::updateLenderAuthorization()
leads to the privilege escalation vulnerability, enabling an unauthorized account with AuthRole.Null
approval status to be able to escalate its privilege to AuthRole.WithdrawOnly
.
When combining the privilege escalation vulnerability with the front-running and Sybil attacks, a sanctioned lender can evacuate and exit their underlying assets from a market without a permit.
Moreover, the market's borrower or Wildcat
protocol owner cannot block the (sanctioned) lender from exiting their asset tokens.
This PoC section is divided into two sub-sections:
The WildcatMarketController::updateLenderAuthorization()
applies a specific lender's current authorization status (set by a borrower) to target markets. Anyone can call this function.
Once the function is executed, it will validate the validity of each target market and then run the WildcatMarketConfig::updateAccountAuthorization()
on the target market to update the given lender's current authorization status. If the borrower has authorized the given lender, the lender's approval status will be assigned with AuthRole.DepositAndWithdraw
. Otherwise, the lender's approval status will be set to AuthRole.WithdrawOnly
-- This design choice allows deauthorized lenders to withdraw their underlying assets from the market.
However, this design choice also allows an attacker to escalate the privilege of their unauthorized account from AuthRole.Null
to AuthRole.WithdrawOnly
. Consequently, the attacker's unauthorized account can exit asset tokens from a market.
To elaborate on the vulnerability, assuming that John wants to escalate their Sybil account called SybilJohn
. John invokes the WildcatMarketController::updateLenderAuthorization()
and specifies SybilJohn
as the lender argument. Since SybilJohn
is not an authorized lender, the _authorizedLenders.contains(SybilJohn)
will pass the 'false' value to the WildcatMarketConfig::updateAccountAuthorization()
.
Because the _isAuthorized
parameter would indicate the 'false' value, the WildcatMarketConfig::updateAccountAuthorization()
will assign the AuthRole.WithdrawOnly
approval status to SybilJohn
. In other words, John has successfully escalated its Sybil account.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol @> function updateLenderAuthorization(address lender, address[] memory markets) external { //@audit -- John inputs his Sybil account (SybilJohn) as the lender argument for (uint256 i; i < markets.length; i++) { address market = markets[i]; if (!_controlledMarkets.contains(market)) { revert NotControlledMarket(); } @> WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender)); //@audit -- Since SybilJohn is not an authorized lender, the _authorizedLenders.contains(SybilJohn) will pass the 'false' value to the WildcatMarketConfig::updateAccountAuthorization() } } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketConfig.sol 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; //@audit -- Because the _isAuthorized == false, SybilJohn's approval status will be assigned with AuthRole.WithdrawOnly (privilege escalation!!) } _accounts[_account] = account; _writeState(state); emit AuthorizationStatusUpdated(_account, account.approval); }
John inputs his Sybil account (SybilJohn) as the lender argument
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L182Since SybilJohn is not an authorized lender, the _authorizedLenders.contains(SybilJohn) will pass the 'false' value to the WildcatMarketConfig::updateAccountAuthorization()
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L188Because the _isAuthorized == false, SybilJohn's approval status will be assigned with AuthRole.WithdrawOnly (privilege escalation!!)
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L121Assuming that John is a lender sanctioned by Chainalysis, Bob is trying to execute the WildcatMarketConfig::nukeFromOrbit()
to block John from interacting with the market and transfer his market tokens to a locked Escrow.
John can leverage the privilege escalation vulnerability in this report together with the front-running and Sybil attacks to evacuate and exit their underlying assets from the market without a permit. Consider the following exploit scenario.
John front runs the execution of the WildcatMarketConfig::nukeFromOrbit()
invoked by Bob to invoke the WildcatMarketToken::transfer()
to transfer his market tokens to his Sybil account called SybilJohn
. John can successfully transfer his tokens to SybilJohn
at this step because his approval status has not yet been updated to AuthRole.Blocked
due to the front running.
However, since SybilJohn
is an unauthorized account with approval status == AuthRole.Null
by default, SybilJohn
cannot exit John's underlying assets from the market.
John executes the WildcatMarketController::updateLenderAuthorization()
by inputting SybilJohn
as the lender argument to escalate SybilJohn
's privilege from AuthRole.Null
to AuthRole.WithdrawOnly
(Please refer to the Explaining how an unauthorized account can escalate its privilege
section above for a detailed explanation).
With the obtained AuthRole.WithdrawOnly
approval status, SybilJohn
can burn the market tokens and exit the underlying asset tokens from the market through the invocation of the WildcatMarketWithdrawals::queueWithdrawal()
and WildcatMarketWithdrawals::executeWithdrawal()
like a regular/unblocked lender.
With this exploit scenario, the market's borrower or Wildcat
protocol owner cannot block SybilJohn
from exiting John's asset tokens.
Manual Review
To fix the vulnerability, apply the onlyBorrower
modifier to the WildcatMarketController::updateLenderAuthorization()
, like the below snippet.
In addition to the recommendation, since only a borrower can authorize or deauthorize lenders in their markets, it still makes sense to apply the onlyBorrower
modifier to the updateLenderAuthorization()
to fix the vulnerability.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol - function updateLenderAuthorization(address lender, address[] memory markets) external { + function updateLenderAuthorization(address lender, address[] memory markets) external onlyBorrower { for (uint256 i; i < markets.length; i++) { address market = markets[i]; if (!_controlledMarkets.contains(market)) { revert NotControlledMarket(); } WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender)); } }
Other
#0 - c4-pre-sort
2023-10-27T09:10:15Z
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:42:49Z
MarioPoneder marked the issue as satisfactory
🌟 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/WildcatMarketConfig.sol#L79 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L173-L174 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L178 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L96-L97 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L108 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L110 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L165 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L167-L168 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L171 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsEscrow.sol#L38
The WildcatMarketConfig::nukeFromOrbit()
and the WildcatMarketWithdrawals::executeWithdrawal()
create incorrect Escrows for locking the blocked lender's asset tokens (underlying assets) and market tokens.
A borrower can steal all asset tokens and market tokens of the blocked lender. Eventually, the borrower can burn the stolen market tokens and withdraw all underlying asset tokens from the market.
This PoC section is divided into three sub-sections:
Once a lender gets sanctioned by Chainalysis, anyone can trigger the WildcatMarketConfig::nukeFromOrbit()
to block the lender from interacting with the market and transfer all lender's market tokens to an Escrow contract.
The nukeFromOrbit()
invokes the WildcatMarketBase::_blockAccount()
to block the lender. The _blockAccount()
will create the Escrow for holding the blocked lender's market tokens. However, the _blockAccount()
will pass the accountAddress
and borrower
arguments into the WildcatSanctionsSentinel::createEscrow()
alternately with the createEscrow()
's required parameters.
Subsequently, the createEscrow()
's borrower
parameter will point to the blocked lender, whereas the account
parameter will point to the borrower instead. Therefore, the createEscrow()
will create the incorrect Escrow contract.
After that, all blocked lender's market tokens will be transferred to the incorrectly generated Escrow, allowing the borrower to execute the WildcatSanctionsEscrow::releaseEscrow()
on the Escrow to steal all market tokens.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketConfig.sol function nukeFromOrbit(address accountAddress) external nonReentrant { if (!IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { revert BadLaunchCode(); } MarketState memory state = _getUpdatedState(); @> _blockAccount(state, accountAddress); //@audit -- invoke the _blockAccount() to block the sanctioned lender _writeState(state); } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol function _blockAccount(MarketState memory state, address accountAddress) internal { Account memory account = _accounts[accountAddress]; if (account.approval != AuthRole.Blocked) { ... if (scaledBalance > 0) { account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( @> accountAddress, //@audit -- the incorrect Escrow for the market tokens is created @> borrower, address(this) ); emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); @> _accounts[escrow].scaledBalance += scaledBalance; //@audit -- all blocked lender's market tokens are sent to the Escrow emit SanctionedAccountAssetsSentToEscrow( accountAddress, escrow, state.normalizeAmount(scaledBalance) ); } _accounts[accountAddress] = account; } } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatSanctionsSentinel.sol function createEscrow( @> address borrower, //@audit -- notice the difference between the function params and the passed arguments @> address account, address asset ) public override returns (address escrowContract) { ... @> tmpEscrowParams = TmpEscrowParams(borrower, account, asset); //@audit -- now, the borrower == address(blocked lender) and account == address(legit borrower) @> new WildcatSanctionsEscrow{ salt: keccak256(abi.encode(borrower, account, asset)) }(); //@audit -- create an incorrect Escrow that the (legit) borrower can execute Escrow::releaseEscrow() to steal the blocked lender's tokens ... }
Invoke the _blockAccount() to block the sanctioned lender
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L79The incorrect Escrow for the market tokens is created
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L173-L174All blocked lender's market tokens are sent to the Escrow
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L178Notice the difference between the function params and the passed arguments
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L96-L97Now, the borrower == address(blocked lender) and account == address(legit borrower)
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L108Create an incorrect Escrow that the (legit) borrower can execute Escrow::releaseEscrow() to steal the blocked lender's tokens
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L110When a sanctioned lender (but has not been blocked from the market) executes the WildcatMarketWithdrawals::executeWithdrawal()
, the function will invoke the WildcatMarketBase::_blockAccount()
to block the lender from the market and then transfer all their market tokens to a created Escrow.
In this step, the _blockAccount()
will create an incorrect Escrow for the blocked lender's market tokens, allowing the borrower to steal all locked market tokens. For a detailed explanation, please refer to the Explaining how the nukeFromOrbit() creates an incorrect Escrow for the market tokens
section above.
Then, the executeWithdrawal()
will execute the WildcatSanctionsSentinel::createEscrow()
to create an Escrow for holding the blocked lender's asset tokens (from a pending withdrawal request that has expired). Similar to the _blockAccount()
, the executeWithdrawal()
will pass the accountAddress
and borrower
arguments into the createEscrow()
alternately with its required parameters.
For this reason, the createEscrow()
's borrower
parameter will point to the blocked lender, whereas the account
parameter will point to the borrower unintentionally. Hence, the createEscrow()
will create the incorrect Escrow contract.
Lastly, all blocked lender's asset tokens (from a pending withdrawal request that has expired) will be transferred to the incorrectly created Escrow, allowing the borrower to execute the WildcatSanctionsEscrow::releaseEscrow()
on the Escrow to steal all asset tokens.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol function executeWithdrawal( address accountAddress, uint32 expiry ) external nonReentrant returns (uint256) { ... if (IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { @> _blockAccount(state, accountAddress); //@audit -- invoke the _blockAccount() to block the sanctioned lender address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( @> accountAddress, //@audit -- the incorrect Escrow for the asset tokens (underlying assets) is created @> borrower, address(asset) ); @> asset.safeTransfer(escrow, normalizedAmountWithdrawn); //@audit -- all blocked lender's asset tokens (from a pending withdrawal request that has expired) are sent to the Escrow emit SanctionedAccountWithdrawalSentToEscrow( accountAddress, escrow, expiry, normalizedAmountWithdrawn ); } else { ... } ... } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol function _blockAccount(MarketState memory state, address accountAddress) internal { Account memory account = _accounts[accountAddress]; if (account.approval != AuthRole.Blocked) { ... if (scaledBalance > 0) { account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( @> accountAddress, //@audit -- the incorrect Escrow for the market tokens is created @> borrower, address(this) ); emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); @> _accounts[escrow].scaledBalance += scaledBalance; //@audit -- all blocked lender's market tokens are sent to the Escrow emit SanctionedAccountAssetsSentToEscrow( accountAddress, escrow, state.normalizeAmount(scaledBalance) ); } _accounts[accountAddress] = account; } } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatSanctionsSentinel.sol function createEscrow( @> address borrower, //@audit -- notice the difference between the function params and the passed arguments @> address account, address asset ) public override returns (address escrowContract) { ... @> tmpEscrowParams = TmpEscrowParams(borrower, account, asset); //@audit -- now, the borrower == address(blocked lender) and account == address(legit borrower) @> new WildcatSanctionsEscrow{ salt: keccak256(abi.encode(borrower, account, asset)) }(); //@audit -- create an incorrect Escrow that the (legit) borrower can execute Escrow::releaseEscrow() to steal the blocked lender's tokens ... }
Invoke the _blockAccount() to block the sanctioned lender
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L165The incorrect Escrow for the asset tokens (underlying assets) is created
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L167-L168All blocked lender's asset tokens (from a pending withdrawal request that has expired) are sent to the Escrow
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L171The incorrect Escrow for the market tokens is created
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L173-L174All blocked lender's market tokens are sent to the Escrow
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L178Notice the difference between the function params and the passed arguments
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L96-L97Now, the borrower == address(blocked lender) and account == address(legit borrower)
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L108Create an incorrect Escrow that the (legit) borrower can execute Escrow::releaseEscrow() to steal the blocked lender's tokens
: https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L110There are two exploit scenarios based on the sanctioned lender (victim)'s actions prior to the sanction event.
If the sanctioned lender (victim) has no pending withdrawal request:
A borrower (attacker) executes the WildcatMarketConfig::nukeFromOrbit()
to block the lender and transfer all lender's market tokens to the incorrectly created Escrow.
The borrower invokes the WildcatSanctionsEscrow::releaseEscrow()
on the Escrow to steal the locked market tokens. Because the account
parameter will point to the borrower (who is not sanctioned by Chainalysis), the check for the release authorization by the canReleaseEscrow()
will be passed (more refs: #1 and #2). The market tokens will be transferred to the borrower.
The borrower executes the WildcatMarketController::authorizeLenders()
to authorize themselves as a lender and then calls the WildcatMarketController::updateLenderAuthorization()
to apply the lender authorization to the target market.
The borrower triggers the WildcatMarketWithdrawals::queueWithdrawal()
to create a pending request for withdrawing (underlying) asset tokens by burning the stolen market tokens for exchange. As a result of Step 3, the borrower now becomes a legitimate lender with the approval status == AuthRole.DepositAndWithdraw
. Therefore, the check for the withdrawal authorization by the _getAccountWithRole()
will be passed.
Once the pending withdrawal request has expired, the borrower triggers the WildcatMarketWithdrawals::executeWithdrawal()
to withdraw the blocked lender's (underlying) asset tokens from the market. The stolen asset tokens will be transferred to the borrower.
If the sanctioned lender (victim) has previously executed some pending withdrawal requests before being sanctioned:
A borrower (attacker) executes the WildcatMarketWithdrawals::executeWithdrawal()
to block the lender (if necessary) and transfer both the market tokens and underlying asset tokens (from a pending withdrawal request that has expired) of the blocked lender to the incorrectly created Escrows (There will be 2 Escrows -- one for the market tokens and another for the asset tokens).
The borrower invokes the WildcatSanctionsEscrow::releaseEscrow()
on both Escrows to steal the locked market tokens and the underlying asset tokens. Because the account
parameter will point to the borrower (who is not sanctioned by Chainalysis), the check for the release authorization by the canReleaseEscrow()
will be passed (more refs: #1 and #2). Finally, both the market tokens and asset tokens will be transferred to the borrower. At this step, the borrower has successfully stolen certain asset tokens. To steal the remaining tokens, perform the next step.
The borrower must burn the stolen market tokens for the underlying asset tokens via the process of executing the WildcatMarketWithdrawals::queueWithdrawal()
and WildcatMarketWithdrawals::executeWithdrawal()
similar to Steps 1.3 - 1.5 above.
Manual Review
To fix the vulnerability, swap the passing arguments (accountAddress
and borrower
) in the WildcatMarketBase::_blockAccount()
and in the WildcatMarketWithdrawals::executeWithdrawal()
, like the snippet below.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol function _blockAccount(MarketState memory state, address accountAddress) internal { Account memory account = _accounts[accountAddress]; if (account.approval != AuthRole.Blocked) { ... if (scaledBalance > 0) { account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( - accountAddress, - borrower, + borrower, + accountAddress, address(this) ); emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); _accounts[escrow].scaledBalance += scaledBalance; emit SanctionedAccountAssetsSentToEscrow( accountAddress, escrow, state.normalizeAmount(scaledBalance) ); } _accounts[accountAddress] = account; } } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol 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, + borrower, + accountAddress, address(asset) ); asset.safeTransfer(escrow, normalizedAmountWithdrawn); emit SanctionedAccountWithdrawalSentToEscrow( accountAddress, escrow, expiry, normalizedAmountWithdrawn ); } else { ... } ... }
Other
#0 - c4-pre-sort
2023-10-27T02:48:28Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T12:08:28Z
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
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L487 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L156 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L67 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L68
The WildcatMarketController::setAnnualInterestBips()
applies the annualInterestBips
parameter (lending interest rate) set by a borrower without asserting it with the controller's MinimumAnnualInterestBips
/MaximumAnnualInterestBips
constraints, allowing a borrower to set a zero interest for their borrowing.
A borrower can execute the WildcatMarketController::setAnnualInterestBips()
to modify their market's annualInterestBips
parameter (lending interest rate). Upon execution, the function will invoke the WildcatMarketConfig::setAnnualInterestBips()
on the target market to adjust the annualInterestBips
.
However, the annualInterestBips
inputted by the borrower will be applied to the target market without asserting with the controller's MinimumAnnualInterestBips
and MaximumAnnualInterestBips
constraints.
Hence, the borrower can set a zero interest for their borrowing. In other words, all lenders will receive 0% lending interest.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol function setAnnualInterestBips( address market, uint16 annualInterestBips ) external virtual onlyBorrower onlyControlledMarket(market) { // If 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); } // FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketConfig.sol function setAnnualInterestBips(uint16 _annualInterestBips) public onlyController nonReentrant { MarketState memory state = _getUpdatedState(); if (_annualInterestBips > BIP) { revert InterestRateTooHigh(); } @> state.annualInterestBips = _annualInterestBips; _writeState(state); emit AnnualInterestBipsUpdated(_annualInterestBips); }
A borrower can set a zero interest for their borrowing. In other words, all lenders will receive 0% lending interest.
Manual Review
Assert the annualInterestBips
parameter against the controller's MinimumAnnualInterestBips
and MaximumAnnualInterestBips
constraints in the WildcatMarketController::setAnnualInterestBips()
, like the below snippet.
// FILE: https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol function setAnnualInterestBips( address market, uint16 annualInterestBips ) external virtual onlyBorrower onlyControlledMarket(market) { + assertValueInRange( + annualInterestBips, + MinimumAnnualInterestBips, + MaximumAnnualInterestBips, + AnnualInterestBipsOutOfBounds.selector + ); // If 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); }
Invalid Validation
#0 - c4-pre-sort
2023-10-27T14:14:50Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:24:20Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T12:26:08Z
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
98.3346 USDC - $98.33
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L14 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L42 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsEscrow.sol#L26 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L75 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L94 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L164
The chainalysisSanctionsList
parameter of the WildcatSanctionsSentinel
contract is an immutable variable. It will be unchangeable if Chainalysis's SanctionsList
contract is down, affecting several essential functions in the Wildcat
protocol to malfunction.
The vulnerability flagged in this report is not about any centralization or trust risks of
Chainalysis
; it is only about the risks from human errors or management mistakes. However, relying on the third party's security (Chainalysis
) should not be the best security idea of theWildcat
protocol.
In the WildcatSanctionsSentinel
contract, the chainalysisSanctionsList
is declared immutable, pointing to Chainalysis's SanctionsList
contract. Therefore, no one can update it if necessary after the Sentinel
contract is deployed.
The chainalysisSanctionsList
parameter is employed in the critical WildcatSanctionsSentinel::isSanctioned()
to query the sanction status of all lenders in every Market
contract throughout the protocol.
I noticed that Chainalysis's SanctionsList
contract implements the critical single-step functions: renounceOwnership() and transferOwnership(). If one of them is somehow executed incorrectly, that could brick the SanctionsList
contract eternally.
Consequently, the Wildcat
protocol's functions will be directly damaged since no one can update the chainalysisSanctionsList
parameter.
contract WildcatSanctionsSentinel is IWildcatSanctionsSentinel { ... @> address public immutable override chainalysisSanctionsList; ... /** * @dev Returns boolean indicating whether `account` is sanctioned * on Chainalysis and that status has not been overridden by * `borrower`. */ function isSanctioned(address borrower, address account) public view override returns (bool) { return !sanctionOverrides[borrower][account] && @> IChainalysisSanctionsList(chainalysisSanctionsList).isSanctioned(account); } ... }
The vulnerability flagged in this report is not about any centralization or trust risks of
Chainalysis
; it is only about the risks from human errors or management mistakes. However, relying on the third party's security (Chainalysis
) should not be the best security idea of theWildcat
protocol.
The following lists essential functions that require the oracle data sourced from Chainalysis's SanctionsList
contract.
WildcatSanctionsEscrow
contract
WildcatMarket
contract
If Chainalysis's SanctionsList
contract is down, the above functions could malfunction. For instance, no one, including the borrower and the Wildcat
protocol owner, can block any sanctioned lenders from interacting with their markets.
Moreover, the bricking of the SanctionsList
will affect all deployed Market
contracts, MarketController
contracts, and MarketControllerFactory
contracts as they point to the singleton Sentinel
contract.
Manual Review
The chainalysisSanctionsList
parameter in the WildcatSanctionsSentinel
contract should be a state variable allowing authorized users (e.g., the protocol owner) to update it when necessary.
Since the Sentinel
is a singleton contract, updating the chainalysisSanctionsList
parameter at the Sentinel
would be effective for all deployed Market
contracts, MarketController
contracts, and MarketControllerFactory
contracts discussed previously.
Oracle
#0 - c4-pre-sort
2023-10-27T15:15:31Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-10-28T18:31:14Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-11-01T17:37:59Z
d1ll0n (sponsor) acknowledged
#3 - c4-sponsor
2023-11-01T17:38:05Z
d1ll0n marked the issue as disagree with severity
#4 - d1ll0n
2023-11-01T17:38:06Z
I think this could be a good QA finding but I wouldn't consider it medium severity, or even fixable. Allowing anyone to change the sanctions list address would only replace this issue with a much worse one, namely that the Wildcat team would be able to block lenders from markets.
Also worth noting that the sanctions list can not be paused as suggested in #138 - the effect of this issue (ignoring the possibility of a malicious owner - only considering the key being lost) would just be that the contract stops adding new sanctioned addresses.
#5 - serial-coder
2023-11-03T06:20:32Z
I'm new to the +Backstage, and this is my first +Backstage comment. I'm not sure if I'm allowed to reply to the sponsor's comment for now. If not, please do not suspend my +Backstage privileges.
As the sponsor said:
Allowing anyone to change the sanctions list address would only replace this issue with a much worse one, namely that the Wildcat team would be able to block lenders from markets.
Please let me clarify the sponsor's comment with this excerpted part from the Recommended Mitigation Steps
section:
The
chainalysisSanctionsList
parameter in theWildcatSanctionsSentinel
contract should be a state variable allowing authorized users (e.g., the protocol owner) to update it when necessary.
Specifically, the report suggested that only authorized staff (e.g., the Wildcat
protocol owner/admin) should be able to update the chainalysisSanctionsList
parameter, not any unauthorized user.
I raised this issue because the SanctionsList
contract implements the critical single-step functions: renounceOwnership() and transferOwnership(). If one of them is executed incorrectly (by mistakes or human errors), that could brick the SanctionsList
contract eternally.
As the sponsor said:
The Wildcat team would be able to block lenders from markets.
I'm not arguing that.
However, if the SanctionsList
cannot add or remove sanctioned addresses, this can directly affect Wildcat
's essential functions relying on it, including WildcatSanctionsEscrow::canReleaseEscrow()
, WildcatMarketConfig::nukeFromOrbit()
, WildcatMarketConfig::stunningReversal()
, WildcatMarketWithdrawals::executeWithdrawal()
, and their dependency functions.
Of course, the Wildcat
team might be able to block lenders from markets manually, but the incident will break the protocol functionality forever. Moreover, this also reduces the transparency and/or decentralization of managing sanctioned lenders.
For this reason, relying on the third party's security (Chainalysis
) should not be the best security idea of the Wildcat
 protocol.
My suggested solution can fully fix the vulnerability without leading to other issues.
Please re-consider this excerpted part from the Recommended Mitigation Steps
section thoroughly:
The
chainalysisSanctionsList
parameter in theWildcatSanctionsSentinel
contract should be a state variable allowing authorized users (e.g., the protocol owner) to update it when necessary.Since the
Sentinel
is a singleton contract, updating thechainalysisSanctionsList
parameter at theSentinel
would be effective for all deployedMarket
contracts,MarketController
contracts, andMarketControllerFactory
contracts discussed previously.
#6 - MarioPoneder
2023-11-07T22:44:15Z
It`s worth to mention that the Chainalysis: Sanctions Oracle's owner is a simple walllet, not a multisig, not a timelock. Therefore, the concern that it could be compromised is valid. (But this external dependency is not in scope of this contest.)
On the other hand, the proposed solution that only authorized staff (e.g., the Wildcat protocol owner/admin) should be able to set a new sanctions list address adds a second point of failure. Therefore, not a viable solution.
As this is not a bug of the protocol itself and cannot be circumvented easily in a satisfactory way, QA seems most appropriate.
@serial-coder
I want to assure you that your comment before post-judging QA did not have any impact on my assessment of severity.
As a fresh backstage warden, mistakes can happen. All the best for your journey on C4.
#7 - c4-judge
2023-11-07T22:44:49Z
MarioPoneder changed the severity to QA (Quality Assurance)
#8 - c4-judge
2023-11-09T14:51:36Z
MarioPoneder marked the issue as grade-a