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: 65/131
Findings: 3
Award: $29.96
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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/main/src/market/WildcatMarketToken.sol#L64-L82
When an account is sanctioned on Chainalysis,
account.approval
to AuthRole.Blocked
and transfer account.scaledBalance
to the corresponding WildcatSanctionsEscrow.Until the account is canceled sanction or the borrower overrides the sanction status, the account can get back the asset locked in WildcatSanctionsEscrow. However, when an account is sanctioned by Chainalysis, changing account.approval
to AuthRole.Blocked
needs to be triggered manually (the two functions mentioned above). Before account.approval
is changed, the account can call ERC20.transfer
to transfer all market tokens it holds to a new account, which does not need to be authorized by the borrower. Afterwards, call WildcatMarketController.updateLenderAuthorization to set the new account's role to AuthRole.WithdrawOnly
. Finally, calling queueWithdrawal creates a withdrawal request. Because the new account has not been sanctioned, call executeWithdrawal to execute a pending withdrawal request to obtain the asset after the batch expires.
The above bypass breaks the core assumption of the protocol: a sanctioned account can only wait until the sanction is lifted or the borrower manually overwrites the sanction status.
 Someone can run a program to continuously monitor whether the specified account has been sanctioned. Once it is detected that the specified account has been sanctioned, all market token of the account will be transferred to a new address via trasnfer
