#36450 [SC-Low] contract timebasedcollateralpool will be unable to process new user transactions
#36450 [SC-Low] Contract TimeBasedCollateralPool will be unable to process new user transactions and user funds are temporary frozen if a user unstake transaction of TimeBasedCollateralPool execute...
Submitted on Nov 3rd 2024 at 04:16:31 UTC by @perseverance for Audit Comp | Anvil
Temporary freezing of funds within the TimeBasedCollateralPool for at least 48 hours
Description
Description
Brief/Intro
The users can unstake from the TimeBasedCollateralPool by using the function unstake
```solidity function unstake(IERC20 _token, uint256 _poolUnits) external {} ```
The vulnerability
Vulnerability Details
The impact described in this bug report can happen in the Scenario:
If a victim user want to unstake his tokens from the pool, then he call ustake(). An user with RESETTER_ROLE reset the pool (may be via Governance Proposal Execution) and this transaction got executed before the victim's transaction. The victim's unstake transaction got executed after reset pool.
But the scenario can happen because of the race condition in Ethereum and the reset pool transaction can get executed before the user's unstake transaction. Because of decentralized and mempool, There is possibility that this scneario can happen.
Step 1: So the pool is reset.
Step 2: Now the victim's unstake transaction got executed
```solidity function unstake(IERC20 _token, uint256 _poolUnits) external { if (_poolUnits == 0) revert UnstakeAmountZero();
{
// Release eligible account tokens. If a reset occurred, there is nothing left to unstake, so return.
715: bool accountWasReset = _releaseEligibleAccountTokens(msg.sender, tokenAddress); if (accountWasReset) { // Do not revert because the user wanted tokens unstaked, and those were unstaked and released. return; } }
So after the reset pool event, when unstake(), at Line 701-704, the accountTokenState[msg.sender][tokenAddress].totalUnits is still the totalUnits of the user before the reset pool event.
But in Line 715 the internal function _releaseEligibleAccountTokens is called. In this internal function, if the pool was reset, then the tokens and units of the user are released.
After the the tokens and units of the user are released, the user's totalUnits is reset to 0.
Please note that _releaseEligibleAccountTokens called inside unstake() still return false means that the pool was not reset because in this scenario, the reset happened before the unstake transaction.
Note: If the resetPool happened inside the _releaseEligibleAccountTokens function, then _releaseEligibleAccountTokens will return true.
But in the function unstake, the accountWasReset is false, so it still process unstake by calling
So now for the victim the state of accountTokenState is incorrect because
```solidity accountTokenState.totalUnits = 0 accountTokenState.firstPendingUnstakeUnits != 0 ``` The pool state is also incorrect
``` contractStateStorage.totalUnits = 0;
contractStateStorage.firstPendingUnstakeUnits != 0 and = unstake Units amount.
the ReserveCollateral of TimeBasedCollateralPool was also reset to 0
```
So the victim and other user future transactions after the willCompleteAtTimestampSeconds time, will call the modifier withEligibleAccountTokensReleased
```solidity function _unlockEligibleTokenContractPendingUnstakes(address _tokenAddress) internal returns (bool) {
// ...
if ((firstVestedUnits + secondVestedUnits) == totalPoolUnits) {
collateral.releaseAllCollateral(contractStateStorage.collateralReservationId);
contractStateStorage.collateralReservationId = 0;
} else {
uint256 tokensToRelease = firstVestedTokens + secondVestedTokens;
if (tokensToRelease > 0) {
try
collateral.modifyCollateralReservation(
contractStateStorage.collateralReservationId,
-Pricing.safeCastToInt256(tokensToRelease)
)
{} catch (bytes memory reason) {
if (bytes4(reason) == ICollateral.ClaimableAmountZero.selector) {
// If we're here, it means that the result of lowering the collateral amount is a reservation with
// 0 claimable balance. The only way to get this collateral out is to release less or more. Less
// invalidates the unstaking logic, so we choose to release more, resetting the dust that remains.
_resetPool(_tokenAddress);
return true;
} else {
assembly {
revert(add(reason, 0x20), mload(reason))
}
}
}
}
}
// ...
}
```
Since the collateral.modifyCollateralReservation will revert in Line 748 in function _modifyCollateralReservation with the check because the oldReservedAmount was reset and the transaction want to release unstake amount.
if (byAmountUint >= oldReservedAmount) revert InsufficientCollateral(byAmountUint, oldReservedAmount); // @audit-issue
Note: In the POC, the victim or some other user stake small amount between the unstake transaction timestamp and the willCompleteAtTimestampSeconds, so the oldReservedAmount is not 0. But if no transactions happen during this time frame, then the oldReservedAmount is 0.
``` So all the transactions will revert in collateral.modifyCollateralReservation with the reason InsufficientCollateral
Since all user transactions will call the modifier withEligibleAccountTokensReleased, so all user transactions will revert.
Impacts
About the severity assessment
If the scenario in this bugs occur, then all of the victim and user future transactions after willCompleteAtTimestampSeconds of unstake transaction always revert. This will cause denial of service of the protocol and temporary frozen of funds in TimeBasedCollateralPool contract.
This will result that in the owner or the protocol team need to manualy interveen to rescue the funds and fix the situation, maybe to propose a Governance Proposal and execute it.
Bug Severity: Low
Impact Category: Temporary freezing of funds within the TimeBasedCollateralPool for at least 48 hours
Probability: Medium, likely
Because the protocol team will need to investigate the root cause and pass a proposal to fix this situation that might take 3-4 days so the freezing of funds can be longer than 48 hours.
``` [PASS] testUnStake() (gas: 508557) Logs: Setup Precondition for the test case Deploying TimeBasedCollateralPool Proxy contract that is VisibleBeaconProxy points to TimeBasedCollateralPool_beacon Initialize TimeBasedCollateralPool contract Approve the TimeBasedCollateralPool_contract as the collateralizable contract Balance of USDC before depositing: 10000000000 Approve the CollateralVault_contract to spend USDC to deposit USDC Step: Deposit 10000 USDC to CollateralVault for the user Balance of USDC before depositing: 10000000000 Approve the CollateralVault_contract to spend USDC to deposit USDC Step: Deposit 10000 USDC to CollateralVault for the user Victim stake to TimeBasedCollateralPool. Stake amount: 100000000 user2 stake to TimeBasedCollateralPool, Stake amount: 100000000 Get getAccountTokenState accountTokenState: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7 100000000 0 Victim can unstake amount: 100000000 Scenario: 1 Step: reset pool Pool was reset accountTokenState before unstake victim: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7 accountTokenState before unstake accountTokenState.totalUnits: 100000000 accountTokenState before unstake accountTokenState.firstPendingUnstakeUnits: 0 Step : Victim Unstake from TimeBasedCollateralPool Victim stake to TimeBasedCollateralPool, stake amount: 50000000 Victim stake to TimeBasedCollateralPool Victim transaction expected to revert user2 stake to TimeBasedCollateralPool user2 transaction expected to revert
```
Explanation:
Before the ResetPool transaction, the victim state
``` Get getAccountTokenState accountTokenState: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7 100000000 0 Victim can unstake amount: 100000000 ```
When user unstake transaction, can see from the log that his tokens was released and still the event UnstakeInitiated was emitted