Ethena Labs - Bughunter101's results

Enabling The Internet Bond

General Information

Platform: Code4rena

Start Date: 24/10/2023

Pot Size: $36,500 USDC

Total HM: 4

Participants: 147

Period: 6 days

Judge: 0xDjango

Id: 299

League: ETH

Ethena Labs

Findings Distribution

Researcher Performance

Rank: 135/147

Findings: 1

Award: $4.52

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L82

Vulnerability details

Impact

Assume the following scenario:

  1. The cool-down time set by the owner at the beginning is 30 days.
  2. Then Alice calls the cooldownAssets function and waits for the cool-down time
  3. After 2 days, the cool-down time set by the owner is 5 days.
  4. Bob calls the cooldownAssets function and then waits for the cool-down time
  5. However, Alice still needs to wait 28 days before calling unstake(), while Bob only needs 5 days, which is unfair. Will cause Alice's funds to be locked for no reason

Proof of Concept

  function unstake(address receiver) external {
    UserCooldown storage userCooldown = cooldowns[msg.sender];
    uint256 assets = userCooldown.underlyingAmount;

    if (block.timestamp >= userCooldown.cooldownEnd) {
      userCooldown.cooldownEnd = 0;
      userCooldown.underlyingAmount = 0;

      silo.withdraw(receiver, assets);
    } else {
      revert InvalidCooldown();
    }
  }

  /// @notice redeem assets and starts a cooldown to claim the converted underlying asset
  /// @param assets assets to redeem
  /// @param owner address to redeem and start cooldown, owner must allowed caller to perform this action
  function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) {
    if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount();

    uint256 shares = previewWithdraw(assets);

    cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
    cooldowns[owner].underlyingAmount += assets;

    _withdraw(_msgSender(), address(silo), owner, assets, shares);

    return shares;
  }

  /// @notice redeem shares into assets and starts a cooldown to claim the converted underlying asset
  /// @param shares shares to redeem
  /// @param owner address to redeem and start cooldown, owner must allowed caller to perform this action
  function cooldownShares(uint256 shares, address owner) external ensureCooldownOn returns (uint256) {
    if (shares > maxRedeem(owner)) revert ExcessiveRedeemAmount();

    uint256 assets = previewRedeem(shares);

    cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
    cooldowns[owner].underlyingAmount += assets;

    _withdraw(_msgSender(), address(silo), owner, assets, shares);

    return assets;
  }

Tools Used

manual

I suggest changing the cooldownDuration to be checked in the unstake function, such as :

  function unstake(address receiver) external {
    UserCooldown storage userCooldown = cooldowns[msg.sender];
    uint256 assets = userCooldown.underlyingAmount;

    if (block.timestamp >= userCooldown.cooldownEnd + cooldownDuration) {//@audit - 1 check  `cooldownDuration` in here
      userCooldown.cooldownEnd = 0;
      userCooldown.underlyingAmount = 0;

      silo.withdraw(receiver, assets);
    } else {
      revert InvalidCooldown();
    }
  }

  /// @notice redeem assets and starts a cooldown to claim the converted underlying asset
  /// @param assets assets to redeem
  /// @param owner address to redeem and start cooldown, owner must allowed caller to perform this action
  function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) {
    if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount();

    uint256 shares = previewWithdraw(assets);

    cooldowns[owner].cooldownEnd = uint104(block.timestamp) ;//@audit - 2 just set block.timestamp
    cooldowns[owner].underlyingAmount += assets;

    _withdraw(_msgSender(), address(silo), owner, assets, shares);

    return shares;
  }

  /// @notice redeem shares into assets and starts a cooldown to claim the converted underlying asset
  /// @param shares shares to redeem
  /// @param owner address to redeem and start cooldown, owner must allowed caller to perform this action
  function cooldownShares(uint256 shares, address owner) external ensureCooldownOn returns (uint256) {
    if (shares > maxRedeem(owner)) revert ExcessiveRedeemAmount();

    uint256 assets = previewRedeem(shares);

    cooldowns[owner].cooldownEnd = uint104(block.timestamp) ;//@audit - 3 just set block.timestamp
    cooldowns[owner].underlyingAmount += assets;

    _withdraw(_msgSender(), address(silo), owner, assets, shares);

    return assets;
  }

