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: 11/131
Findings: 4
Award: $612.38
π Selected for report: 2
π Solo Findings: 0
π Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xbepresent, Aymen0909, InAllHonesty, QiuhaoLi, TrungOre, ggg_ttt_hhh, hash, nonseodion, rvierdiiev
395.3774 USDC - $395.38
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L151-L154 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/libraries/FeeMath.sol#L168-L172
To explain this issue, I will need to mention two things: the fee structure of the protocol and how closing a market works. Let's start with the fees.
Lenders earn interest with two different types of fees: Base interest and delinquency fee. The base interest depends on the annual interest rate of the market and it is paid by the borrower no matter what. On the other hand, the delinquency fee is a penalty fee and it is paid by the borrower if the reserves of the market drop below the required reserves amount.
The important part is how the penalty fees are calculated and I'll be focusing on penalty fees at the moment.
Every market has a delinquency grace period, which is a period that is not penalized. If a market is delinquent but the grace period is not passed yet, there is no penalty fee. After the grace period is passed, the penalty fee is applied.
The most crucial part starts now: The penalty fee does not become 0 immediately after the delinquency is cured. The penalty fee is still being applied even after the delinquency is cured until the grace tracker counts down to zero.
An example from the protocol gitbook/borrowers section is: "Note: this means that if a markets grace period is 3 days, and it takes 5 days to cure delinquency, this means that 4 days of penalty APR are paid."
Here you can find the code snippet of penalty fee calculation:
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/libraries/FeeMath.sol#L89
Now, let's check how to close a market and here is the closeMarket()
function:
file: WildcatMarket.sol 142. function closeMarket() external onlyController nonReentrant { 143. MarketState memory state = _getUpdatedState(); 144. state.annualInterestBips = 0; 145. state.isClosed = true; 146. state.reserveRatioBips = 0; 147. if (_withdrawalData.unpaidBatches.length() > 0) { 148. revert CloseMarketWithUnpaidWithdrawals(); 149. } 150. uint256 currentlyHeld = totalAssets(); 151.@> uint256 totalDebts = state.totalDebts(); //@audit-issue Current debt is calculated with the current scaleFactor. It doesn't check if there are remaining "state.timeDelinquent" to pay penalty fees. 152. if (currentlyHeld < totalDebts) { 153. // Transfer remaining debts from borrower 154.@> asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); //@audit remaining debt is transferred and market is closed, but if the market was delinquent for a while, debt will keep increasing. Total assets will not cover the total debt 155. } else if (currentlyHeld > totalDebts) { 156. // Transfer excess assets to borrower 157. asset.safeTransfer(borrower, currentlyHeld - totalDebts); 158. } 159. _writeState(state); 160. emit MarketClosed(block.timestamp); 161. }
While closing the market, the total debt is calculated and the required amount is transferred to the market. This way all debts are covered. However, the covered total debt is calculated with the current scale factor. As you can see above, this function does not check if there are still penalties to be paid. It should have checked the state.timeDelinquent
.
If the state.timeDelinquent > grace period
when closing the market (which means the borrower still has more penalties to pay), the scale factor will keep increasing after every state update.
The borrower didn't pay the remaining penalties when closing the market, but who will pay it?
Lenders will keep earning those penalty fees (the base rate will be 0 after closing, but the penalty fee will still accumulate)
Lenders will start withdrawing their funds.
All lenders except the last one will withdraw the exact debt to the lender when closed + the penalty fee after closing
.
The last lender will not even be able to withdraw the exact debt to the lender when closed
because some portion of the funds dedicated to the last lender are already transferred to the previous lenders as the penalty fee.
The borrower might intentionally do it to escape from the penalty, or the borrower may not even be aware of the situation.
The borrower had a cash flow problem after taking the debt
The market stayed delinquent for a long time
The borrower found some funds
The borrower wanted to close the high-interest debts right after finding some funds
Immediately paid everything and closed the market while the market was still delinquent.
From the borrower's perspective, they paid all of their debt while closing the market.
But in reality, the borrower only paid the half of the penalty fee (while the counter was counting up). But the second half of the penalties, which will be accumulated while the counter was counting down, is not paid by the borrower.
The protocol does not check if there are remaining penalties, and doesn't charge the borrower enough while closing the market.
I provided a coded PoC below that shows every step of the vulnerability.
Borrowers who are aware of this may create charming markets with lower base rate but higher penalty rate (They know they won't pay the half of it). Or the borrowers may not be aware of this, but the protocol doesn't take the required penalty from them. They "unintentionally" not pay the penalty, but the lender will have to cover it.
You can use the protocol's own test setup to prove this issue.
- Copy the snippet below, and paste it into the WildcatMarket.t.sol
test file.
- Run it with forge test --match-test test_closeMarket_withoutPaying_HalfofThePenalty -vvv
// @audit Not pay the half, leave it to the last lender function test_closeMarket_withoutPaying_HalfofThePenalty() external { // -----------------------------------------CHAPTER ONE - PREPARE-------------------------------------------------------------------------------- // ------------------------------DEPOSIT - BORROW - WITHDRAW -> MARKET IS DELINQUENT------------------------------------------------------------- // Alice and Bob deposit 50k each, borrower borrows 80% _authorizeLender(bob); vm.prank(alice); market.depositUpTo(50_000e18); vm.prank(bob); market.depositUpTo(50_000e18); vm.prank(borrower); market.borrow(80_000e18); // Alice and Bob request withdrawal for 10k each, reserve will be 0, market will be delinquent. vm.prank(alice); market.queueWithdrawal(10_000e18); vm.prank(bob); market.queueWithdrawal(10_000e18); // skip withdrawal batch duration skip(1 days); market.executeWithdrawal(alice, 86401); //86401 is the batch expiry. I hardoced it to make it shorter but it can also be found with _witdrawalData market.executeWithdrawal(bob, 86401); // Update the state. Market must be delinquent. market.updateState(); MarketState memory state = market.previousState(); assertTrue(state.isDelinquent); //----------------------------------------------CHAPTER TWO - ACTION------------------------------------------------------------------------------ //----------------------------------CLOSE THE MARKET IMMEDIATELY AFTER PAYING DEBT---------------------------------------------------------------- // Fast forward the time while delinquent to see the effect of delinquency penalty fees. skip(30 days); // Give some funds to the borrower to pay the debt while closing. asset.mint(address(borrower), 100_000e18); _approve(borrower, address(market), type(uint256).max); // We will close the market now. Save current state parameters just before closing. market.updateState(); state = market.previousState(); uint256 normalizedBalanceOfAliceBeforeClosing = state.normalizeAmount(market.scaledBalanceOf(alice)); uint256 normalizedBalanceOfBobBeforeClosing = state.normalizeAmount(market.scaledBalanceOf(bob)); uint256 totalDebtBeforeClosing = state.totalDebts(); uint256 scaleFactorBeforeClosing = state.scaleFactor; console2.log("debt before closing: ", totalDebtBeforeClosing); console2.log("scale factor before closing: ", scaleFactorBeforeClosing); // Total debt before closing == normalized balance of Alice and Bob + unclaimed rewards + protocol fees. assertEq(totalDebtBeforeClosing, normalizedBalanceOfAliceBeforeClosing + normalizedBalanceOfBobBeforeClosing + state.normalizedUnclaimedWithdrawals + state.accruedProtocolFees); // Close the market. vm.prank(address(controller)); market.closeMarket(); // Total asset in the market must be equal to the total debts. All debts are covered (ACCORDING TO CURRENT DEBT) assertEq(state.totalDebts(), market.totalAssets()); //-----------------------------------------------CHAPTER THREE - SHOW IT------------------------------------------------------------------------------- //---------------------------------DEBT WILL KEEP ACCUMULATING BECAUSE OF THE REMANINING PENALTY FEES-------------------------------------------------- // Fast forward 30 more days. // Annual interest rate is updated to 0 when closing the market, but penalty fee keeps accumulating until the "state.timeDelinquent" goes toward 0. skip(30 days); // Update the state. market.updateState(); state = market.previousState(); uint256 totalDebtAfterClosing = state.totalDebts(); uint256 scaleFactorAfterClosing = state.scaleFactor; // Debt and scale factor kept accumulating. Total debt is higher than the paid amount by borrower. assertGt(totalDebtAfterClosing, totalDebtBeforeClosing); assertGt(scaleFactorAfterClosing, scaleFactorBeforeClosing); console2.log("debt after closing: ", totalDebtAfterClosing); console2.log("scale factor after closing: ", scaleFactorAfterClosing); // Who will pay this difference in debt? --> The last lender to withdraw from the market will cover it. // Previous lenders except the last one will keep earning those penalty fees, but the last one will have to pay those funds. // Alice withdraws all of her balance. uint256 normalizedBalanceOfAliceAfterClosing = state.normalizeAmount(market.scaledBalanceOf(alice)); vm.prank(alice); market.queueWithdrawal(normalizedBalanceOfAliceAfterClosing); // withdrawal batch duration skip(1 days); market.executeWithdrawal(alice, 5356801); // 5356801 is the emitted batch expiry. I hardoced it to make it shorter but it can also be found with _witdrawalData // After Alice's withdrawal, there won't be enough balance in the market to fully cover Bob. // Bob had to pay the penalty fee that the borrower didn't pay uint256 normalizedBalanceOfBobAfterClosing = state.normalizeAmount(market.scaledBalanceOf(bob)); assertGt(normalizedBalanceOfBobAfterClosing, market.totalAssets()); console2.log("total assets left: ", market.totalAssets()); console2.log("normalized amount bob should get: ", normalizedBalanceOfBobAfterClosing); }
Below, you can find the test results:
Running 1 test for test/market/WildcatMarket.t.sol:WildcatMarketTest [PASS] test_closeMarket_withoutPaying_HalfofThePenalty() (gas: 714390) Logs: debt before closing: 81427089816031080808713 scale factor before closing: 1016988862478541592821945607 debt after closing: 82095794821496423225911 scale factor after closing: 1025347675046858373036920502 total assets left: 40413182814156745887236 normalized amount bob should get: 41013907001874334921477 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.41ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manuel review, Foundry
I think there might be two different solutions: Easier one and the other one.
The easy solution is just not to allow the borrower to close the market until all the penalty fees are accumulated. This can easily be done by checking state.timeDelinquent
in the closeMarket()
function.
That one is simple, but I don't think it is fair for the borrower because the borrower will have to pay the base rate too for that additional amount of time. Maybe the borrower will be inclined to pay the current debt + future penalties
and close the market as soon as possible.
That's why I think closing the market can still be allowed even if there are penalties to accumulate. However, the problem with that one is we can not know the exact amount of future penalties due to the compounding mechanism. It will depend on how many times the state is updated while the grace counter counts down.
Therefore I believe a buffer amount should be added. If the borrowers want to close the market, they should pay current debt + expected future penalties + buffer amount
, and the excess amount from the buffer should be transferred back to the borrower after every lender withdraws their funds.
Context
#0 - c4-pre-sort
2023-10-28T02:53:25Z
minhquanym marked the issue as duplicate of #592
#1 - c4-judge
2023-11-07T15:40:30Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T15:44:34Z
MarioPoneder marked the issue as selected for report
#3 - laurenceday
2023-11-09T08:29:25Z
We decided to permit borrowers to close markets if delinquent and just zero out state.timeDelinquent
, returning everything outstanding at that moment and ending things there. It's preferable from the POV of the lender to just be able to access their notional and interest ASAP rather than wait for the timer to run back down to within the grace period. There's no alternative 'good' solution to this that isn't particularly fiddly given the way in which interest compounds.
The suggested solution would require that people waited until the market was out of delinquency before the scale factor caught up when only the penalty rate applied so they could redeem for the extra amount due to them: this could be a long time if the situation leading up to a market closure after going delinquent is a protracted one.
Mitigated by https://github.com/wildcat-finance/wildcat-protocol/pull/57/commits/292ebda50008062dac0c5acbffe45639143fadd1
#4 - MarioPoneder
2023-11-12T15:58:19Z
Was selected for report because of overall quality and level of detail.
#5 - c4-sponsor
2023-11-14T17:29:01Z
laurenceday (sponsor) confirmed
#6 - c4-sponsor
2023-11-14T17:29:07Z
laurenceday marked the issue as disagree with severity
#7 - c4-sponsor
2023-11-14T17:30:16Z
laurenceday marked the issue as agree with severity
#8 - laurenceday
2023-11-14T17:30:34Z
Inside me are two wolves, apparently
π 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
A Wildcat market can never be closed.
Borrowers can create markets and control these markets through controller contracts.
A market can be closed by calling WildcatMarket::closeMarket()
function and this function has onlyController
modifier.
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142
function closeMarket() external onlyController nonReentrant { ... }
This function must be called by the controller contract but the controller contract does not make any calls to the WildcatMarket
contract to close the market.
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol
It is impossible to close a market at this moment. Normally, closing a market updates the annual interest rate to 0, and sets the reserve ratio to 0.
It is possible to work around this situation and manually set the annual interest rate to 0 without closing the market, but the reserve ratio can not be set manually. This will make the market prone to delinquency, which will cause borrowers to pay a penalty APR.
Manual review
I would recommend adding a function in the controller contract to close the market.
Error
#0 - c4-pre-sort
2023-10-27T07:12:50Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T14:02: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: osmanozdemir1
Also found by: 0xCiphky, 0xStalin, HChang26, Infect3d, Jiamin, Juntao, QiuhaoLi, circlelooper, crunch, rvierdiiev
118.6132 USDC - $118.61
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L79 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L178
Blocked accounts keep earning interest contrary to the WhitePaper
Lenders might be flagged as sanctioned by the ChainAnalysis oracle and these lenders can be blocked with the nukeFromOrbit()
function or during a withdrawal execution. Both of these functions will call the internal _blockAccount()
function.
function _blockAccount(MarketState memory state, address accountAddress) internal { // skipped for brevity @> 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; // skipped for brevity } }
As we can see above, if the user's scaled balance is greater than zero, an escrow contract is created for market tokens and the scaled token balance is transferred to the escrow contract.
In this protocol, there are two types of tokens: Underlying tokens, and market tokens. Market tokens are used for internal balance accounting and it is tracked with scaled balances
. Underlying tokens are tracked with normalized balances
. scaleFactor
is the value that provides accumulated interest, and increment with every state update. scaled balances
are multiplied by the scaleFactor
and this will provide the normalized balances
.
scaled balances do earn interest.
normalized balances do not earn interest.
According to the protocol's WhitePaper (page 16), blocked accounts should not earn interest. "The effect of this would be that an auxiliary escrow contract is deployed and the debt obligation of the borrower towards the lender is transferred to this new contract. Interest does not accrue within this contract from the time the debt is transferred, and the borrower is expected to immediately return the balance of funds due up to that time to the escrow contract."
However, because the escrow contract holds scaled balances, these funds will keep earning interest.
These scaled balances will be normalized when the funds are released (after the sanction is removed) according to that date's scaleFactor
.
Note: There might be two escrow contract for the same lender if it is triggered during withdrawal request. One with market tokens and the other with underlying tokens. The one with underlying tokens does not earn interest as expected. However, the escrow with the market tokens will still keep earning interest. I believe a blocked account earning interest contradicts the idea of the blocking.
Manuel review, reading
I think all of the scaled balances should be normalized before transferring to the escrow contract to stop earning interest.
Context
#0 - c4-pre-sort
2023-10-27T14:49:25Z
minhquanym marked the issue as duplicate of #123
#1 - c4-judge
2023-11-07T18:15:14Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T18:20:59Z
MarioPoneder marked the issue as selected for report
#3 - laurenceday
2023-11-09T08:41:03Z
Error in whitepaper. Acknowledged (and embarrassing) but won't fix.
Needs to account for growth in the event that the sanction is performed by a hostile (read: non-Chainalysis) actor in control of the oracle. Borrowers affected by this that are concerned about growth potential can close the market.
#4 - MarioPoneder
2023-11-12T15:58:58Z
Was selected for report due to concise PoC and reference to Whitepaper.
#5 - MiloTruck
2023-11-13T14:42:47Z
Hi @MarioPoneder, could you re-evaluate the severity of this finding?
A mismatch between the whitepaper and code has historically been judged as low unless a substantial impact has been demonstrated, more specifically, if it could lead to funds at risk.
This is the stance agreed upon by many judges, as seen below.
From discussion in backstage:
From trust90: imo these issues are quite nuanced and case by case basis. Generally speaking, if the user facing docs can be reasonably interpreted to interact in a certain way, and this interaction could lead to a freeze or loss of funds, this has the potential to be rated H/M
From the org repo:
Recommend Low Severity for any behavior that is different from the whitepaper, without any notable risk
I don't believe that the bug here could cause any significant harm, even when misinterpreted. The only impact is that blocked accounts still accrue interest while their funds are in the escrow, which is intended behavior as mentioned by the sponsor above.
As such, I don't think this should be a medium severity finding.
Thanks!
#6 - ding99ya
2023-11-13T18:14:30Z
I agree with @MiloTruck, in most other contest mismatch with whitepaper is just QA.
#7 - 0xdeth
2023-11-13T18:29:46Z
I agree with @MiloTruck. Inconsistencies between the code and the docs is QA.
#8 - MarioPoneder
2023-11-14T16:25:43Z
Thank you for all the input on this, here in the comments and within the discussion.
There are cases where a mistake in the docs/whitepaper (whereby whitepaper > rest of docs) is easy to see and other cases where the impact due to a mismatch between whitepaper and code is negligible. In those cases QA is justified without any doubt.
For every warden participating in this contest, the whitepaper was a source of truth while the code was in question / to be audited:
As a consequence, Medium severity seems appropriate in this specific case.
#9 - laurenceday
2023-11-14T17:48:15Z
I'm genuinely surprised at seeing wardens coming out to bat for this, thank you @MiloTruck, @ding99ya and @0xdeth.
I very much want to slap the 'Disagree with severity' button, but feel like we'd just be relitigating a position that @MarioPoneder has defended pretty respectably.
Acknowledging.
#10 - c4-sponsor
2023-11-14T17:48:20Z
laurenceday (sponsor) acknowledged
#11 - MarioPoneder
2023-11-14T17:58:39Z
Thank you! I want everyone to know that I respect the counter arguments and partially agree with them.
Such cases are always a narrow path between respecting the sponsor's opinions and intentions while also treating wardens with respect and fairness, while additionally staying within the C4 guidelines & rules.
I am glad that in the majority of cases I can genuinely agree with the sponsor, but I am also convinced that I made a fair decision in this particular case.
#12 - ding99ya
2023-11-14T18:03:12Z
@MarioPoneder I appreciate you give clear explanation why this one is medium. Thanks for your effort!
π 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
98.3346 USDC - $98.33
Tokens will still be stuck in the escrow contract in same cases, even if the borrower overrides the sanction to release funds
Some accounts might be sanctioned due to legal reasons or any reason. If a lender is sanctioned, an escrow contract is created and the sanctioned lender's funds are transferred to this escrow contract. These funds can be released from the escrow contract after the sanction is removed or overridden.
The sanction status of an account is tracked with the ChainAnalysis sanction list. Additionally to that, the borrower has the power to override the sanction of an account. With this power, funds in the escrow can be released even if the account is still sanctioned at the oracle level.
Here is the overrideSanction
function:
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L48C1-L51C4
function overrideSanction(address account) public override { sanctionOverrides[msg.sender][account] = true; emit SanctionOverride(msg.sender, account); }
Below, you can see the releaseEscrow() function:
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsEscrow.sol#L33C1-L41C4
function releaseEscrow() public override { if (!canReleaseEscrow()) revert CanNotReleaseEscrow(); uint256 amount = balance(); IERC20(asset).transfer(account, amount); //@audit it will only be transferred to the account. No other option. What if the account is still blocked at token contract level? emit EscrowReleased(account, asset, amount); }
If the sanction is overridden by the borrower, canReleaseEscrow()
function will return true and assets will be transferred.
The issue here is the assets can only be transferred to the account itself.
Why is it a problem, you may ask?
The problem is that if an account is sanctioned in ChainAnalysis oracle, that account is very likely to be blocked at the contract level too for some tokens. For example, USDC has a blacklist option.
The aim of the sanctionOverride
is being able to release funds even if the account is sanctioned. But if the account is blocked/blacklisted at the contract level, it will not matter if the borrower overrides the sanction or not. The funds can not be transferred regardless of the overriding, and the override function will not serve its purpose.
Manual review
releaseEscrow()
function is public, doesn't take any arguments, and transfers funds directly to the account. I think this one should remain still, and another function should be added.
The new function should only be called by the account itself, take an address argument, and transfer the funds to the provided address. This way, sanctionOverride
functionality will serve its purpose even if the account itself is sanctioned at the contract level, and the account owner can transfer his/her funds to another address.
function releaseEscrowToAnotherAddress(address _to) public override { if (!canReleaseEscrow()) revert CanNotReleaseEscrow(); -> if (msg.sender != account) revert YouAreNotTheOwnerOfTheFunds(); uint256 amount = balance(); -> IERC20(asset).transfer(_to, amount); emit EscrowReleased(account, asset, amount); }
Token-Transfer
#0 - c4-pre-sort
2023-10-28T12:24:50Z
minhquanym marked the issue as low quality report
#1 - minhquanym
2023-10-28T12:25:06Z
Lender can transfer the market token to another address then withdraw
#2 - c4-judge
2023-11-08T18:03:35Z
MarioPoneder marked the issue as unsatisfactory: Insufficient proof
#3 - osmanozdemir1
2023-11-13T18:10:59Z
Hi @MarioPoneder, Thanks for judging this contest.
I would like to share legal documents of two biggest stable coins to add some context. For USDC: Reference link Legal & Privacy - USDC Terms - Section 3. Applicable Laws and Regulations: "Applicable laws require us to prevent Restricted Persons from holding USDC using USDC Services. A Restricted Person means any person that is the subject or target of any sanctions, including a person that is:
For USDT: Reference link Terms of Service Clause 8.15: "...If Tether determines that you have engaged in any Prohibited Use, Tether may address such Prohibited Use through an appropriate sanction, in its sole and absolute discretion."
Clause 9: "...Tether in its absolute and sole discretion may determine that you are a customer of TIL or TLTD. Such determination will be communicated to you. Additionally, Tether monitors for and assesses suspicious or sanctionable transactions under applicable AML, Anti-Corruption, and Economic Sanctions Laws, as well as undertakes mandatory reporting to FinCEN, OFAC, FIA, and international regulators..."
These legal documents show that major stable token issuers track OFAC sanctions. A sanctioned account will most likely be blocked at the contract level too as mentioned in the original submission, which defeats the whole idea of sanction overriding. Tokens will still be locked in the escrow contract even if the borrower overrides the sanction. This line will pass after sanction overriding, but the transaction will revert a few lines below in this line.
Second thing I would like to mention is related to lookout's this comment:
Lender can transfer the market token to another address then withdraw
Unfortunately, this is not true. Tokens can only be released to the account itself. In fact, ability to transfer the market token to another address is exactly what I suggested as solution in the "recommended mitigation steps" section.
And last thing I would like to point out is in the contest page in C4 website (under the Attack Ideas (where to look for bugs)): "Consider ways in which parties to an escrow contract might be locked out of it, or the escrow contract might otherwise be bricked."
This submission shows how the escrow might be bricked and funds might still be locked even if the borrower overrides the sanctions. I would appreciate if you could reconsider the severity of this finding.
Kind regards
#4 - MarioPoneder
2023-11-14T21:49:16Z
Thank you for your comment!
The "assets" in the escrow are actually the market tokens, not the underlying tokens. Therefore the transfer in releaseEscrow()
cannot be blocked at token contract level.
The lender can just transfer the released market tokens to another address and then proceed to withdraw.
#5 - osmanozdemir1
2023-11-14T22:37:20Z
Hi again sir, thank you for the response.
I would like to point out that there might be two different escrow contracts for the same account, one for market tokens and the other one for underlying tokens.
Reference related to double escrow mechanism from the contest channel on discord.
If the escrows created in the executeWithdrawal()
function, this line will create escrow for market tokens, and this will create escrow for underlying assets, which are transferred to escrow here.
Thanks again for your time
#6 - MarioPoneder
2023-11-14T22:51:22Z
Thank you for this clarification! Given this input and the likelihood of impact while maintaining consistency with #717 and #305 (please review the reasoning in those issues), QA seems most appropriate.
#7 - c4-judge
2023-11-14T22:51:33Z
MarioPoneder changed the severity to QA (Quality Assurance)
#8 - c4-judge
2023-11-14T22:51:38Z
MarioPoneder marked the issue as grade-b
#9 - osmanozdemir1
2023-11-14T23:11:22Z
Hi @MarioPoneder
Thank you for your response and referencing some issues.
I understand your reasoning in those referenced issues that says users decide which tokens to use and it's not a bug of protocol per se, and I agree with you for those cases.
However in this case, the protocol already has an additional functionality, which is overriding a sanction, and that functionality is broken.
This was my last comment and thanks for all of your efforts. Kind regards
#10 - MarioPoneder
2023-11-14T23:47:08Z
Although it's not a bug of the protocol per se, the conflict between overriding the sanction status of the Chainalysis oracle while sactioning/blacklisting is still in place on token level is an extremely interesting thought.
#11 - c4-judge
2023-11-14T23:47:14Z
MarioPoneder marked the issue as grade-a