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: 20/131
Findings: 5
Award: $415.23
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xbepresent, Aymen0909, InAllHonesty, QiuhaoLi, TrungOre, ggg_ttt_hhh, hash, nonseodion, rvierdiiev
304.1365 USDC - $304.14
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L154
closeMarket()
doesn't account for possible future fees for delinquency, which can be a lose for lenders (although borrowers can send the fees after the market is closed, he/she can argue the market is closed and refuse to pay that).
In closeMarket()
, we will send the difference between total debts and total assets from the borrower to the 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); <--- }
The debts are accounted as normalized total supply, protocol fees, and unclaimed (paid) withdraws:
function totalDebts(MarketState memory state) internal pure returns (uint256) { return state.normalizeAmount(state.scaledTotalSupply) + state.normalizedUnclaimedWithdrawals + state.accruedProtocolFees; }
However, even if the market isn't delinquent after the debts are paid, fees for delinquency can still exist depending on how long the market has been delinquent. If we only get the current total debts, the unclaimed delinquent fees can be a loss for lenders. E.g.:
Manual Review.
closeMarket
.For 1, the patch can be:
diff --git a/src/market/WildcatMarket.sol b/src/market/WildcatMarket.sol index e072c0f..8cde74c 100644 --- a/src/market/WildcatMarket.sol +++ b/src/market/WildcatMarket.sol @@ -148,6 +148,7 @@ contract WildcatMarket is if (_withdrawalData.unpaidBatches.length() > 0) { revert CloseMarketWithUnpaidWithdrawals(); } + require(state.timeDelinquent <= delinquencyGracePeriod, "Delinquent fee is being collected."); uint256 currentlyHeld = totalAssets(); uint256 totalDebts = state.totalDebts(); if (currentlyHeld < totalDebts) {
Context
#0 - c4-pre-sort
2023-10-28T02:37:26Z
minhquanym marked the issue as duplicate of #592
#1 - c4-judge
2023-11-07T15:36:12Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T15:41:08Z
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/WildcatMarket.sol#L142 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatArchController.sol#L8
We provide closeMarket()
in WildMarket.sol
for the borrower to close the market when they want to stop paying interest and return all outstanding debt. However, we didn't implement the relative call in WildMarketController.sol
, which makes the method useless, and the borrower has to keep paying interest (assets loss).
The coseMarket()
is only callable by the market controller:
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(); }
However, there is no call to this method in the controller, making this method useless.
Manual Review.
Add a call to closeMarket()
in WildMarketController.sol
:
diff --git a/src/WildcatMarketController.sol b/src/WildcatMarketController.sol index 55f62f6..3a7a54a 100644 --- a/src/WildcatMarketController.sol +++ b/src/WildcatMarketController.sol @@ -513,4 +513,8 @@ contract WildcatMarketController is IWildcatMarketControllerEventsAndErrors { } } } + + function closeMarket(address market) external onlyBorrower onlyControlledMarket(market) { + WildcatMarket(market).closeMarket(); + } }
Context
#0 - c4-pre-sort
2023-10-27T07:17:56Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T14:04:46Z
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: 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
The borrower can't increase the maximum supply, which may cause economic loss for the borrower/lenders or violate the offline law contract.
We provide a setMaxTotalSupply()
method in WildcatMarketConfig.sol
for the borrower to increase the max supply of a market:
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); }
As we can see, it's only callable from the market controller. However, there is no such call in WildMarketController.sol
, making this method useless.
Now consider:
Manual Review.
diff --git a/src/WildcatMarketController.sol b/src/WildcatMarketController.sol index 55f62f6..fd7ff3d 100644 --- a/src/WildcatMarketController.sol +++ b/src/WildcatMarketController.sol @@ -513,4 +513,9 @@ contract WildcatMarketController is IWildcatMarketControllerEventsAndErrors { } } } + + function setMaxTotalSupply(address market, uint256 _maxTotalSupply) external onlyBorrower onlyControlledMarket(market) { + WildcatMarket(market).setMaxTotalSupply(_maxTotalSupply); + } + }
Context
#0 - c4-pre-sort
2023-10-27T06:24:58Z
minhquanym marked the issue as duplicate of #162
#1 - c4-pre-sort
2023-10-27T06:58:20Z
minhquanym marked the issue as duplicate of #147
#2 - c4-judge
2023-11-07T13:51:00Z
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/main/src/market/WildcatMarketToken.sol#L54 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketToken.sol#L60
WildcatMarketToken will forbid tokens to transfer from or to a blocked/sanctioned address but doesn't forbid a blocked/sanctioned account from getting an allowance and using its allowance, i.e., interacting with WildcatMarketToken and trigger token transfers, which can lead to law issues (possible assets loss).
In WildMarketBase.sol:_getAccount()
, we will revert if it's a blocked account:
function _getAccount(address accountAddress) internal view returns (Account memory account) { account = _accounts[accountAddress]; if (account.approval == AuthRole.Blocked) { revert AccountBlacklisted(); } }
And in WildcatMarketToken.sol
, we use the _getAccount
for from/to address during transfer process:
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(); } Account memory fromAccount = _getAccount(from); <--- fromAccount.scaledBalance -= scaledAmount; _accounts[from] = fromAccount; Account memory toAccount = _getAccount(to); <---
So basically when an account is blocked/sanctioned, we forbid tokens to be transferred from or to the account address. However, in transferFrom()
, we didn't check if the msg.sender
is a blocked/sanctioned address:
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) { uint256 newAllowance = allowed - amount; _approve(from, msg.sender, newAllowance); } _transfer(from, to, amount);
So, a sanctioned account can still interact with WildcatMarketToken.transferFrom
with its previous allowance.
To give a reference, tokens like USDT will check if the address is blacklisted in transferFrom
:
// Forward ERC20 methods to upgraded contract if this one is deprecated function transferFrom(address _from, address _to, uint _value) public whenNotPaused { require(!isBlackListed[_from]); if (deprecated) { return UpgradedStandardToken(upgradedAddress).transferFromByLegacy(msg.sender, _from, _to, _value); } else { return super.transferFrom(_from, _to, _value); } }
The following PoC shows that we can use transfer()
directly for a blocked/sanctioned account, but it can use transferFrom
for token transfers:
// test/market/WildcatMarketConfig.t.sol:WildcatMarketConfigTest function test_sanction_transferFrom() external { _deposit(bob, 1e18); _deposit(alice, 1e18); sanctionsSentinel.sanction(bob); market.nukeFromOrbit(bob); assertEq( uint(market.getAccountRole(bob)), uint(AuthRole.Blocked), 'account role should be Blocked' ); startPrank(alice); IERC20(address(market)).approve(bob, 10000000); stopPrank(); startPrank(bob); vm.expectRevert(); IERC20(address(market)).transfer(address(0x1234), 10000000); // will revert in _transfer() with "AccountBlacklisted()" IERC20(address(market)).transferFrom(alice, address(0x1234), 10000000); // will success }
Output:
Running 1 test for test/market/WildcatMarketConfig.t.sol:WildcatMarketConfigTest [PASS] test_sanction_transferFrom() (gas: 883736) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.49ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Review.
diff --git a/src/market/WildcatMarketToken.sol b/src/market/WildcatMarketToken.sol index 5e53a0e..dcaeb6a 100644 --- a/src/market/WildcatMarketToken.sol +++ b/src/market/WildcatMarketToken.sol @@ -43,6 +43,8 @@ contract WildcatMarketToken is WildcatMarketBase { function transferFrom( address from, address to, uint256 amount ) external virtual nonReentrant returns (bool) { + require(_accounts[msg.sender].approval != AuthRole.Blocked); +
ERC20
#0 - c4-pre-sort
2023-10-27T03:18:29Z
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:39:33Z
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/main/src/WildcatSanctionsSentinel.sol#L95 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/interfaces/IWildcatSanctionsSentinel.sol#L71 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol#L172 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol#L166
escrow.account
is wrongly set to the borrower
. So a blocked lender's escrow assets can be released to the borrower while sanctioned. This is a direct assets loss for the lender (who should get the assets back after being unsanctioned) and an indirect loss for the borrower/other lenders (dirty money flows to them may make them sanctioned).sanctionOverrides[borrower][escrowContract]
is wrongly set to sanctionOverrides[account][escrowContract]
, so the escrow contract can be sanctioned and blocked later. But the borrower can use overrideSanction()
to fix this.In IWildcatSanctionsSentinel.sol
we define createEscrow()
as:
function createEscrow( address account, address borrower, address asset ) external returns (address escrowContract);
And in WildcatMarketBase.sol:_blockAccount()
and WildcatMarketWithdrawals.sol:executeWithdrawal()
, we follow the definitions:
if (scaledBalance > 0) { account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( <------- accountAddress, borrower, address(this) ); ...... if (IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { _blockAccount(state, accountAddress); address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( <-------- accountAddress, borrower, address(asset) );
However, in WildcatSanctionsSentinel.sol:createEscrow()
, we wrongly reversed the order of borrower
and accountAddress
:
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; <--------
So escrow.account
is wrongly set to the borrower
, which means canReleaseEscrow()
will return true
and the assets will be released to the borrower. Also, and sanctionOverrides[borrower][escrowContract]
is wrongly set to sanctionOverrides[account][escrowContract]
, the borrower can use overrideSanction()
to fix this.
The following PoC shows escrow assets of bob are wrongly released the borrower even bob is stationed:
// test/market/WildcatMarketConfig.t.sol import 'forge-std/console.sol'; function test_createEscrow_fail() external { _deposit(bob, 1e18); sanctionsSentinel.sanction(bob); market.nukeFromOrbit(bob); assertEq( uint(market.getAccountRole(bob)), uint(AuthRole.Blocked), 'account role should be Blocked' ); WildcatSanctionsSentinel sentinel = WildcatSanctionsSentinel(market.sentinel()); address expect_escrowContract = sentinel.getEscrowAddress(market.borrower(), bob, address(market)); address wrong_escrowContract = sentinel.getEscrowAddress(bob, market.borrower(), address(market)); console.log("expect_escrowContract.codehash = %x", uint256(expect_escrowContract.codehash)); console.log("wrong_escrowContract.codehash = %x", uint256(wrong_escrowContract.codehash)); console.log("wrong_escrowContract.account == bob: %s", WildcatSanctionsEscrow(wrong_escrowContract).account() == bob); console.log("wrong_escrowContract.account == borrower: %s", WildcatSanctionsEscrow(wrong_escrowContract).account() == market.borrower()); console.log("assets can be released to the **borrower while bob being sanctioned**: "); console.log("wrong_escrowContract.canReleaseEscrow = %s", WildcatSanctionsEscrow(wrong_escrowContract).canReleaseEscrow()); uint256 borrower_balance_before_release = IERC20(address(market)).balanceOf(market.borrower()); WildcatSanctionsEscrow(wrong_escrowContract).releaseEscrow(); uint256 borrower_balance_after_release = IERC20(address(market)).balanceOf(market.borrower()); console.log("borrower get market tokens from bob's wrong_escrowContract = %s", borrower_balance_after_release - borrower_balance_before_release); }
Output:
Running 1 test for test/market/WildcatMarketConfig.t.sol:WildcatMarketConfigTest [PASS] test_createEscrow_fail() (gas: 746081) Logs: expect_escrowContract.codehash = 0x0 wrong_escrowContract.codehash = 0x15a581ca1b307ec9f5718c7472e92635eb6aba398642962be11b5d682a08f0d wrong_escrowContract.account == bob: false wrong_escrowContract.account == borrower: true assets can be released to the **borrower while bob being sanctioned**: wrong_escrowContract.canReleaseEscrow = true borrower get market tokens from bob's wrong_escrowContract = 1000000000000000000
Manual Review.
function createEscrow( - address borrower, address account, + address borrower, address asset ) public override returns (address escrowContract) { if (!IWildcatArchController(archController).isRegisteredMarket(msg.sender)) {
Context
#0 - c4-pre-sort
2023-10-27T02:28:20Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:55:23Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xStalin, HChang26, Infect3d, Jiamin, Juntao, QiuhaoLi, circlelooper, crunch, rvierdiiev
91.2409 USDC - $91.24
Judge has assessed an item in Issue #481 as 2 risk. The relevant finding follows:
A blocked/sanctioned account can still received interest
#0 - c4-judge
2023-11-15T20:40:58Z
MarioPoneder marked the issue as duplicate of #550
#1 - c4-judge
2023-11-15T20:41:05Z
MarioPoneder marked the issue as satisfactory