Assessed type

Error

#0 - c4-pre-sort

2023-10-30T22:24:09Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-10-30T22:24:14Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2023-10-30T22:24:21Z

Good catch.

#3 - c4-pre-sort

2023-11-01T20:12:36Z

raymondfam marked the issue as high quality report

#4 - c4-sponsor

2023-11-09T17:38:50Z

FJ-Riveros (sponsor) acknowledged

#5 - c4-judge

2023-11-13T18:58:01Z

fatherGoose1 marked the issue as satisfactory

#6 - c4-judge

2023-11-14T17:19:19Z

fatherGoose1 marked the issue as selected for report

#7 - radeveth

2023-11-15T09:40:59Z

Hi, @fatherGoose1!

I think this issue should be marked as Low Severity issue (QA), because the given impact of this problem does not lead to financial losses (assets are not at risk).

#8 - flowercrimson

2023-11-15T16:24:18Z

Hey, @fatherGoose1, I think this issue should be Low Severity (QA) as well: (1) This is intended behavior based on code implementation: In the demonstrated attack scenario by warden, a) Bob can withdraw after 5 days, only because Bob called the cool-down function after admin set the cool-down to 5 days; b) Alice has to wait for 30 days, because Alice called the cool-down when the cool-down period is 30 days; Nothing is techinically wrong here and there is no exploit; (2) As the comment above said, there is no funds at risk, which also makes it a QA severity; In addition, the warden thinks this is unfair for Alice. I don't agree, because Alice agreed on a 30-day cool-down when she calls cooldownAssets. It's like a fixed-term bank deposit in which the term is 30-day.

#9 - adeolu98

2023-11-16T10:15:29Z

@flowercrimson @radeveth @fatherGoose1 the contract has coolDown states which work like switches. You can toggle it on or off. Toggle off by setting coolDown duration to 0, toggle on by setting to non-zero. In the event that it is toggled off, the expected behaviour is that all withdrawals from the silo contract can be removed immediately but that doesnt happen. Dev intent about this can be confirmed from the comments here so thats why i think it is a valid issue.

#10 - fatherGoose1

2023-11-17T02:44:14Z

I am changing this issue to QA. While the sponsor acknowledged and the functionality does not match the code comment (described in above comment), there is no actual impact here.

The warden lists the following scenario:

  • The cool-down time set by the owner at the beginning is 30 days.
  • Then Alice calls the cooldownAssets function and waits for the cool-down time
  • After 2 days, the cool-down time set by the owner is 5 days.
  • Bob calls the cooldownAssets function and then waits for the cool-down time
  • However, Alice still needs to wait 28 days before calling unstake(), while Bob only needs 5 days, which is unfair. Will cause Alice's funds to be locked for no reason

In the above scenario explained by the warden, Alice can simply call cooldownAssets() again to set the cooldown time to the shorter 5 days. The staker can navigate away from the "unfair scenario" by cooling down once again.

#11 - c4-judge

2023-11-17T02:45:09Z

fatherGoose1 changed the severity to QA (Quality Assurance)

#12 - Shubh0412

2023-11-17T05:57:58Z

@fatherGoose1 I believe this to be a Medium severity finding. As per your reply,

In the above scenario explained by the warden, Alice can simply call cooldownAssets() again to set the cooldown time to the shorter 5 days. The staker can navigate away from the "unfair scenario" by cooling down once again.

But this wouldn't be possible according in "Scenario 3" of the report #29 which is

Scenario 3:

  • Current cooldownDuration = 90 days
  • Zayn calls cooldownAssets to redeem some assets.
  • cooldownEnd for Zayn is set to 90 days counting from the present timestamp.
  • Admin changes cooldownDuration to 0 days.
  • Zayn immediately calls unstake but the function reverts because now he has to wait for 90 days until cooldownEnd is achieved.

Here Zayn won't be able to call cooldownAssets() because of ensureCooldownOn modifier which would cause a revert.

