Platform: Code4rena
Start Date: 15/12/2023
Pot Size: $38,500 USDC
Total HM: 15
Participants: 5
Period: 6 days
Judge: hansfriese
Total Solo HM: 8
Id: 313
League: ETH
Rank: 4/5
Findings: 5
Award: $0.00
🌟 Selected for report: 4
🚀 Solo Findings: 3
🌟 Selected for report: 0x73696d616f
Data not available
https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/PosManager.sol#L175
Users can avoid being liquidated if they frontrun liquidation calls with a liquidate call with 1 wei. Or, they may do a partial liquidation and avoid being liquidated before the interest reaches the value of the debt pre liquidation. The total interest stored in __posBorrInfos[_posId].borrExtraInfos[_pool].totalInterest
would also be wrong.
The POS_MANAGER
stores the total interest in __posBorrInfos[_posId].borrExtraInfos[_pool].totalInterest
. Function updatePosDebtShares()
assumes that ILendingPool(_pool).debtShareToAmtCurrent(currDebtShares)
is always increasing, but this is not the case, as a liquidation may happen that reduces the current debt amount. This leads to calls to updatePosDebtShares()
reverting.
The most relevant is when liquidating, such that users could liquidate themselves for small amounts (1) and prevent liqudiations in the same block. This is because the debt accrual happens over time, so if the block.timestamp is the same, no debt accrual will happen. Thus, if a liquidate call with 1 amount frontruns a liquidate call with any amount, the second call will revert.
A user could still stop liquidations for as long as the accrued interest doesn't reach the last debt value before liquidation, if the user liquidated a bigger part of the debt.
Add the following test to TestInitCore.sol
:
function test_POC_Liquidate_reverts_frontrunning_PosManager_WrongAssumption() public { address poolUSDT = address(lendingPools[USDT]); address poolWBTC = address(lendingPools[WBTC]); _setTargetHealthAfterLiquidation_e18(1, type(uint64).max); // by pass max health after liquidate capped _setFixedRateIRM(poolWBTC, 0.1e18); // 10% per sec uint collAmt; uint borrAmt; { uint collUSD = 100_000; uint borrUSDMax = 80_000; collAmt = _priceToTokenAmt(USDT, collUSD); borrAmt = _priceToTokenAmt(WBTC, borrUSDMax); } address liquidator = BOB; deal(USDT, ALICE, collAmt); deal(WBTC, liquidator, borrAmt * 2); // provides liquidity for borrow _fundPool(poolWBTC, borrAmt); // create position and collateralize uint posId = _createPos(ALICE, ALICE, 1); _collateralizePosition(ALICE, posId, poolUSDT, collAmt, bytes('')); // borrow _borrow(ALICE, posId, poolWBTC, borrAmt, bytes('')); // fast forward time and accrue interest vm.warp(block.timestamp + 1 seconds); ILendingPool(poolWBTC).accrueInterest(); uint debtShares = positionManager.getPosDebtShares(posId, poolWBTC); _liquidate(liquidator, posId, 1, poolWBTC, poolUSDT, false, bytes('')); // liquidate all debtShares _liquidate(liquidator, posId, 1000, poolWBTC, poolUSDT, false, bytes('panic')); }
Vscode, Foundry
Update the user's last debt position __posBorrInfos[_posId].borrExtraInfos[_pool].totalInterest
on _repay()
.
Under/Overflow
#0 - c4-judge
2023-12-22T02:30:23Z
hansfriese marked the issue as primary issue
#1 - c4-sponsor
2023-12-30T01:58:57Z
fez-init (sponsor) confirmed
#2 - hansfriese
2023-12-30T02:41:43Z
After discussing internally with the sponsor/warden, we've confirmed the issue. Here is a part of the discussion.
when it frontruns the liquidation with 1 share, it removes 1 share and 2 debt when it calculates the amount again in the following liquidation, the shares will be worth 1 less and it reverts
As a mitigation, we can update extraInfo.totalInterest
only when debtAmtCurrent > extraInfo.lastDebtAmt.
#3 - hansfriese
2023-12-30T04:16:25Z
#4 - hansfriese
2023-12-30T04:17:31Z
High is appropriate as the main invariant might be broken temporarily while repaying.
#5 - c4-judge
2023-12-30T04:17:45Z
hansfriese marked the issue as satisfactory
#6 - c4-judge
2023-12-30T04:17:54Z
hansfriese marked the issue as selected for report
🌟 Selected for report: 0x73696d616f
Data not available
https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L151 https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L282 https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L317
repay()
, liquidate()
and liquidateWLp()
transactions revert if users approve the exact repay amount they need in the frontend and only after some blocks have passed is the transaction settled. This happens because the interest accrual is by timestamp, so the debt would have increased since the approval, when the transaction settles.
A test when repaying debt was carried out in TestInitCore.sol
. The timestamp increased just 1 second, but it was enough to make the transaction revert. It may be possible to request a bigger alowance than expected, but this has other implications.
function test_POC_TransferFromFails_DueToDebtAccrual() public { uint256 _wbtcAmount = 3e8; uint256 _borrowAmount = 1e8; address _user = makeAddr("user"); deal(WBTC, _user, _wbtcAmount); uint256 _posId = _createPos(_user, _user, 2); uint256 shares_ = _mintPool(_user, address(lendingPools[WBTC]), _wbtcAmount, ""); vm.startPrank(_user); lendingPools[WBTC].transfer(address(positionManager), shares_); initCore.collateralize(_posId, address(lendingPools[WBTC])); vm.stopPrank(); uint256 _debtShares = _borrow(_user, _posId, address(lendingPools[WBTC]), _borrowAmount, ""); uint256 _userDebtBalance = lendingPools[WBTC].debtShareToAmtCurrent(_debtShares); vm.prank(_user); IERC20(WBTC).approve(address(initCore), _userDebtBalance); skip(1); vm.prank(_user); vm.expectRevert("ERC20: transfer amount exceeds balance"); initCore.repay(address(lendingPools[WBTC]), _debtShares, _posId); }
Vscode, Foundry
Receive the amount in InitCore as argument instead of the shares on the repay()
, liquidate()
and liquidateWLp()
functions.
Under/Overflow
#0 - c4-judge
2023-12-22T04:51:45Z
hansfriese marked the issue as primary issue
#1 - c4-sponsor
2023-12-28T23:19:47Z
fez-init (sponsor) acknowledged
#2 - fez-init
2023-12-28T23:22:37Z
The issue should be mitigated with the introduction of hooks, where such additional logic of amount to share conversion can be implemented.
#3 - c4-judge
2023-12-29T05:59:51Z
hansfriese marked the issue as satisfactory
#4 - c4-judge
2023-12-29T06:00:01Z
hansfriese marked the issue as selected for report
🌟 Selected for report: 0x73696d616f
Data not available
The impact of this finding is more on the marketing/data fetching side, on exchanges it would appear that the shares are worth less VIRTUAL_SHARES than the underlying token. Given that it would influence the perception of the value of the shares token, medium severity seems appropriate.
The Openzeppelin implementation includes the decimals offset (log10(VIRTUAL_SHARES
) in LendingPool) in the decimals()
function. However, INIT only places the decimals of the underlying.
A POC was built, add it to TestLendingPool.sol
:
function test_POC_WrongDecimals() public { uint256 _wbtcAmount = 3e8; // 3 BTC address _user = makeAddr("user"); _mintPool(_user, WBTC, _wbtcAmount); uint256 _wbtcDecimals = 1e8; uint256 VIRTUAL_SHARES = 1e8; uint256 _poolDecimals = 10**lendingPools[WBTC].decimals(); uint256 _userBalance = lendingPools[WBTC].balanceOf(_user); assertEq(_userBalance/_poolDecimals, _wbtcAmount/_wbtcDecimals*VIRTUAL_SHARES); assertEq(_userBalance/_poolDecimals, 3e8); assertEq(_userBalance, _wbtcAmount*VIRTUAL_SHARES); }
Vscode, Foundry
Include the virtual shares decimals in the decimals()
function:
uint private constant VIRTUAL_SHARES = 8; ... function decimals() public view override returns (uint8) { return IERC20Metadata(underlyingToken).decimals() + VIRTUAL_SHARES; } ... function _toShares(uint _amt, uint _totalAssets, uint _totalShares) internal pure returns (uint shares) { return _amt.mulDiv(_totalShares + 10**VIRTUAL_SHARES, _totalAssets + VIRTUAL_ASSETS); } ... function _toAmt(uint _shares, uint _totalAssets, uint _totalShares) internal pure returns (uint amt) { return _shares.mulDiv(_totalAssets + VIRTUAL_ASSETS, _totalShares + 10**VIRTUAL_SHARES); }
ERC20
#0 - c4-judge
2023-12-22T04:58:49Z
hansfriese marked the issue as primary issue
#1 - c4-sponsor
2023-12-27T14:06:58Z
fez-init (sponsor) confirmed
#2 - c4-judge
2023-12-29T06:26:08Z
hansfriese marked the issue as satisfactory
#3 - c4-judge
2023-12-29T06:26:13Z
hansfriese marked the issue as selected for report
🌟 Selected for report: rvierdiiev
Also found by: 0x73696d616f, ladboy233
Data not available
https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L327
Users who collateralized using wLP
won't ever be liquidated unless the wLP
is whitelisted back, but this could be dangerous depending on the reason it was removed from the whitelist. They can't also decollateralize. The severity of this issue depends entirely on the reason of the removal.
Add the following code before this line:
address[] memory wlps = new address[](1); wlps[0] = address(mockWLpUniV2); config.setWhitelistedWLps(wlps, false);
The test will fail due to the fact that the wLP
is no longer whitelisted, due to the check here.
Vscode, Foundry
2 different allowlists should be set, one for collateralizing and another for decollateralizing/liquidating. This way, a WLP can be removed from collateralizing only, being it still possible to decollateralize/liquidate. In case the wLP is compromised or similar, it can be removed from both whitelists.
Access Control
#0 - c4-judge
2023-12-22T05:02:51Z
hansfriese marked the issue as duplicate of #13
#1 - c4-judge
2023-12-29T06:59:15Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: 0x73696d616f
Data not available
Oracle unavailable for up to 1 hour, which could lead to positions going underwater and being liquidated when it becomes live again, without users having the chance to repay/collateralize.
The airnode code has a note concerning this behaviour:
/// @dev Reverts if the timestamp is from more than 1 hour in the future
It means that the prices can be up to 1 hour in the future.
This modifier is triggered when updating prices in processBeaconUpdate()
, setting the dataFeed
, which is read in _readDataFeedWithId()
, called initially in readDataFeedWithId()
.
Then, in the Api3OracleReader
, the following check may underflow if the timestamp is in the future:
_require(block.timestamp - timestamp <= dataFeedInfo.maxStaleTime, Errors.MAX_STALETIME_EXCEEDED);
.
Vscode Foundry
Limit the timestamp to be at most block.timestamp
.
Under/Overflow
#0 - JeffCX
2023-12-22T04:12:12Z
duplicate of #4
#1 - c4-judge
2023-12-22T04:48:13Z
hansfriese marked the issue as duplicate of #4
#2 - c4-judge
2023-12-28T02:18:25Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: 0x73696d616f
Also found by: ladboy233, sashik_eth
Data not available
execute()
could check leftover balances in all interacted tokens.Users could make mistakes and tokens beside ETH
could be left in the contract. The refund mechanism could be extended to other tokens as well.
setReserveFactor()_e18
does not check the reserveFactor, which if bigger than 1e18
, will make accrueInterest()
always underflow, DoSing the lending pool functions that depend on the accrue()
modifier. It should ideally be capped at a lower value, but this is more of a centralization risk.
collTokens
from Config.sol
, which could be dangerous in the long run as some token could go rogue (or an upgrade).setBorrFactors_e18()
could check for duplicate _pools
as an additional check to make sure that no incorrect factors are set. If 2 duplicates are sent, only the latter will take effect, which could have very dangerous implications.InitCore:Multicall()
and [InitCore:callback()
](https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L505) could be refunded.[_liquidateInternal()
](https://github.com/code-423n4/2023-12-initcapital/blob/main/contracts/core/InitCore.sol#L592) should revert if the mode is 0.msg.value
in a loop reverts or steals balance from the contract (although it is not supposed to hold funds). It's still dangerous anyway.#0 - c4-sponsor
2023-12-27T13:59:23Z
fez-init (sponsor) acknowledged
#1 - c4-judge
2023-12-30T04:25:30Z
hansfriese marked the issue as grade-a
#2 - hansfriese
2023-12-30T04:30:09Z
[L - 01] execute() could check leftover balances in all interacted tokens. NC
[L - 02] reserveFactor in LendingPool should be capped at 1e18 L
[L - 03] missing a way to remove collTokens from Config.sol, which could be dangerous in the long run as some token could go rogue (or an upgrade). NC
[L - 04] setBorrFactors_e18() could check for duplicate _pools as an additional check to make sure that no incorrect factors are set. If 2 duplicates are sent, only the latter will take effect, which could have very dangerous implications. L
[L - 05] excess ETH in InitCore:Multicall() and InitCore:callback() could be refunded. L
[L - 06] _liquidateInternal() should revert if the mode is 0. Invalid
[L - 07] msg.value in a loop reverts or steals balance from the contract (although it is not supposed to hold funds). It's still dangerous anyway. Invalid
Plus 3 downgraded QAs
#3 - c4-judge
2023-12-30T04:30:17Z
hansfriese marked the issue as selected for report