Platform: Code4rena
Start Date: 01/11/2023
Pot Size: $24,150 USDC
Total HM: 5
Participants: 5
Period: 5 days
Judge: cccz
Total Solo HM: 4
Id: 303
League: ETH
Rank: 1/5
Findings: 5
Award: $0.00
🌟 Selected for report: 3
🚀 Solo Findings: 3
🌟 Selected for report: ladboy233
Data not available
Judge has assessed an item in Issue #19 as 2 risk. The relevant finding follows:
Borrower can abuse enterMarkets to force liquidator can pay more fund
#0 - c4-judge
2023-11-15T04:37:02Z
thereksfour marked the issue as satisfactory
#1 - c4-judge
2023-11-15T04:37:20Z
thereksfour marked the issue as selected for report
#2 - c4-judge
2023-11-15T04:41:31Z
thereksfour marked the issue as primary issue
Data not available
https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L76 https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniPool.sol#L303 https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L152 https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L180
MEV bot can frontrun user's repayment to liquidate user first when the OmniPool is unpaused
this report tries to combine a few issue
note that the repay function has WhenNotPaused modifier
if the OmniPool is paused for a long period of time,
then a malicious liquidator can keep calling accure to force user pay the interest
and when when admin unpause the contract, there is no time lock for user to repay the debt to reduce the borrow account, and the user's is forced to enter the unhealthy state
liquidator cannot liquidate user when the contract is paused, but they can monitor the unpause transaction
MEV bot can frontrun user's repayment to liquidate user first when OmniPool is unpaused
or it is possible during the pause period, the collateral asset price drops and when contract unpaused, before user repay, MEV bot liquidate and seize user asset first to capture liquidation reward
Manual Review
enable repayment even when contract is paused, applies a timelock window for user to repay the debt when contract is unpaused,
do not charge interest when contract is paused
Invalid Validation
#0 - c4-judge
2023-11-07T07:48:46Z
thereksfour marked the issue as duplicate of #32
#1 - c4-judge
2023-11-07T08:40:28Z
thereksfour marked the issue as satisfactory
Data not available
https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L76 https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L103
interest is still accuring when the market is paused, force user to incur debts
when the function accure is called
the interest is accured after the interest rate is calculated
{ uint256 interestRate = IIRM(irm).getInterestRate(address(this), trancheIndex, totalDeposit, totalBorrow); interestAmount = (trancheBorrowAmount_ * interestRate * timePassed) / 365 days / IRM_SCALE; }
the interest rate depends on the time passed as well
the longer time passed, the higher the interest paid
however, even when the market / tranche is paused
the user interest is still accuring
but repay is not a option because the repay function is guarded by whenNotPaused modifier
function repay(uint96 _subId, address _market, uint256 _amount) external whenNotPaused {
then user cannot make account health and write off debt by repaying
the only option that user has is to deposit more fund to make the account healthy
but when the contract is paused, user can deposit but cannot really withdraw
function withdraw(uint96 _subId, uint8 _trancheId, uint256 _share) external nonReentrant returns (uint256 amount) {
the withdraw function is guarded by nonReentrant as well
so any fund that user use to make the account health is locked until the contract is paused
Manual
do not incur interest when the tranche is paused
Timing
#0 - JeffCX
2023-11-06T21:16:52Z
duplicate of #32
#1 - stalinMacias
2023-11-07T01:47:46Z
No, this is not a duplicate of #32 , and I think this is even invalid, borrows and deposits should accrue interests even if the tranch is paused, why they should not? Borrowers pay for the time they are using the borrowedCollateral, no matter if the tranch is paused or not, depositors also receive their yield for the time they've lent their assets.
This looks to me like an invalid problem. I'd say #5 is a duplicate of #32, even though #5 also mentions the same about accruing interests if the tranch is paused, it also mentions the fact that repayments are not possible if the OmniPool is paused and the potential consequences of it, such as accounts falling into liquidation and mev risks of accounts getting liquidated.
#2 - JeffCX
2023-11-07T01:55:09Z
this issue mentions
however, even when the market / tranche is paused
the user interest is still accuring
but repay is not a option because the repay function is guarded by whenNotPaused modifier
which is the main point of #32
the main point for #5
is MEV bot can frontrun user's repayment after user unpause, account can be unhealthy not only because of the interest charge, but also because of the market price
#3 - stalinMacias
2023-11-07T01:58:33Z
Well, in #32 I mentioned both problems, the fact that users can't repay and the possibility of mev races after the contract is unpaused.
so, based on your comment, both, #5 and #34 would be duplicates at a 50% of #32
#4 - JeffCX
2023-11-07T02:00:37Z
I see your report from #32
if the account felt into liquidation status, now the users and liquidators will be in a mev run to either repay the debt or liquidate the collateral.
Agree both #5 and #34 and #32 can be group together
#5 - c4-judge
2023-11-07T07:48:37Z
thereksfour marked the issue as duplicate of #32
#6 - c4-judge
2023-11-07T08:33:44Z
thereksfour marked the issue as satisfactory
🌟 Selected for report: ladboy233
Data not available
https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniPool.sol#L348 https://github.com/code-423n4/2023-11-betafinance/blob/0f1bb077afe8e8e03093c8f26dc0b7a2983c3e47/Omni_Protocol/src/OmniToken.sol#L180
paucheTranche state can be set to arbitrary value
the protocol has this concept of tranche id and borrower tier, the higher borrower tier means high risk
lower borrower tier means low risk
but when liquidation happens
if the borrowTrueValue is greater than depositTrueValue, which can happen because of the underlying oracle price can change
if (evalAfter.borrowTrueValue > evalAfter.depositTrueValue) { pauseTranche = borrowTier; emit PausedTranche(borrowTier); }
when the pauseTranche id is set to the borrower tier in this line of code
then the user that deposit below the paused tranche id should not be able to withdraw / tranche id share
because this check is in-place
require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::withdraw: Tranche paused.");
but the problem is that the paucheTranche state can be set to arbitrary value
suppose a user has borrower tier 10
and borrowTrueValue exceed depositTrueValue, the pauseTranche is set to 10
then a second user has borrower tier 0
he is subject to liquidation as well
and borrowTrueValue exceed depositTrueValue as well
the pauseTranche is reset to 0 again, which defeat the check of
require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::withdraw: Tranche paused.");
Manual Review
change the code to
if (evalAfter.borrowTrueValue > evalAfter.depositTrueValue) { pauseTranche = max(borrowTier, pauseTranche) emit PausedTranche(borrowTier); }
Invalid Validation
#0 - thereksfour
2023-11-07T05:08:21Z
Nice Find!
pauseTranche
is used to disallow the user from calling withdraw()
to withdraw assets before socializeLoss()
is called.
As for severity, I'm not so sure it's a High, need more thought on that.
#1 - c4-judge
2023-11-07T05:08:29Z
thereksfour marked the issue as satisfactory
#2 - c4-judge
2023-11-07T05:08:35Z
thereksfour marked the issue as primary issue
#3 - JeffCX
2023-11-07T11:04:52Z
As for severity, I'm not so sure it's a High, need more thought on that
I submit as high because this finding allow user to perform privilege escalation and act as admin to pause arbitrary tranche
user can select a tranche and deposit and borrow and liquidation themselves to set pauseTranche to any value
unless admin keep step into to unpause, all other user's withdraw transaction can revert because of this check
require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::withdraw: Tranche paused.");
#4 - thereksfour
2023-11-07T16:15:19Z
This requires not only that the user can be liquidated, but also that the bad debt arises (there is big gap between the two), so I think it would be very difficult for an attacker to actively exploit this issue to set pauseTranche.
And as TRST-M-1 Users can avoid losses by withdrawing assets before pausing due to bad debt
noted, users can withdraw preemptively to prevent socializeLoss() from causing losses.
So I'm not so sure it's a high, maybe a medium would be more appropriate, anyway, need thoughts from sponsors.
#5 - c4-judge
2023-11-08T16:44:11Z
thereksfour changed the severity to 2 (Med Risk)
#6 - thereksfour
2023-11-08T16:48:53Z
Users can't actively arise bad debt, medium likelihood. Users can withdraw their assets before socialize loss, medium impact. So consider it medium severity. Need sponsors' thoughts.
#7 - JeffCX
2023-11-08T22:02:09Z
Additional notes
I wonder if the recommendation
if (evalAfter.borrowTrueValue > evalAfter.depositTrueValue) { pauseTranche = min(borrowTier, pauseTranche) emit PausedTranche(borrowTier); }
is better
because set pause tranche to low risk tranche (even 0 tranche id) means withdraw asset is too risky for all tranche
0 pauseTranche means all tranche is paused
255 pauseTranche means all tranche is non-paused
#8 - allenjlee
2023-11-12T17:17:41Z
Agree with judge's assessment, medium severity. There should be no loss to users, and this situation arises in the case only where a higher tranche has bad debt after a lower tranche has bad debt. The worst case scenario, is that one depositor is left with all the bad debt, which is unintended by the protocol but does not cause loss of funds compared to existing protocols -- which rely on manual pause intervention, which the protocol also has as we inherit Pausable
, so this is medium severity.
We will fix to the latest recommendation, which is using the pauseTranche = borrowTier > pauseTranche ? pauseTranche : borrowTier;
#9 - c4-sponsor
2023-11-12T17:21:26Z
allenjlee (sponsor) confirmed
#10 - c4-judge
2023-11-13T05:17:26Z
thereksfour marked the issue as selected for report
🌟 Selected for report: ladboy233
Data not available
loss of precision is too high when accuring interest
When intereste accures, we are calling
uint256 interestAmount; { uint256 interestRate = IIRM(irm).getInterestRate(address(this), trancheIndex, totalDeposit, totalBorrow); interestAmount = (trancheBorrowAmount_ * interestRate * timePassed) / 365 days / IRM_SCALE; }
note that the loss of precision is too high:
interestAmount = (trancheBorrowAmount_ * interestRate * timePassed) / 365 days / IRM_SCALE;
we are dividing 365 days and then divde by IRM_SCALE (which is 1e9)
combing the fact taht the IRM_SCALE is hardcoded to 1e9
and if the time passed between two accuring is small,
and the underlying token have low decimals (6 decimals, even two decimals)
the interest accured will be always rounded to 0
In tests/MockERC20.sol,
the decimal is hardcoded to 18
we change the hardcode decimal to 6
and we add the POC below in TestOmniToken.t.sol
function test_Accrue_POC_Low_decimal() public { setUpBorrow(); (uint256 tda0, uint256 tba0,,) = oToken.tranches(0); (uint256 tda1, uint256 tba1,,) = oToken.tranches(1); (uint256 tda2, uint256 tba2,,) = oToken.tranches(2); uint256 td = tda0 + tda1 + tda2; uint256 borrowAmount = 10 * (10 ** uToken.decimals()); IOmniPool(pool).borrow(0, address(oToken), borrowAmount); uint256 time_elapse = 120 seconds; vm.warp(time_elapse); uint256 interestAmount; { uint256 interestRate = irm.getInterestRate(address(oToken), 0, td, borrowAmount); interestAmount = borrowAmount * interestRate * time_elapse / 365 days / oToken.IRM_SCALE(); } uint256 feeInterestAmount = interestAmount * oToken.RESERVE_FEE() / oToken.FEE_SCALE(); interestAmount -= feeInterestAmount; oToken.accrue(); vm.warp(time_elapse * 2); oToken.accrue(); vm.warp(time_elapse * 4); oToken.accrue(); }
we are accuring the interest every two minutes
before running the POC
we import the
import "forge-std/console.sol";
In OmniToken.sol
and add the console.log to log the interest
{ uint256 interestRate = IIRM(irm).getInterestRate(address(this), trancheIndex, totalDeposit, totalBorrow); // @audit // this can be trancated to 0 interestAmount = (trancheBorrowAmount_ * interestRate * timePassed) / 365 days / IRM_SCALE; console.log("interestAmount", interestAmount); }
then we run the POC
forge test -vv --match-test "test_Accrue_POC_Low_decimal"
the output is
Running 1 test for src/tests/TestOmniToken.t.sol:TestOmniToken [PASS] test_Accrue_POC_Low_decimal() (gas: 737833) Logs: interestAmount 0 interestAmount 0 interestAmount 0
this mean within every two minutes time elapse, the interest is round down to 0
and the accure function is called in every borrow / withdraw / transfer, so the interest will be rounded down heaviliy
or user can keep calling accure for every two minutes (or x minutes) to avoid paying interest
Manual Review
modify the interest, do not do muliplication before division to avoid precision loss
do not hardcode IRM_SCALE and consider token decimals when accuring interess
Decimal
#0 - c4-judge
2023-11-07T15:44:24Z
thereksfour marked the issue as primary issue
#1 - allenjlee
2023-11-10T10:05:43Z
We acknowledge this issue happens when values are small. I believe that the loss of precision causing 0 rounding is mainly from the 365 days value, which is equivalent to 31,536,000 seconds (a little over 7 decimals of precision loss). For the interest rate, let's say -- worst case -- the lowest interest rate is 0.1%, which would be 3 decimals of precision loss as well. Therefore, there is a divisor of 31,536,000,000 every second, where the borrow amount must exceed this in the worst case. For tokens with greater than 11 decimals, there should be no issues. For tokens with less than 11 decimals, there will be issues.
For USDC, this means there must be greater than 31,536 USDC per tranche being actively borrowed otherwise the interest rounds to 0 (assuming accrue()
is called every second). If we relax this assumption, and say accrue()
is called once every 10 seconds, the requirement becomes 3,153.60 USDC per tranche. Assuming it's below the 31,536 USDC threshold, this would mean ~31.5 USDC is lost in interest every year.
Similarly, for USDT (w/ 8 decimals) it will require greater than 315.36 (1 second) and 3.1536 (10 seconds) USDT per tranche being actively borrowed, otherwise interest rounds to 0.
For WBTC, this would also mean that we would need a 1% base interest rate for it to be 0.31536 (10 second block time) to compensate for no interest. The loss on this (assuming BTC price is $100K) would also be $300 in interest lost. Practically the value is small.
We think refactoring the code to address this issue would be quite challenging and introduce potential problems/edge cases and the changes outweigh the problems solved, so we will instead make it aware in our documentation that there is this limitation. We will also be mindful of setting the configurations for the asset IRMs. Thank you for bringing up this issue, we agree with the severity.
#2 - c4-sponsor
2023-11-10T10:06:01Z
allenjlee (sponsor) acknowledged
#3 - c4-judge
2023-11-13T05:09:00Z
thereksfour marked the issue as satisfactory
#4 - c4-judge
2023-11-13T05:09:06Z
thereksfour marked the issue as selected for report
Data not available
Issue | Description |
---|---|
Admin validation | should validate if duplicate market exists when admin set mode |
Account Count Limitation | In OmniTokenNoBorrow.sol and OmniToken.sol , only the first 25 accounts are counted toward the user balance. |
Airdrop Token Handling | OmniTokenNoBorrow.sol and OmniToken.sol are not equipped to handle airdrop tokens effectively. |
Borrow Cap Validation | OmniPool.sol 's enterMarkets does not check if the borrow cap is reached before entering the market. |
OpenZeppelin Version | The protocol uses OpenZeppelin version 4.8.0; it is recommended to update to a more recent version (4.9+). |
Hardcoded WETH Address | The WETH address is hardcoded for ETH mainnet, which could cause issues in a multichain environment. |
Oracle Validation | Additional validation is needed for the oracle when Provider.Other is used to ensure price reliability. |
Token Oracle Validation by Symbol | The protocol should not validate the token oracle by symbol due to the potential for symbol changes. |
Token Decimals Caching | Token decimals should be cached in the oracle to avoid fetching them every time, which is a costly operation. |
Price Feed Deprecation Handling | Operations such as liquidation, borrowing, and withdrawal could be blocked if the oracle price becomes stale or the price feed is deprecated. |
when admin set the mode
function setModeConfiguration(ModeConfiguration memory _modeConfiguration) external onlyRole(MARKET_CONFIGURATOR_ROLE) { if (_modeConfiguration.expirationTimestamp <= block.timestamp) { revert("OmniPool::setModeConfiguration: Bad expiration timestamp."); } modeCount++; modeConfigurations[modeCount] = _modeConfiguration; emit SetModeConfiguration(modeCount, _modeConfiguration); }
the code should validate if duplicate markets exists in the mode configuration to avoid double evaluting and counting user collateral
balanceOf in OmniToken only iteravte over first 25 account to count the user balance
but user can create sub account in uint96 space
then when external api replies on the balanceOf to query the user balance, the returned result undercounts under balance if the user's sub account number exceed 25
if the protocol has the need to expose the user balance
the protocol should update the user balance when user deposit the protocol should reduce the user balance when user withdraw or transfer
and then return the user balance in O(1) time instead of in O(n) time when running the for loop
OmniTokenNoBorrow.sol and OmniToken.sol is likely to hold a large amount of asset
in case when there is a project issue airdrop based on the token balance,
the token pool is not capable of claim airdrop and cannot transfer the airdrop out from the token pool
then the airdrop is lost
when enterMarket, the protocol only validate when the borrow cap is not 0
require(IOmniToken(market).getBorrowCap(0) > 0, "OmniPool::enterMarkets: Market has no borrow cap for 0 tranche.");
but does not validate the BorrowCap of the market is reachced
in case when BorrowCap of the market is reachced, user cannot really borrow but still paying the gas to call accure every time for that added market
the protocol openzepplein used is 4.8.0, but the newly version is 4.9+
recommend using a more recent version of openzeppelin to avoid bugs
the protocol wants to deploy the contract in both eth mainnet and BSC
but the WETH address is hardcoded to ETH address in mainnet
address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // Need to hardcode WETH address per network deployed for Band
as the comment suggest,
the protocol needs to hardcode WETH address per network deployed for Band
in this line of code
} else if (config.provider == Provider.Other) { return IOmniOracle(config.oracleAddress).getPrice(_underlying) * (PRICE_SCALE / 1e18) / (10 ** IERC20Metadata(_underlying).decimals()); }
in case when token oracle is not provided by band or chainlink, Provider.Other is used
if the provider.OTHER is used, the protocol should validate if the price is stale or return 0 as well instead of use the oracle price directly
because the oracle price deposit collateral value and incorrect value can result in loss of fund (false liquidation / overborrowing)
When band oracle is used, if the token is not WETH, the type of the oracle query is constructed based on the token symbol
data = IStdReference(config.oracleAddress).getReferenceData(IERC20Metadata(_underlying).symbol(), USD);
however, the underlying address returned symbol can change, so it is recommend to cache the token symbol and validate by token address instead of using the returned token symbol to construct query data
when oracle is used, every time the token decimal is fetched when return the oracle price
return uint256(answer) * (PRICE_SCALE / (10 ** IChainlinkAggregator(config.oracleAddress).decimals())) / (10 ** IERC20Metadata(_underlying).decimals());
but the underlying token returned decimal can change
so it is recommend the cache the decimal for token
chainlink reguarly deprecate the feed
https://docs.chain.link/data-feeds/deprecating-feeds?network=deprecated&page=1
in case when the feed is deprecated,
calling IChainlinkAggregator(config.oracleAddress).latestRoundData() wilil result in revert
or when the price is stale, both chainlink oracle or band oracle revert
when it is correct to not use the incorrect or misreported price
the liquidation flow / borrow / withdraw flow that replies on the oracle price is blocked and revert until the feed is updated by admin or the price not become stale
#0 - c4-judge
2023-11-07T17:19:03Z
thereksfour marked the issue as grade-a
#1 - allenjlee
2023-11-11T09:53:33Z
Good QA report, helpful findings here and optimizations.
Restructured oracle to store a variable for the token string symbol, as well as added decimals to the oracle config to reduce storage loads. Also modified oracle contract to take in the weth address as a variable in the constructor to be set as an immutable variable.
I think regarding airdrops, we will try to work directly with the airdropping team instead to ensure tokens are properly distributed. Don't think this should be in the scope of the protocol.
The purpose of balanceOf() is just for external sources, e.g. Metamask/Etherscan, to pick up balances on the protocol in their UI. No one should be calling this function on-chain, and it's also not called anywhere in any other contract.
Overall, great QA report, thank you
#2 - c4-sponsor
2023-11-11T09:54:10Z
allenjlee (sponsor) confirmed
#3 - c4-judge
2023-11-15T04:51:42Z
thereksfour marked the issue as selected for report
#4 - c4-judge
2023-11-15T05:10:50Z
thereksfour marked the issue as not selected for report