#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

  • Report ID: #36450

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://etherscan.io/address/0xd042C267758eDDf34B481E1F539d637e41db3e5a

  • Impacts:

    • 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();

701: if ( 702: accountTokenState[msg.sender][tokenAddress].totalUnits - 703: getTotalAccountUnitsPendingUnstake(msg.sender, tokenAddress) < 704: _poolUnits ) { revert InsufficientBalance( _poolUnits, accountTokenState[msg.sender][tokenAddress].totalUnits - getTotalAccountUnitsPendingUnstake(msg.sender, tokenAddress) ); }

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.

```solidity function _releaseEligibleAccountTokens( address _account, address _tokenAddress ) internal returns (bool _poolWasReset) { _poolWasReset = _unlockEligibleTokenContractPendingUnstakes(_tokenAddress);

```

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

```solidity _addToAccountPendingUnstakeNextEpoch(tokenAddress, msg.sender, _poolUnits); _addToContractPendingUnstakeNextEpoch(tokenAddress, _poolUnits);

```

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 modifier withEligibleAccountTokensReleased(address _account, address _tokenAddress) { _releaseEligibleAccountTokens(_account, _tokenAddress);

```

And this modifier will call function _unlockEligibleTokenContractPendingUnstakes

```solidity function _releaseEligibleAccountTokens( address _account, address _tokenAddress ) internal returns (bool _poolWasReset) { _poolWasReset = _unlockEligibleTokenContractPendingUnstakes(_tokenAddress); //... } ```

```solidity function _unlockEligibleTokenContractPendingUnstakes(address _tokenAddress) internal returns (bool) {

```

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.

```solidity function _modifyCollateralReservation( uint96 _reservationId, int256 _byAmount ) internal returns (uint256 _reservedCollateral, uint256 _claimableCollateral) { // Redacted

Line 748: if (byAmountUint >= oldReservedAmount) revert InsufficientCollateral(byAmountUint, oldReservedAmount); // @audit-issue

``` 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.

https://gist.github.com/Perseverancesuccess2021/f7643506a8ed7cc80ec111ce111151da#file-testtimebasedcollateralpool-sol

Proof of Concept

Proof of concept

Steps to reproduce the bug

Step 1: The user unstake his tokens

Step 2: the user with RESETTER ROLE reset the pool for some reasons and because of race condition, this transaction got executed before step 1

Test code to show: ```solidity function testUnStake() public {

```

Test Log:

https://gist.github.com/Perseverancesuccess2021/f7643506a8ed7cc80ec111ce111151da#file-testunstake_241103_0850-log

``` [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

``` ├─ [92608] VisibleBeaconProxy::unstake(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 100000000 [1e8]) │ ├─ [307] 0x1f00D6f7C18a8edf4f8Bb4Ead8a898aBDd9c9E14::implementation() [staticcall] │ │ └─ ← [Return] 0xd042C267758eDDf34B481E1F539d637e41db3e5a │ ├─ [91594] 0xd042C267758eDDf34B481E1F539d637e41db3e5a::unstake(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 100000000 [1e8]) [delegatecall] │ │ ├─ emit AccountResetNonceUpdated(account: victim: [0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7], oldNonce: 0, newNonce: 1) │ │ ├─ [8527] 0x5d2725fdE4d7Aa3388DA4519ac0449Cc031d675f::transferCollateral(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 100000000 [1e8], victim: [0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7]) │ │ │ ├─ emit CollateralTransferred(fromAccount: VisibleBeaconProxy: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], tokenAddress: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, toAccount: victim: [0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7], tokenAmount: 100000000 [1e8]) │ │ │ └─ ← [Stop] │ │ ├─ emit CollateralReleased(token: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, tokenAmount: 100000000 [1e8], poolUnits: 100000000 [1e8], destinationAccount: victim: [0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7]) │ │ ├─ emit UnstakeInitiated(account: victim: [0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7], token: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, unitsToUnstake: 100000000 [1e8], willCompleteAtTimestampSeconds: 1730605188 [1.73e9]) │ │ └─ ← [Stop] │ └─ ← [Return] ├─ [0] VM::stopPrank()

```

Then after the time willCompleteAtTimestampSeconds then all user transactions will revert

```solidity

```

Note that:

In the POC, I have created the code with more contract log to make sure my bug is valid. To turn on the log, just modify the code and re-run the test

```solidity uint256 contract_log = 1; ``` Log: https://gist.github.com/Perseverancesuccess2021/f7643506a8ed7cc80ec111ce111151da#file-testunstake_241103_0900-log

Full POC:

https://gist.github.com/Perseverancesuccess2021/f7643506a8ed7cc80ec111ce111151da#file-testtimebasedcollateralpool-sol

```solidity // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0;

import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../src/TimeBasedCollateralPool.sol"; import "../src/CollateralVault.sol"; import "../src/VisibleBeaconProxy.sol"; import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract testTimeBasedCollateralPool is Test {

}

```

Just download the zip file:

https://drive.google.com/file/d/1eaO4_BvlBjyFhWe22rwnWAL2JzOKhE9m/view?usp=sharing

The test code uses Foundry. Just Unzip and run the test case:

```bash forge test --match-test testUnStake -vvvvv > testUnStake_241103_0850.log

```

Last updated

Was this helpful?