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: 23/131
Findings: 5
Award: $407.94
🌟 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
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142-L162 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L1-L516
Closing a market is a vital part of logic in the protocol. Closing a market will make depositing and borrowing unavailable and will either transfer tokens from the borrower to the market or vice versa depending on the t otalDebts
and totalAssets
.
closeMarket
is marked as onlyController
, meaning only the WildcatMarketController
can call the function.
The problem here is that, the WildcatMarketController
has no function that calls closeMarket
on a specific market. This means that markets can never be closed.
Inspecting WildcatMarketController
reveals that nowhere does it call closeMarket
or has any function that interacts with closeMarket
.
Something of note, is that the tests that are testing the closeMarket
functionality are written incorrectly.
Example:
function test_closeMarket_FailTransferRemainingDebt() external asAccount(address(controller)) { // Borrow 80% of deposits then request withdrawal of 100% of deposits _depositBorrowWithdraw(alice, 1e18, 8e17, 1e18); vm.expectRevert(SafeTransferLib.TransferFromFailed.selector); market.closeMarket(); }
You'll notice the modifier called asAccount
.
modifier asAccount(address prank) { startPrank(prank); _; stopPrank(); }
This modifier just pranks as the address that is given to him. The problem here is that we are just pranking as a contract address and using Foundry we call closeMarket
.
Manual Review
Add a function inside WildcatMarketController
that calls closeMarket
.
Other
#0 - c4-pre-sort
2023-10-27T07:06:39Z
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-07T13:59:28Z
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: 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
3.3357 USDC - $3.34
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L95-L98 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L172-L176 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L166-L170
createEscrow
is used to create an escrow for an account that was sanctioned by Chainanalysis. When an account gets blocked his funds gets transferred to the escrow. The only way for the account to retrieve the tokens from the escrow is to get unsanctioned by Chainanalysis or the borrower of the market to override the sanction.
Let's take a look at the createEscrow
function:
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(); }
You can see the arguments of the function are as follows: address borrower, address account, address asset
.
Now let's see where the createEscrow
is called:
In 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, // This should be the borrower borrower, // This should be the sanctioned account (accountAddress) address(this) ); emit Transfer(accountAddress, escrow, state.normalizeAmount(scaledBalance)); _accounts[escrow].scaledBalance += scaledBalance; emit SanctionedAccountAssetsSentToEscrow( accountAddress, escrow, state.normalizeAmount(scaledBalance) ); } _accounts[accountAddress] = account; } }
In 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, // This should be the borrower borrower, // This should be the sanctioned account (accountAddress) address(asset) ); asset.safeTransfer(escrow, normalizedAmountWithdrawn); emit SanctionedAccountWithdrawalSentToEscrow( accountAddress, escrow, expiry, normalizedAmountWithdrawn ); } else { ... }
You can see that in both these instances the borrower
and accountAddress
are swapped, meaning that when we create an escrow, the accountAddress
will be set as the borrower
and the account
will be set to the borrower
.
Paste the following inside test/market/WildcatMarket.t.sol
and run forge test --mt test_EscrowIsNotCreatedCorrectly -vvvv
.
function test_EscrowIsNotCreatedCorrectly() external { // Alice deposits 10_000 tokens to the market vm.prank(alice); market.depositUpTo(10_000e18); // Alice queues a withdrawal for all her tokens vm.prank(alice); market.queueWithdrawal(10_000e18); // Chainanalysis sanctions Alice MockChainalysis(address(SanctionsList)).sanction(alice); vm.warp(block.timestamp + 2 weeks); // We executeWithdrawal for alice and the batch market.executeWithdrawal(alice, 1); // 0xEa0D550abcacF378882E6f583ed06dCF036d3aB7 address of the created escrow, take a look at the logs for more info // You can see that inside the escrow, the borrower is Alice // while the account is the borrower // It should be the other way around assertEq(alice, WildcatSanctionsEscrow(0xEa0D550abcacF378882E6f583ed06dCF036d3aB7).borrower()); assertEq(borrower, WildcatSanctionsEscrow(0xEa0D550abcacF378882E6f583ed06dCF036d3aB7).account()); }
Manual Review Foundry
Swap the the borrower
and accountAddress
, so they are in their correct positions.
Other
#0 - c4-pre-sort
2023-10-27T02:32:37Z
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-07T12:05:12Z
MarioPoneder marked the issue as partial-50
377.709 USDC - $377.71
collectFees
is a function that collects the protocol fees and sends them to the fee recipient address.
collectFees
calls _writeState
, which checks if liqudityRequried > totalAssets
and if it is, it sets isDelinquent = true
, meaning the borrower will now start accruing extra fees until he covers the liquidityRequired
.
The problem here lies in that, _writeState
is called before asset.transferFrom
, meaning that when _writeState
is called, totalAssets
will be the token amount BEFORE the fees were sent out to the fee recipient, basically it's using an old incorrect value.
This is a problem, as if collectFees
is called and it should put the borrower in delinquency, it won't, since it's using an old value for totalAssets
. Because of this, the borrower won't start accruing delinquency fees immediately or even not at all, if he repays some tokens so the next time _writeState
is called liqudityRequired < totalAssets
.
Paste the following inside test/market/WildcatMarket.t.sol
and run forge test --mt test_collectFeesDoesntPutBorrowerInDelinquencyEvenIfItShould -vvvv
.
// To see the difference, run this test once with // _writeState() before asset.transfer // and once with // _writeState() after asset.transfer function test_collectFeesDoesntPutBorrowerInDelinquencyEvenIfItShould() external { // Alice deposits tokens vm.prank(alice); market.depositUpTo(50_000e18); vm.warp(block.timestamp + 12); // 1 block passes // Borrower borrows up to the maximum borrowableAssets - 1e18 vm.startPrank(borrower); market.borrow(market.borrowableAssets() - 1e18); vm.stopPrank(); // Time passes and collectFees is called vm.warp(block.timestamp + 7 hours); market.collectFees(); // false, if _writeState() is called before asset.transfer // true, if _writeState() is called after asset.transfer assertEq(false, market.previousState().isDelinquent); vm.warp(block.timestamp + 365 days); // Warp time to better showcase the difference in the values below market.updateState(); // 1100087941403649145486263143 is scaleFactor if _writeState() is called before asset.transfer // 1200089593611292566435953603 is scaleFactor if _writeState() is called after asset.transfer assertEq(1100087941403649145486263143, market.previousState().scaleFactor); }
Manual Review Foundry
Call _writeState
after asset.safeTransfer
.
Other
#0 - c4-pre-sort
2023-10-27T15:08:29Z
minhquanym marked the issue as duplicate of #36
#1 - c4-judge
2023-11-07T15:15:34Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T15:15:40Z
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
When creating a Market through the WildcatMarketController
, there are certain min/max constraints that need to be followed. They apply for several parameters including annualInterestBips
.
When creating a market though deployMarket
the constraints are enforced through the function enforceParameterConstraints
.
There is also a function inside WildcatMarketController
called setAnnualInterestBips
. The function is used to set the annualInterestBips
for a specific market that the borrower has already deployed.
The protocol team stated that a invariant of the protocol is: "Market parameters should never be able to exit the bounds defined by the controller which deployed it". Basically the market params can't go under/over the min/max values that the protocol team has defined.
The problem here is that no constraints are enforced on annualInterestBips
, which breaks assumptions and an important invariant of the protocol.
Go to test/shared/TestConstants.sol
and change the following values.
... uint16 constant MinimumAnnualInterestBips = 1_000; // Originally was 0 uint16 constant MaximumAnnualInterestBips = 8_000; // Originally was 10_000
Add the following test inside test/market/WildcatMarket.t.sol
and run forge test --mt test_noConstraintsOnSetAnnualInterest -vvvv
function test_noConstraintsOnSetAnnualInterest() external { vm.prank(borrower); controller.setAnnualInterestBips(address(market), 0); // The min is 1_000 and we can set it to 0 assertEq(0, market.previousState().annualInterestBips); vm.prank(borrower); controller.setAnnualInterestBips(address(market), 10_000); // The max is 8_000 and we can set it to 10_000 assertEq(10_000, market.previousState().annualInterestBips); }
Manual Review Foundry
Add the following inside setAnnualInterestBips
:
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. assertValueInRange( annualInterestBips, MinimumAnnualInterestBips, MaximumAnnualInterestBips, AnnualInterestBipsOutOfBounds.selector ); ...
Context
#0 - c4-pre-sort
2023-10-27T14:13:24Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:24:21Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T12:25:39Z
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
Currently there is no type of repay function inside WildcatMarket
, so the borrower has to use ERC20.transfer/transferFrom
to repay his debts.
The problem is, even if the borrower transfers asset tokens to the market, updateState
isn't called. So for example if the borrower is delinquent and decides to repay his loans, his state won't get updated automatically with the transfer of tokens, meaning he will stay in delinquency and continue accruing fees, even after repaying.
In my opinion it's bad design to force the borrower to call another function, instead of just having a repay function. It's possible a malicious actor to block stuff the borrower from calling updateState
. The delinquency fees has to be quite large so it makes sense to grief the borrower in such a way, but it's still possible in some occasions.
Let's imagine the following:
updateState
, but gets block stuffed by a malicious actor. Bob unfairly accrues more delinquency fees.
7B. Bob forgets to call updateState
, so more delinquency fees are accrued to him.Manual Review
Add a repay function inside WildcatMarket
that transfers the assets to the market and calls _getUpdatedState
and _writeState
.
Other
#0 - c4-pre-sort
2023-10-28T11:40:48Z
minhquanym marked the issue as duplicate of #76
#1 - c4-judge
2023-11-08T16:37:04Z
MarioPoneder changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-11-09T15:10:15Z
MarioPoneder marked the issue as grade-b