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
Rank: 135/147
Findings: 1
Award: $4.52
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xmystery
Also found by: 0x11singh99, 0xAadi, 0xAlix2, 0xG0P1, 0xStalin, 0xWaitress, 0x_Scar, 0xhacksmithh, 0xhunter, 0xpiken, Al-Qa-qa, Arz, Avci, Bauchibred, BeliSesir, Breeje, Bughunter101, DarkTower, Eeyore, Fitro, HChang26, Imlazy0ne, J4X, JCK, Kaysoft, Kral01, Madalad, Mike_Bello90, Noro, PASCAL, PENGUN, Proxy, Rickard, Shubham, SovaSlava, Strausses, Team_Rocket, ThreeSigma, Topmark, Udsen, Walter, Yanchuan, Zach_166, ZanyBonzy, adam-idarrha, adeolu, almurhasan, arjun16, ast3ros, asui, ayden, btk, cartlex_, castle_chain, cccz, chainsnake, codynhat, critical-or-high, cryptonue, csanuragjain, deepkin, degensec, dirk_y, erebus, foxb868, ge6a, hunter_w3b, jasonxiale, kkkmmmsk, lanrebayode77, lsaudit, marchev, matrix_0wl, max10afternoon, nuthan2x, oakcobalt, oxchsyston, pavankv, peanuts, pep7siup, pipidu83, pontifex, ptsanev, qpzm, radev_sw, rokinot, rotcivegaf, rvierdiiev, sorrynotsorry, squeaky_cactus, supersizer0x, tnquanghuy0512, twcctop, twicek, young, zhaojie, ziyou-
4.5226 USDC - $4.52
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L82
Assume the following scenario:
30
days.cooldownAssets
function and waits for the cool-down time2
days, the cool-down time set by the owner is 5
days.cooldownAssets
function and then waits for the cool-down time28
days before calling unstake()
, while Bob only needs 5
days, which is unfair. Will cause Alice's funds to be locked for no reasonfunction 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; }
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; }
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:
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:
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