95:   function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) {
33:   modifier ensureCooldownOn() {
34:       if (cooldownDuration == 0) revert OperationNotAllowed();
35:       _;
36:   }

Zayn's funds would be stuck in the silo contract until the 90 days are over.

#13 - radeveth

2023-11-17T08:21:43Z

Hi @Shubh0412!

You say:

"Here Zayn won't be able to call cooldownAssets() because of ensureCooldownOn modifier which would cause a revert."


Why should Zayn call the cooldownAssets() function again (for the second time) after he has redeemed his assets and started a cooldown period to claim his converted underlying asset? After 90 days, Zayn will should just to call unstake() to claim his converted underlying asset.

As mentioned in the comment by @flowercrimson:

In addition, the warden thinks this is unfair to Alice. I don't agree because Alice agreed to a 30-day cooldown when she called cooldownAssets(). It's similar to a fixed-term bank deposit with a 30-day term.

The same principle applies here.


I have actually explained this issue in my analysis report and why it is invalid/nc.

#14 - Shubh0412

2023-11-17T11:03:09Z

Hello @radeveth I read the analysis report. The scenario you mentioned there is the same as my above comment.

When the cooldown duration is disabled (set to 0), the users who have already called cooldownAssets() or cooldownShares() and sent their assets/shares to the Silo contract would not be able to call cooldownAssets()/cooldownShares() because of the ensureCooldownOn modifier.

Since cooldownDuration == 0 now, calling cooldownAssets()/cooldownShares() would revert with the error OperationNotAllowed.

According to the analysis report

After all, I observed that users who have already called cooldownAssets()/cooldownShares() can call the unstake() function again to retrieve their converted underlying asset.

How calling the unstake() again will the users be able to retrieve their converted asset? Calling unstake() will always revert with InvalidCooldown because of if (block.timestamp >= userCooldown.cooldownEnd) check. If a user has deposited when cooldownDuration was 90 days, then unless the 90 days have passed since calling the function, the funds would be stuck in the Silo contract regardless of the others users who can withdraw instantly.

& for

It's like a fixed-term bank deposit in which the term is 30-day.

If that's the intention then also the user who has initially called cooldownAssets()/cooldownShares(), can call these functions again to change their deposit time period & break the FIXED-TERM BANK DEPOSIT.

& for

Why should Zayn call the cooldownAssets() function again (for the second time) after he has redeemed his assets and started a cooldown period to claim his converted underlying asset?

This was because of the comment left by the judge

Alice can simply call cooldownAssets() again to set the cooldown time to the shorter 5 days. The staker can navigate away from the "unfair scenario" by cooling down once again.

#15 - fatherGoose1

2023-11-17T16:46:44Z

@Shubh0412 is correct. The ensureCooldownOn modifier makes it so that stakers that previously cooled down while there was a non-zero cooldown time will have to wait unfairly for their cooldowns to end.

While other comments have explained that the stakers have agreed to the terms of the cooldown, it does not outweigh the unfairness of not being able to unstake immediately. The cooldown period may be set to 0 in the case of a known vulnerability, and the protocol will urge users to unstake their funds. If they are blocked from doing so because of this oversight, that will result in frozen funds, and at worst, loss of funds.

#16 - c4-judge

2023-11-17T16:47:10Z

This previously downgraded issue has been upgraded by fatherGoose1

#17 - fatherGoose1

2023-11-20T20:07:10Z

The primary issue with this implementation is regarding stakers who have called cooldown...() while a non-zero cooldownDuration was set, and then the cooldownDuration is set to 0, resulting a revert upon calling unstake(). While reading through this report and its duplicates, there is a clear division between reports that highlight this issue and those that don't.

I am in the process of de-duping these reports and assigning the proper severity levels. Reports that highlight this case will receive "Medium" while those that don't will be marked as QA.

Reports that do not highlight the reversion due to the ensureCooldownOn modifier have not accurately explained how the current implementation can cause significant impact.

#18 - c4-judge

2023-11-27T20:00:11Z

fatherGoose1 changed the severity to QA (Quality Assurance)

#19 - c4-judge

2023-11-27T20:00:20Z

fatherGoose1 marked the issue as grade-b

#20 - c4-judge

2023-11-27T22:00:54Z

fatherGoose1 marked the issue as not selected for report

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter