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: 31/131
Findings: 5
Award: $290.62
π Selected for report: 1
π 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
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L142-L161 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol#L136-L139
WildcatMarket::closeMarket() function
has defined the onlyController
modifier, which validates that the caller is the Controller that was registered when the Market was deployed, this means that only the Controller contract is allowed to call this function, but, in the WildcatMarketController
contract there is not any single reference that calls the closeMarket() function.
WildcatMarketBase contract
modifier onlyController() { //@audit-info => If the caller is not the controller contract, the tx is reverted! if (msg.sender != controller) revert NotController(); _; }
WildcatMarket contract
//@audit-info => onlyController modifier enforces that the only allowed caller is the Controller contract function closeMarket() external onlyController nonReentrant { ... ... ... }
Manual Audit
Access Control
#0 - c4-pre-sort
2023-10-27T07:29:13Z
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:06:45Z
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: 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/main/src/market/WildcatMarketConfig.sol#L134-L144 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol#L136-L139
WildcatMarketConfig::setMaxTotalSupply()
function is restricted to be called only by the controller, it has implemented the onlyController
modifier, which enforces the caller to be the Controller contract. The problem is that in the WildcatMarketController
contract there is not a single reference that calls the setMaxTotalSupply(), thus, the borrowers won't be able to update the original value that was set for the maxTotalSupply variable.
WildcatMarketBase contract
modifier onlyController() { //@audit-info => If caller is not the controller contract, the tx is reverted! if (msg.sender != controller) revert NotController(); _; }
WildcatMarketConfig contract
//@audit-info => onlyController modifier enforces that the only allowed caller is the Controller contract function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); if (_maxTotalSupply < state.totalSupply()) { revert NewMaxSupplyTooLow(); } state.maxTotalSupply = _maxTotalSupply.toUint128(); _writeState(state); emit MaxTotalSupplyUpdated(_maxTotalSupply); }
Manual Audit
Access Control
#0 - c4-pre-sort
2023-10-27T06:25:25Z
minhquanym marked the issue as duplicate of #162
#1 - c4-pre-sort
2023-10-27T06:58:16Z
minhquanym marked the issue as duplicate of #147
#2 - c4-judge
2023-11-07T13:50:09Z
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
17.0566 USDC - $17.06
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol#L182-L190
Sentinel::isSanctioned()
function to verify if the account(lender) is sanctioned by the borrower of the market
Sentinel::isSanctioned()
function, it can be noted that the lender's account must have been sanctioned in the Oracle first before the account is finally sanction in a MarketWildcatSanctionsSentinel.sol
function isSanctioned(address borrower, address account) public view override returns (bool) { //@audit-info => sanctionOverrides[borrower][account] must be false <==> sanction must not be overridden for this function to return true! //@audit-info => If sanctionOverrides[borrower][account] is set to true, this function will return false, as if the account would not be sanctioned //@audit-info => For this function to return true, the account's sanction should have not been overridden (it's set to false), and the account must have been sanctioned in the ChainalysisSanctionsList Oracle. return !sanctionOverrides[borrower][account] && IChainalysisSanctionsList(chainalysisSanctionsList).isSanctioned(account); }
Now, based on the previous explanation, we know that the lender's account needs to be sanctioned in the Chainalysis Oracle before the Sentinel::isSanctioned()
function is called.
WildcatMarketToken::transfer()
function, as a result, the lender's account that is sanction in the Chainalysis Oracle has no MarketTokens anymore, all those tokens have been moved to another accounts.
So, at this point, the lender's MarketTokens were distributed among different accounts of his own, such accounts have never interacted with the Market, so, their current role is the Null
Role.
Everything might look fine because the accounts where the tokens were sent have no permissions to interact with the Market, but there is a bug that allows lenders to gain the WithdrawOnly Role on any account they want without having the consent of the borrower
WildcatMarketController::updateLenderAuthorization()
function, the reason of this problem will be explained in the below code walkthrough:
_isAuthorized
as false, and in the WildMarketConfig::updateAccountAuthorization()
function, because the value of _isAuthorized
is false, it will end up granting the WithdrawOnly Role.
WildcatMarketController.sol
//@audit-info => Anybody can call this function and pass a lender and an array of markets where the changes will be applied! 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(); } //@audit-info => Forwards the value of the `lender` argument, and depending on the `lender` address is found in the _authorizedLenders EnumerableSet.AddressSet, will be forwarded a true or false accordingly //@audit => If the lender address is not found in the _authorizedLenders variable, it will forward a false to the Market::updateAccountAuthorization() function WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender)); } }
EnumerableSet.sol
function contains(AddressSet storage set, address value) internal view returns (bool) { //@audit-info => Calls the internal _contains() //@audit-info => If the given value is found it will return true, otherwise it will return false! return _contains(set._inner, bytes32(uint256(uint160(value)))); } //@audit-info => The internal function will just return a true or false if the given value is in the set or not, but the tx won't be reverted! /** * @dev Returns true if the value is in the set. O(1). */ function _contains(Set storage set, bytes32 value) private view returns (bool) { return set._indexes[value] != 0; }
WildcatMarketConfig.sol
function updateAccountAuthorization( address _account, //@audit-info => For any account that is not registered in the `_authorizedLenders` of the Controller, this flag was set as false! bool _isAuthorized ) external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); //@audit-info => If the accountAddress is not registered in the storage, the approval role is set to Null //@audit-info => If the account has been blacklisted, tx will revert! Account memory account = _getAccount(_account); if (_isAuthorized) { account.approval = AuthRole.DepositAndWithdraw; //@audit => Any account not registered in the Controller will be assigned the WithdrawOnly role. } else { account.approval = AuthRole.WithdrawOnly; } _accounts[_account] = account; _writeState(state); emit AuthorizationStatusUpdated(_account, account.approval); }
Manual Audit
WildcatMarketController::updateLenderAuthorization()
function, either only allow the Borrower to call it, or create a type of withelist of valid actors who are capable of updating the lender's authorization on the Markets, in this way, the Lenders won't be capable of granting the WithdrawOnly Role to any account they want to, thus, they won't be able even to attempt to escape the sanctions.WildcatMarketController.sol
- function updateLenderAuthorization(address lender, address[] memory markets) external { + function updateLenderAuthorization(address lender, address[] memory markets) external onlyAuthorizedEntities(){ for (uint256 i; i < markets.length; i++) { address market = markets[i]; if (!_controlledMarkets.contains(market)) { revert NotControlledMarket(); } WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender)); } } modifier onlyAuthorizedEntities() { require(msg.sender == <authorizedEntities>, "you are not allowed sir"); _; }
Context
#0 - c4-pre-sort
2023-10-27T08:58:28Z
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:41:46Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2023-11-07T14:47:42Z
MarioPoneder marked the issue as selected for report
#4 - laurenceday
2023-11-09T08:30:51Z
#5 - MarioPoneder
2023-11-12T15:52:58Z
Was selected because of concise impact and detailed step-by-step PoC.
#6 - c4-sponsor
2023-11-14T17:25:19Z
laurenceday (sponsor) confirmed
π Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xStalin, HChang26, Infect3d, Jiamin, Juntao, QiuhaoLi, circlelooper, crunch, rvierdiiev
91.2409 USDC - $91.24
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatSanctionsEscrow.sol#L33-L41 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketToken.sol#L15-L19 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/libraries/FeeMath.sol#L50
balanceOf()
function of the MarketToken contract, which returns the normalized balance of account
with interest.WildcatSanctionsEscrow.sol
function releaseEscrow() public override { if (!canReleaseEscrow()) revert CanNotReleaseEscrow(); //@audit-info => Will call the balanceOf() of the asset contract! uint256 amount = balance(); //@audit-info => Transfers the tokens to the account out of this escrow! //@audit-info => When the asset is a MarketToken, the `amount` has already been accounted with all the interests that were accrued during all the time the Lender's account was sanctioned! IERC20(asset).transfer(account, amount); emit EscrowReleased(account, asset, amount); }
function balance() public view override returns (uint256) { //@audit-info => When the escrow is holding MarketContracts, will call the balanceOf() of the WildcatMarketToken contract! return IERC20(asset).balanceOf(address(this)); }
WildcatMarketToken.sol
function balanceOf(address account) public view virtual nonReentrantView returns (uint256) { (MarketState memory state, , ) = _calculateCurrentState(); //@audit-info => By normalizing the amount will account for all the interests that were generated during all the time the Lender's account was sanctioned! return state.normalizeAmount(_accounts[account].scaledBalance); } function _transfer(address from, address to, uint256 amount) internal virtual { MarketState memory state = _getUpdatedState(); uint104 scaledAmount = state.scaleAmount(amount).toUint104(); if (scaledAmount == 0) { revert NullTransferAmount(); } //@audit-info => Will transfer all the MarketTokens that were locked in the Escrow to the Account //@audit-info => Then, the Account will be able to withdraw those MarketTokens for the asset's market and will receive all the interest that was accrued during the sanctioned period Account memory fromAccount = _getAccount(from); fromAccount.scaledBalance -= scaledAmount; _accounts[from] = fromAccount; Account memory toAccount = _getAccount(to); toAccount.scaledBalance += scaledAmount; _accounts[to] = toAccount; _writeState(state); emit Transfer(from, to, amount); }
FeeMath.sol
function applyProtocolFee( MarketState memory state, uint256 baseInterestRay, uint256 protocolFeeBips ) internal pure returns (uint256 protocolFee) { // Protocol fee is charged in addition to the interest paid to lenders. uint256 protocolFeeRay = protocolFeeBips.bipMul(baseInterestRay); //@audit-info => Here, the scaledTotalSupply of the MarketTokens is the base to compute the protocolFee //@audit-info => All the MarketTokens locked in escrows will also be accounted as part of the protocolFee protocolFee = uint256(state.scaledTotalSupply).rayMul( uint256(state.scaleFactor).rayMul(protocolFeeRay) ); state.accruedProtocolFees = (state.accruedProtocolFees + protocolFee).toUint128(); }
Manual Audit
Context
#0 - c4-pre-sort
2023-10-27T14:51:59Z
minhquanym marked the issue as duplicate of #123
#1 - c4-judge
2023-11-07T18:16:01Z
MarioPoneder marked the issue as satisfactory
π Selected for report: MiloTruck
Also found by: 0xStalin, DarkTower, GREY-HAWK-REACH, InAllHonesty, J4X, YusSecurity, devival
172.0937 USDC - $172.09
Judge has assessed an item in Issue #571 as 2 risk. The relevant finding follows:
[L-01] Internal accounting wonβt work for rebase & elastic tokens
#0 - c4-judge
2023-11-09T15:28:34Z
MarioPoneder marked the issue as duplicate of #560
#1 - c4-judge
2023-11-09T15:29:14Z
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/WildcatMarketToken.sol#L59-L82
The current implementation of the approvals functionality is such that the approver grants X amount of MarketTokens to the spender to be spent on his behalf, the problem is that the accounting of the MarketTokens is being handled in ray units (scaledAmounts), not in normal units, as opposed to the accounting of the approvals mechanism which is handled in normal units, this difference will cause that as the time passes (and the scaleFactor) grows, the real amount of MarketTokens that the spenders will be allowed to spend, it will continuously decrease as the scaleFactor grows.
In example, Alice approves Bob to spend 10 MarketTokens on her behalf. Time passes, and Bob wants to spend all his allowance, now the scaleFactor has grown from 1.5 to 2, as a result, because of the existing implementation, Bob's allowance will be reduced to 0, but the real amount of MarketTokens that were actually transferred will be less than 10, the reason is that in the _transfer() function, the input amount (10), it will be scaled considering the value of the scaleFactor, this will cause that the computed value of the scaledAmount will be less than 10, thus, Bob was able to transfer less than what he was allowed to.
WildcatMarketToken.sol
function _approve(address approver, address spender, uint256 amount) internal virtual { //@audit-info => Allowances are not saved in scaledUp units //@audit-info => Spender receives as allowance the exact input amount, regardless of the value of the scaleFactor! allowance[approver][spender] = amount; emit Approval(approver, spender, amount); } function transferFrom( address from, address to, uint256 amount ) external virtual nonReentrant returns (bool) { uint256 allowed = allowance[from][msg.sender]; // Saves gas for unlimited approvals. if (allowed != type(uint256).max) { //@audit-info => The inputted amount is deducted from the allowance //@audit-info => i.e. Bob wants to transfer 10 MarketTokens from Alice, the current implementation will reduce the allowance of Bob by 10, regardless of how many MartketTokens are actually transferred in the below _transfer() function uint256 newAllowance = allowed - amount; _approve(from, msg.sender, newAllowance); } _transfer(from, to, amount); return true; } function _transfer(address from, address to, uint256 amount) internal virtual { MarketState memory state = _getUpdatedState(); //@audit-info => The inputted amount is scaled, this is considering the current value of the scaleFactor, which as its value grows, the scaledAmount will continuously decrease. uint104 scaledAmount = state.scaleAmount(amount).toUint104(); if (scaledAmount == 0) { revert NullTransferAmount(); } //@audit-info => Does the actual transfer of MarketTokens from one account to the other //@audit-issue => The real amount of MarketTokens will be less than the inputted amount, the reason is, as the scaleFactor grows, the scaledAmount will always be lower and lower //@audit-info => The bigger scaleFactor, the lower the computed scale amount! Account memory fromAccount = _getAccount(from); fromAccount.scaledBalance -= scaledAmount; _accounts[from] = fromAccount; Account memory toAccount = _getAccount(to); toAccount.scaledBalance += scaledAmount; _accounts[to] = toAccount; //@audit-info => As a result, if Bob was allowed to spend 10 MarketTokens, the real amount of MarketTokens that were transferred will be lower and lower as time passes and scaleFactor grows _writeState(state); emit Transfer(from, to, amount); }
Manual Audit
state.scaleAmount()
functionContext
#0 - c4-pre-sort
2023-10-28T11:19:51Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-10-28T11:20:29Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-11-01T11:44:18Z
d1ll0n (sponsor) disputed
#3 - d1ll0n
2023-11-01T11:45:57Z
All user interactions happen in units of normalized market tokens, which are equivalent to underlying token amounts. If Alice has 10 market tokens (normalized) and grants Bob approval for 10 market tokens, Alice's balance growing from 10 to 20 via interest accrual should not affect the approval she has given to Bob. He can still only transfer 10 tokens from Alice in normalized units. If this were not the case, it would mean Bob could cause balanceOf(alice)
to go from 20 to 0, even though Alice called approve(bob, 10)
.
Users should not even need to consider scaled token amounts, the same way people don't consider their scaled balances of Aave's aTokens. They think in terms of the normalized units, by which metric their balance is simply always increasing rather than being static and growing in value.
#4 - c4-judge
2023-11-07T16:42:29Z
MarioPoneder changed the severity to QA (Quality Assurance)
#5 - MarioPoneder
2023-11-07T16:47:02Z
I acknowledge the discrepancy leading to confusing UX.
However, the way it is currently implemented does not cause further malicious impacts, therefore QA.
#6 - c4-judge
2023-11-09T14:58:03Z
MarioPoneder marked the issue as grade-b