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: 54/131
Findings: 3
Award: $59.02
🌟 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
User cannot change the capacity of the vault, implementation for this change is in the code but is not callable by anyone, as the setMaxTotalSupply is only callable via the controller but is never called in the controller.
About changing the the capacity of the market the whitepaper states the following:
If a borrower decides they want less, this is also fine - dropping the capacity does not have any impact on assets that are currently in the vault, as the capacity dictates openness to new deposits. If a lender deposits 100 WETH into a vault and the borrower decides that actually, that’ll do, they can drop the maximum capacity to zero if they wish, but they’re still on the hook to ultimately repay the lender that 100 WETH plus any interest accrued.
So a user is free to increase or decrease the maxTotalSupply/capacity of the vault, but the final implementation just let to increase the capacity which is the intended design.
So lets have a look at the code for the setMaxTotalSupply
:
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); }
We can see that modifier used is onlyController
which means that only controller can call this function, so borrower should have a way to invoke this via the controller code.
But the catch is this function is never called in the controller but someone may point how this obvious thing can be missed out in the unit tests.
So if we look at the test contracts for this function, we are calling this function always pranking as the controller but controller actually have no point of entry for borrower to invoke. Following is the test code:
function test_maxTotalSupply() external returns (uint256) { assertEq(market.maxTotalSupply(), parameters.maxTotalSupply); vm.prank(parameters.controller); market.setMaxTotalSupply(10000); assertEq(market.maxTotalSupply(), 10000); }
vm.prank(parameters.controller);
is the problem in this test case
I have added the following function in the test cases that pranks as borrower to invoke the function directly this can be WildcatMarketConfig.t.sol
and run the command:
forge test --match-contract WildcatMarketConfigTest --match-test test_maxTotalSupply_Borrower -vv
and it would pass for the expected revert
function test_maxTotalSupply_Borrower() external returns(uint256) { assertEq(market.maxTotalSupply(), parameters.maxTotalSupply); vm.prank(parameters.borrower); // expect revert with NotController() vm.expectRevert(IMarketEventsAndErrors.NotController.selector); market.setMaxTotalSupply(10000); }
Above will give the output:
Running 1 test for test/market/WildcatMarketConfig.t.sol:WildcatMarketConfigTest [PASS] test_maxTotalSupply_Borrower():(uint256) (gas: 17572) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.12ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Now lets add another function in the controller and run another test
Add the following code in the controller code:
function setMaxTotalSupply(address market, uint256 maxTotalSupply) external onlyBorrower { require(_controlledMarkets.contains(market),"Not a controller market"); WildcatMarket(market).setMaxTotalSupply(maxTotalSupply); }
And add the following code into the WildcatMarketConfig.t.sol
and run the command
function test_setmaxTotaSupply_Borrower_Fixed() external returns(uint256) { assertEq(market.maxTotalSupply(), parameters.maxTotalSupply); vm.prank(parameters.borrower); controller.setMaxTotalSupply(address(market),10000); assertEq(market.maxTotalSupply(), 10000); }
forge test --match-contract WildcatMarketConfigTest --match-test test_setmaxTotaSupply_Borrower_Fixed -vv
It should give the following output:
Running 1 test for test/market/WildcatMarketConfig.t.sol:WildcatMarketConfigTest [PASS] test_setmaxTotaSupply_Borrower_Fixed():(uint256) (gas: 54618) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.61ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Foundry + Sleep + Some brain power + luck
Add the above added setMaxTotalSupply
function in the controller and give the entry point to the borrower, or maybe make the function directly callable by the borrower in the config itself.
Access Control
#0 - c4-pre-sort
2023-10-27T07:00:49Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T13:56:14Z
MarioPoneder marked the issue as partial-50
#2 - c4-judge
2023-11-07T14:16:53Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 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/WildcatMarketBase.sol#L163-L187 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L137-L188 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L95-L120
While calling createEscrow
in WildcatMarketBase
and WildcatMarketWithdrawl
wrong order of inputs is passed instead of the correct signature of the createEscrowFunction
which leads to setting the escrow address against the lender in sanctionOverrides
instead of the borrower and also the deployed address is different from what it should have been due to wrong salt.
Lets look at the function signature of the the createEscrow
function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) { if (!IWildcatArchController(archController).isRegisteredMarket(msg.sender)) { revert NotRegisteredMarket(); } escrowContract = getEscrowAddress(borrower, account, asset); if (escrowContract.codehash != bytes32(0)) return escrowContract; tmpEscrowParams = TmpEscrowParams(borrower, account, asset); new WildcatSanctionsEscrow{ salt: keccak256(abi.encode(borrower, account, asset)) }(); emit NewSanctionsEscrow(borrower, account, asset); sanctionOverrides[borrower][escrowContract] = true; emit SanctionOverride(borrower, escrowContract); _resetTmpEscrowParams(); } }
We can see the first argument is borrower, second is lender and third one and last is the asset being lent.
But the problem is this function is called at two places and in both places first two arguments are switched and in one case third argument is passed as the calling contract instead of the asset in scope.
First in the WildcatMarketBase.sol
function _blockAccount(MarketState memory state, address accountAddress) internal { Account memory account = _accounts[accountAddress]; if (account.approval != AuthRole.Blocked) { uint104 scaledBalance = account.scaledBalance; account.approval = AuthRole.Blocked; emit AuthorizationStatusUpdated(accountAddress, AuthRole.Blocked); if (scaledBalance > 0) { account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(this) ); emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); _accounts[escrow].scaledBalance += scaledBalance; emit SanctionedAccountAssetsSentToEscrow( accountAddress, escrow, state.normalizeAmount(scaledBalance) ); } _accounts[accountAddress] = account; } }
Here we can clearly see the arguments are messed up
Second in the WildcatMarketWithdrawl.sol
function executeWithdrawal( address accountAddress, uint32 expiry ) external nonReentrant returns (uint256) { if (expiry > block.timestamp) { revert WithdrawalBatchNotExpired(); } MarketState memory state = _getUpdatedState(); WithdrawalBatch memory batch = _withdrawalData.batches[expiry]; AccountWithdrawalStatus storage status = _withdrawalData.accountStatuses[expiry][ accountAddress ]; uint128 newTotalWithdrawn = uint128( MathUtils.mulDiv(batch.normalizedAmountPaid, status.scaledAmount, batch.scaledTotalAmount) ); uint128 normalizedAmountWithdrawn = newTotalWithdrawn - status.normalizedAmountWithdrawn; status.normalizedAmountWithdrawn = newTotalWithdrawn; state.normalizedUnclaimedWithdrawals -= normalizedAmountWithdrawn; if (normalizedAmountWithdrawn == 0) { revert NullWithdrawalAmount(); } 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); } emit WithdrawalExecuted(expiry, accountAddress, normalizedAmountWithdrawn); // Update stored state _writeState(state); return normalizedAmountWithdrawn; }
So passing the arguments in wrong order, firstly deploys at the wrong address which is not desired by the protocol, and secondly to prevent the escrow contract we are using the sanctionOverride
mapping which set the escrow contract for the borrower to true to prevent permanent locking of funds in case the oracle gets manipulated or shuts down.
But in this case instead of setting the escrow address for the borrower it is set for the lender(account) which is not intended behaviour.
Manual review
Simply pass the arguments in right order.
Other
#0 - c4-pre-sort
2023-10-27T02:24:01Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:46:36Z
MarioPoneder changed the severity to 3 (High Risk)
#2 - c4-judge
2023-11-07T11:49:17Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: rahul
Also found by: 0xSmartContract, InAllHonesty, J4X, Sathish9098, ZdravkoHr, catellatech, hunter_w3b, invitedtea, nirlin, radev_sw
Wildcat introduces a unique model of lending and borrowing that we have not seen in DEFI ever before. Instead of giving all the control to the protocol, protocol gives all the control to borrowers to create a market with every possible parameter to be modifiable in accordance to the needs of borrowers and lenders.
As the official whitepaper states:
Wildcat is primarily aimed at organisations such as market makers wishing to borrow funds in the medium-term, but can reasonably be extended to parties such as DeFi protocols wishing to raise funds without the consequences of selling a native-token filled treasury into the ground to do so. As a protocol that is - in its current form - reliant upon counterparty selection by borrowers, Wildcat is fundamentally permissioned in nature. Given that the positions that Wildcat vaults allow a lender to enter are undercollateralised by design, it is not suitable for usage in many cases, dependent on risk appetite.
For this audit I followed the top down approach of diving into code on the following scope:
WildcatMarket.sol:
WildcatMarketConfig.sol:
WildcatToken.sol:
WildcatMarketWithdrawal.sol:
WildcatArchController.sol:
WildcatControllerFactory.sol:
WildcatMarketController.sol:
-10.WildcatSanctionsSentinal.sol
This contract deploys the escrow contract, and lets override the oracle blocking the lenders to prevent any oracle problem.
First have a look at the overall architecture of market how market is formed from these multiple contracts
MarketBase inherits from three main contracts, that each bring in their functionalities as discussed above, using different libraries and their own custom functions.
Than the wildcatMarket.sol
inherits from all these contracts
contract WildcatMarket is WildcatMarketBase, WildcatMarketConfig, WildcatMarketToken, WildcatMarketWithdrawals
The interaction between the controller factory, arch controller, sentinal, escrow deployment, controller and market deployment is as per following diagram:
I would say the codebase quality is good but can be improved, there are checks in place to handle different roles, each standard is ensured to be followed. And if something is not fully being followed that have been informed. But still it can be improved in following areas
Codebase Quality Categories | Comments |
---|---|
Unit Testing | Code base is well-tested but there is still place to add fuzz testing and invariant testing and foundry have now solid support for those, I would say best in class. |
Code Comments | Overall comments in code base were not enough, more commenting can be done to more specifically describe specifically mathematical calculation. At many point if there were more detail commenting and natspec it would have been bit easy for the auditor and less question would have been asked from sponser, saving time for both. Specifically perpetual vault needs more comments as its flow is hard to understand |
Documentation | Documentation was to the point but not detailed enough, such novelty requires more documentation thanthat, gitbook content can be further improved. |
Organization | Code organisation was great, best in class, managing each part separately, specifically dividing the market functionalities into separate easy to understand contracts and than using inheritance to use them together.. |
Protocol is highly dependent on the well behaving of the borrower as borrower can run away any time. But wild cat reduces that risks by doing the KYC on its own end before adding the borrower into the market and letting him open the vaults. So it seems covered and safe in this case and there seems to be no concentration risk.
Another case is the deployer may go rouge and add the malicious borrowers into the wild cat system and set the wrong parameters, but it is not clear will either the deployer be multisig or not. This concern can be mitigated too by using the multisig.
If we look at the the package.json for the project, we can see that the not the latest version of openzeppelin is used:
''' "dependencies": { "@openzeppelin/contracts": "^4.9.3", "@types/node": "^20.4.2", "cli-barchart": "^0.2.3", "ethers": "^6.6.3" } '''
Project is using the version ^4.9.3 while the latest recent major upgrade is 0.5.0 which have many improvements and optimisation which makes it more efficient to use.
Some of the main upgrades are:
So instead of using the old version, the project is using ^4.3.1. Replace the old version with newer one to get advantage of all these new updates.
Wildcat brings a novel solution to the existing problem that we have not seen before. Overall the code quality and mission statement is great. Had some hard time understanding the bip and ray mathematics, more documentations would have helped but the solution is gonna serve well to most of the people.
27 hours
#0 - c4-judge
2023-11-09T12:09:31Z
MarioPoneder marked the issue as grade-b
#1 - 0xnirlin
2023-11-13T15:19:52Z
@MarioPoneder will appreciate the second look, IMO the report is on par with some of the grade a marked.
#2 - MarioPoneder
2023-11-14T16:36:54Z
Thank you for your comment! As I pointed out in #704, the bar for analysis reports is very high in this contest:
Due to the high number of analysis reports, it was necessary to set the standard very high in order to properly reward the best reports, therefore:
- Outstanding reports got an A grade.
- Very good reports got a B grade.
- All others got a C grade.
Although many will be dissatisfied about the grading, I deeply appreciate all of you who tired hard to provide value for the reader & sponsor.
Your report is definitely close to grade A, but I have to draw the line somewhere. The current grade A reports provide even more depth & detail, i.e. more value for the sponsor.
Nevertheless, your report is still good and valuable therefore it was graded with B.
Thank you for your understanding.