.
File: src\market\WildcatMarketToken.sol 36: function transfer(address to, uint256 amount) external virtual nonReentrant returns (bool) { 37:-> _transfer(msg.sender, to, amount); 38: return true; 39: } ...... 64: function _transfer(address from, address to, uint256 amount) internal virtual { 65: MarketState memory state = _getUpdatedState(); 66: uint104 scaledAmount = state.scaleAmount(amount).toUint104(); 67: 68: if (scaledAmount == 0) { 69: revert NullTransferAmount(); 70: } 71: 72:-> Account memory fromAccount = _getAccount(from); 73: fromAccount.scaledBalance -= scaledAmount; 74: _accounts[from] = fromAccount; 75: 76: Account memory toAccount = _getAccount(to); 77: toAccount.scaledBalance += scaledAmount; 78: _accounts[to] = toAccount; 79: 80: _writeState(state); 81: emit Transfer(from, to, amount); 82: }
L72, _getAccount(from)
is used to obtain the corresponding account information. As long as from's account.approval
is not AuthRole.Blocked
, tx will execute successfully.
Since the account is currently sanctioned by Chainalysis, this status has not yet been synchronized to the Market, which needs to be triggered manually (described in the Impact section).
Consider the following scenario:
As a borrower, Alice deploys a WildcatMarket (M1).
WildcatMarketController.authorizeLenders
to authorize bob’s address (B1).M1.deposit
to deposit some assets and obtain the corresponding M1 token.M1.transfer/transferFrom
to transfer all M1 tokens held by B1 to B2. B2 is not authorized by Alice.M1.updateLenderAuthorization(B2, markets)
, where markets
contains M1. This will set B2's account.approval
to AuthRole.WithdrawOnly
.M1.queueWithdrawal
which only requires AuthRole.WithdrawOnly
.M1.executeWithdrawal
to execute a pending withdrawal request for B2. bob successfully obtains asset(including interest).The above scenario describes how bob bypasses sanction restrictions.
Manual Review
File: src\market\WildcatMarketToken.sol 64: function _transfer(address from, address to, uint256 amount) internal virtual { ...... 68: if (scaledAmount == 0) { 69: revert NullTransferAmount(); 70: } 71:+ require(!IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, from), "banned transfer"); 72: Account memory fromAccount = _getAccount(from); 73: fromAccount.scaledBalance -= scaledAmount; ...... 82: }
Context
#0 - c4-pre-sort
2023-10-27T03:13:32Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-10-27T03:13:44Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-10-30T15:18:30Z
laurenceday (sponsor) confirmed
#3 - laurenceday
2023-10-30T15:22:50Z
Confirmed as an issue with updateAccountAuthorization
: https://github.com/code-423n4/2023-10-wildcat-findings/issues/155#issuecomment-1784137482
Instead of adding bulky checks to transfer
, the easier way to fix this is to fix updateAccountAuthorization
(see comment).
Great find, everyone!
#4 - c4-judge
2023-11-07T14:34:50Z
MarioPoneder marked the issue as satisfactory
#5 - c4-judge
2023-11-07T14:47:38Z
MarioPoneder marked issue #266 as primary and marked this issue as a 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/main/src/market/WildcatMarketBase.sol#L172-L176 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol#L166-L170
When an account is sanctioned on Chainalysis, its assets will be transferred to the corresponding WildcatSanctionsEscrow. If an account wants to get back assets, it needs to meet one of the following conditions:
However, the current implementation passes in wrong parameters when creating WildcatSanctionsEscrow([1], [2]). Market's borrower becomes WildcatSanctionsEscrow's account while Market's account becomes WildcatSanctionsEscrow's borrower. This will have the following impacts:
We will analyze the flow of _blockAccount
to prove this problem.
File: src\market\WildcatMarketBase.sol 163: function _blockAccount(MarketState memory state, address accountAddress) internal { 164: Account memory account = _accounts[accountAddress]; 165: if (account.approval != AuthRole.Blocked) { 166: uint104 scaledBalance = account.scaledBalance; 167: account.approval = AuthRole.Blocked; 168: emit AuthorizationStatusUpdated(accountAddress, AuthRole.Blocked); 169: 170: if (scaledBalance > 0) { 171: account.scaledBalance = 0; 172: address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( 173:-> accountAddress, 174:-> borrower, 175: address(this) 176: ); 177: emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); 178:-> _accounts[escrow].scaledBalance += scaledBalance; 179: emit SanctionedAccountAssetsSentToEscrow( 180: accountAddress, 181: escrow, 182: state.normalizeAmount(scaledBalance) 183: ); 184: } 185: _accounts[accountAddress] = account; 186: } 187: }
This function is divided into two steps:
account.approval
to AuthRole.Blocked
,account.scaledBalance
is greater than 0, create the corresponding escrow and transfer the Market Token to the escrow (at L178).The problem lies in L173 and L174: L173 should be borrower
, and L174 should be accountAddress
. Let's look at the code of WildcatSanctionsSentinel.createEscrow
:
File: src\WildcatSanctionsSentinel.sol 095: function createEscrow( 096: address borrower, 097: address account, 098: address asset 099: ) public override returns (address escrowContract) { ...... 104: escrowContract = getEscrowAddress(borrower, account, asset); 105: 106: if (escrowContract.codehash != bytes32(0)) return escrowContract; 107: 108:-> tmpEscrowParams = TmpEscrowParams(borrower, account, asset); 109: 110: new WildcatSanctionsEscrow{ salt: keccak256(abi.encode(borrower, account, asset)) }(); ...... 119: }
The TmpEscrowParams
at L108 is prepared for the constructor of WildcatSanctionsEscrow. When the account is not sanctioned by Chainalysis or the borrower overrides the sanction status, releaseEscrow
can be called to transfer the asset to the account.
File: src\WildcatSanctionsEscrow.sol 33: function releaseEscrow() public override { 34: if (!canReleaseEscrow()) revert CanNotReleaseEscrow(); 35: 36: uint256 amount = balance(); 37: 38:-> IERC20(asset).transfer(account, amount); 39: 40: emit EscrowReleased(account, asset, amount); 41: }
Through the above analysis, we can see that accountAddress
of L173 in _blockAccount
is eventually set to borrower
of WildcatSanctionsEscrow, and borrower
of L174 is set to account
of WildcatSanctionsEscrow. This means that account
of WildcatSanctionsEscrow is not sanctionedand and releaseEscrow()
can immediately be called to transfer the asset to account
, who is actually borrower
of Market.
Manual Review
There are two places in the code that need to be fixed:
File: src\market\WildcatMarketBase.sol 163: function _blockAccount(MarketState memory state, address accountAddress) internal { ...... 172: address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( 173:- accountAddress, 173:+ borrower, 174:- borrower, 174:+ accountAddress, 175: address(this) 176: ); ...... 187: } File: src\market\WildcatMarketWithdrawals.sol 137: function executeWithdrawal( 138: address accountAddress, 139: uint32 expiry 140: ) external nonReentrant returns (uint256) { ...... 164: if (IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { 165: _blockAccount(state, accountAddress); 166: address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( 167:- accountAddress, 167:+ borrower, 168:- borrower, 168:+ accountAddress, 169: address(asset) 170: ); ...... 188: }
Context
#0 - c4-pre-sort
2023-10-27T02:47:53Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T12:07:43Z
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/main/src/market/WildcatMarket.sol#L63
The borrower can grant authorization for a set of lenders via WildcatMarketController.authorizeLenders. When an account is set as an authorized lender by a borrower, the account can normally deposit assets via WildcatMarket.deposit. This is expected behavior. A malicious user can front-run authorizeLenders
called by borrower to set account.approval
to AuthRole.WithdrawOnly
, which is lower than AuthRole.DepositAndWithdraw
. However, WildcatMarket.deposit
requires the caller to have the AuthRole.DepositAndWithdraw
role, which is checked internally. If the role of msg.sender
is insufficient, tx will be revert.
 deposit
internally calls _getAccountWithRole
to check whether the caller has the AuthRole.DepositAndWithdraw role.
File: src\market\WildcatMarket.sol 59: // Transfer deposit from caller 60: asset.safeTransferFrom(msg.sender, address(this), amount); 61: 62: // Cache account data and revert if not authorized to deposit. 63:-> Account memory account = _getAccountWithRole(msg.sender, AuthRole.DepositAndWithdraw); 64: account.scaledBalance += scaledAmount;
The code for _getAccountWithRole
is as follows:
File: src\market\WildcatMarketBase.sol 197: function _getAccountWithRole( 198: address accountAddress, 199: AuthRole requiredRole 200: ) internal returns (Account memory account) { 201: account = _getAccount(accountAddress); 202: // If account role is null, see if it is authorized on controller. 203: if (account.approval == AuthRole.Null) { 204: if (IWildcatMarketController(controller).isAuthorizedLender(accountAddress)) { 205: account.approval = AuthRole.DepositAndWithdraw; 206: emit AuthorizationStatusUpdated(accountAddress, AuthRole.DepositAndWithdraw); 207: } 208: } 209: // If account role is insufficient, revert. 210:-> if (uint256(account.approval) < uint256(requiredRole)) { 211:-> revert NotApprovedLender(); 212: } 213: }
L201, call _getAccount(accountAddress)
to obtain the corresponding Account
structure. If accountAddress already has the AuthRole.Blocked
role, then the function will revert internally.
L203-208, if the account is a new account and has been authorized by the borrower, then account.approval
will be updated from AuthRole.Null
to AuthRole.DepositAndWithdraw
. If account.approval
is not equal to AuthRole.Null
, then the code here will be skipped.
L210, check that account.approval
will not be lower than requiredRole
. Otherwise, tx will revert.
As can be seen from the above code, if account.approval
is not AuthRole.Null
and cannot be less than requiredRole (here equal to AuthRole.DepositAndWithdraw
).
Anyone can call WildcatMarketController.updateLenderAuthorization, which will internally call WildcatMarket(market).updateAccountAuthorization to set the role of the specified account. If an account has not been authorized by the borrower, then the account's role can be set to AuthRole.WithdrawOnly
. In this way, after the account is authorized, he calls the deposit
function to deposit the asset, and tx will be revert.
Consider the following scenario:
As a borrower, Alice deploys a WildcatMarket (M1).
WildcatMarketController.authorizeLenders
to authorize bob, tx1 enters the memory pool.updateLenderAuthorization(bob, markets)
to front-run tx1. tx2 enters the memory pool. The markets
array contains M1.account.approval
is set to AuthRole.WithdrawOnly
.deposit
to deposit some assets. However, since bob's account.approval
is AuthRole.WithdrawOnly
(2) which is smaller than AuthRole.DepositAndWithdraw
(3), tx revert.Manual Review
File: src\market\WildcatMarketBase.sol 197: function _getAccountWithRole( 198: address accountAddress, 199: AuthRole requiredRole 200: ) internal returns (Account memory account) { 201: account = _getAccount(accountAddress); 202: // If account role is null, see if it is authorized on controller. 203:- if (account.approval == AuthRole.Null) { 203:+ if (account.approval == AuthRole.Null || account.scaledBalance == 0) { 204: if (IWildcatMarketController(controller).isAuthorizedLender(accountAddress)) { 205: account.approval = AuthRole.DepositAndWithdraw; 206: emit AuthorizationStatusUpdated(accountAddress, AuthRole.DepositAndWithdraw); 207: } 208: } 209: // If account role is insufficient, revert. 210: if (uint256(account.approval) < uint256(requiredRole)) { 211: revert NotApprovedLender(); 212: } 213: }
If _getAccount(accountAddress)
at L201 returns normally, it means that accountAddress’s role will not be AuthRole.Blocked
.
DoS
#0 - c4-pre-sort
2023-10-27T09:02:40Z
minhquanym marked the issue as sufficient quality report
#1 - d1ll0n
2023-11-06T11:03:05Z
Good catch. The main issue here is the fact that the role gets set to WithdrawOnly from Null, which should only happen if the account already has the deposit role.
#2 - c4-sponsor
2023-11-06T11:03:10Z
d1ll0n (sponsor) confirmed
#3 - c4-judge
2023-11-07T14:26:40Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - MarioPoneder
2023-11-07T14:28:42Z
DoS is not permanent, another call to updateLenderAuthorization(...)
will give the authorized lender the correct approval, see updateAccountAuthorization(...)
:
if (_isAuthorized) { account.approval = AuthRole.DepositAndWithdraw; } else { account.approval = AuthRole.WithdrawOnly; }
#5 - c4-judge
2023-11-09T15:03:27Z
MarioPoneder marked the issue as grade-b
#6 - securitygrid
2023-11-13T17:50:50Z
Although DOS is not permanent, functions do not work as expected. The lender must manually call updateLenderAuthorization
to update the role to successfully deposit.
Therefore M is appropriate.
#7 - MarioPoneder
2023-11-14T19:16:25Z
Thank you for your comment!
Due to the low impact that is easily circumventable, Medium severity is unjustified.
Furthermore, it would be unfair towards other wardens given the impacts of the present Medium findings in this contest.
Thank you for your understanding.