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: 4/147
Findings: 3
Award: $1,598.50
🌟 Selected for report: 0
🚀 Solo Findings: 0
161.7958 USDC - $161.80
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L148-L159
According to docs:
SOFT_RESTRICTED_STAKER_ROLE - Addresses under this category will be soft restricted. They cannot deposit USDe to get stUSDe or withdraw stUSDe for USDe. However they can participate in earning yield by buying and selling stUSDe on the open market. Address with this role can't stake his USDe tokens or get stUSDe tokens minted to him.
But this is not true because there can be a particular case where theses addresses will be able to mint & withdraw stUSDe.
redistributeLockedAmount()
burns the full restricted user amount and mints to the desired owner address.
The first check ensures that address to which an equable burned amount is minted isn't full restricted.
File: StakedUSDe.sol function redistributeLockedAmount(address from, address to) external onlyRole(DEFAULT_ADMIN_ROLE) { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { /// @note - no check for soft restricted address uint256 amountToDistribute = balanceOf(from); _burn(from, amountToDistribute); // to address of address(0) enables burning if (to != address(0)) _mint(to, amountToDistribute); /// @note - tokens minted for soft restricted address emit LockedAmountRedistributed(from, to, amountToDistribute); } else { revert OperationNotAllowed(); } }
However there is no check to ensure that the to
address isn't soft restricted. There is a check to see if the to
address isn't address(0) but thats it.
In a situation when redistributeLockedAmount()
is called with a to
address having SOFT_RESTRICTED_STAKER_ROLE
, the function enters the 1st if condition returning true for the check & later tokens are minted to that address.
Also as the per the docs SOFT_RESTRICTED_STAKER_ROLE cannot deposit USDe to get stUSDe or withdraw stUSDe for USDe.
Thats why there is no check in _withdraw()
for the SOFT_RESTRICTED_STAKER_ROLE
because since they cannot deposit, there won't be anything to withdraw.
But thats not the case here. The amount minted to a SOFT_RESTRICTED_STAKER_ROLE address can be withdrawn as there is no check here. The check is only for FULL_RESTRICTED_STAKER_ROLE address.
File: StakedUSDe.sol function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } /// @note - no check for SOFT_RESTRICTED_STAKER_ROLE super._withdraw(caller, receiver, _owner, assets, shares); _checkMinShares(); }
Thus a soft restricted address gets tokens minted for them & they can withdraw easily without any restriction.
Address having SOFT_RESTRICTED_STAKER_ROLE can get stUSDe minted, earn yield & withdraw the amount thereby breaking the protocol's guidelines.
Labelling this as a HIGH
because since Ethena Labs is not allowed to provide yield to addresses of some countries (thats why marked soft restricted), the situation described in this finding will put the organization involved in illegal activities.
Manual Review
Add a check to ensure that the to
address in redistributeLockedAmount()
isn't soft restricted.
- 232 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { + 232 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to) && !hasRole(SOFT_RESTRICTED_STAKER_ROLE, to)) {
Access Control
#0 - c4-pre-sort
2023-10-31T19:52:02Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T19:52:11Z
raymondfam marked the issue as duplicate of #52
#2 - c4-judge
2023-11-10T21:43:09Z
fatherGoose1 marked the issue as satisfactory
#3 - c4-judge
2023-11-14T17:21:24Z
fatherGoose1 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-11-27T21:38:40Z
fatherGoose1 marked the issue as not a duplicate
#5 - c4-judge
2023-11-27T21:39:06Z
fatherGoose1 marked the issue as duplicate of #72
#6 - fatherGoose1
2023-11-27T21:39:14Z
This report does reference the lack of check in _withdraw()
🌟 Selected for report: adeolu
Also found by: Eeyore, Madalad, Mike_Bello90, Shubham, jasonxiale, josephdara, peanuts
1432.1788 USDC - $1,432.18
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L82 https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L100 https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L116
The maximum cooldown duration is set at 90 days which can be changed by the admin when needed but cannot exceed 90 day threshold. Whenever the cooldownDuration
> 0, cooldownAssets
or cooldownShares
can be called which sends the funds to the silo contract until the cooldown period ends for the user.
Whenever a user decides to unstake, cooldownEnd
is checked to make sure the user is eligible to withdraw funds.
File: StakedUSDeV2.sol function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; if (block.timestamp >= userCooldown.cooldownEnd) { /// @note - will fail incase cooldownDuration changes userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
The cooldownEnd
is set either when calling cooldownAssets() or cooldownShares().
File: StakedUSDeV2.sol 95 function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { .......... 100 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; } 111 function cooldownShares(uint256 shares, address owner) external ensureCooldownOn returns (uint256) { .......... 116 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; }
Scenario 1:
cooldownAssets
to redeem some assets.cooldownEnd
for Alice is set to 90 days counting from the present timestamp.unstake
after 45 days but the call reverts with InvalidCooldown error.Scenario 2:
cooldownAssets
to redeem some assets.cooldownEnd
for Bob is set to 50 days counting from the present timestamp.unstake
after 50 days & withdraws his funds even though the cooldown duration has been extended.Scenario 3:
cooldownAssets
to redeem some assets.cooldownEnd
for Zayn is set to 90 days counting from the present timestamp.unstake
but the function reverts because now he has to wait for 90 days until cooldownEnd is achieved.According to the 1st scenario, even though the cooldown period has been reduced, early users would still not be able to withdraw their funds compared to the new users who requested their funds after the new period.
According to the 2nd scenario, even though the cooldown period has been increased, early users would be able to withdraw according to the previous cooldown duration.
According to the 3rd scenario, even though the cooldown period has been removed or set to 0 days, the user wouldn't be able to unstake as his funds will be stuck inside the Silo contract till the previous cooldown duration ends.
Manual Review
Remove cooldownDuration from cooldownAssets() and cooldownShares() & add it to unstake().
File: StakedUSDeV2.sol 95 function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { .......... - 100 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; + 100 cooldowns[owner].cooldownEnd = uint104(block.timestamp) } 111 function cooldownShares(uint256 shares, address owner) external ensureCooldownOn returns (uint256) { .......... - 116 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; + 116 cooldowns[owner].cooldownEnd = uint104(block.timestamp) } 78 function unstake(address receiver) external { ........ - 82 if (block.timestamp >= userCooldown.cooldownEnd) { + 82 if (block.timestamp >= userCooldown.cooldownEnd + cooldownDuration ) {
Timing
#0 - c4-pre-sort
2023-10-31T18:51:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T18:52:12Z
raymondfam marked the issue as duplicate of #29
#2 - c4-judge
2023-11-13T18:58:55Z
fatherGoose1 marked the issue as satisfactory
#3 - Shubh0412
2023-11-15T07:19:25Z
I believe this submission (#424) should selected for report rather than #29.
In currently selected report (#29), readers would need to study the code directly as they initially wouldn't know what the functions intend to do. It has only showcased one way how the bug affects the code. Under PoC only the code inside the contract has been shown without any tags as to where the bug exists.
#4 - c4-judge
2023-11-17T02:45:07Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#5 - fatherGoose1
2023-11-17T03:08:42Z
@Shubh0412 Issue #29 was downgraded to QA due to lack of impact.
#6 - c4-judge
2023-11-17T16:47:08Z
This previously downgraded issue has been upgraded by fatherGoose1
#7 - Shubh0412
2023-11-17T18:11:55Z
As previously requested in the comment section, this report #424 should be "selected for report".
As you saw recently in the comments section of issue #29 that it was not able to clarify the doubts of other wardens & was downgraded due to lack of clarity.
A case from my report #424 was needed to clarify those doubts (Scenario 3). Valid reasons have been provided in the above mentioned comment to choose this report.
And when the original report is published on C4, wardens reading the report #29 would think that other cases were neglected from the report which are mentioned in #424.
#8 - Shubh0412
2023-11-21T09:24:54Z
@fatherGoose1
I would put all the effort with valid reasoning to make this the best submission.
As per your recent comment on the original issue, I still believe this submission to be a valid Medium & "selected for report".
Although the submission does not explicitly mention the ensureCooldownOn
modifier, it explains the bug clearly.
According to the 3rd scenario, even though the cooldown period has been removed or set to 0 days, the user wouldn't be able to unstake as his funds will be stuck inside the Silo contract till the previous cooldown duration ends.
Further explanations to back up this submission have already been provided here and here by me on the previously mentioned issue #29 which you have rightly acknowledged.
#9 - c4-judge
2023-11-27T19:56:12Z
fatherGoose1 marked the issue as not a duplicate
#10 - c4-judge
2023-11-27T19:56:46Z
fatherGoose1 marked the issue as duplicate of #198
🌟 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
Issue | Instances | |
---|---|---|
L-01 | Users can transfer in small amounts before VESTING_PERIOD duration has passed | 1 |
L-02 | No check to see whether benefactor/beneficiary are restricted addresses |
Issue | Instances | |
---|---|---|
NC-01 | Unnecessary check in verifyRoute() | 1 |
NC-02 | Unnecessary computation in transferInRewards() | 1 |
getUnvestedAmount()
returns 0 when timeSinceLastDistribution >= VESTING_PERIOD that is greater or equal to 8 hours.
But even if the timeSinceLastDistribution
is close to 8 hours & vesting amount is high, the function would still return 0 or if the amount to send is small & the not much time has passed since the last transfer.
File: StakedUSDe.sol function getUnvestedAmount() public view returns (uint256) { uint256 timeSinceLastDistribution = block.timestamp - lastDistributionTimestamp; if (timeSinceLastDistribution >= VESTING_PERIOD) { return 0; } /// @note - will return 0 in some cases return ((VESTING_PERIOD - timeSinceLastDistribution) * vestingAmount) / VESTING_PERIOD; }
Suppose
Similar situation would be if vestingAmount is 1, then Rewarder can call transferInRewards()
with a huge amount the very next second & rewards will be sent.
The Rewarder will try calling transferInRewards()
& it will pass even though VESTING_PERIOD duration hasn't been achieved.
This enables rewards to be transferred before the set time period. Worst case is that users can transfer in small amounts before the 8 hours criteria.
Inside EthenaMinting.sol
, mint() & redeem() are called by the MINTER & REEDEMER respectively. During mint(), the collateral is transferred from the benefactor & during redeem() the collateral is transferred to the beneficiary.
But there is no check to ensure that the address of either the benefactor or beneficiary are soft/fully restricted. This would break the protocol guidelines as the soft restricted addresses aren't supposed to get USDe tokens minted & there should be no interaction with a fully restricted addresses due to malicious or illegal nature of the address.
It is recommended to add a check for blacklisted addresses in EthenaMinting.sol
contract.
verifyRoute()
verifyRoute()
is only called inside mint()
in EthenaMinting contract.
We can see that the 1st if condition already checks for the Order Type which should be MINT
. So inside verifyRoute()
the 1st check will always return false.
File: EthenaMinting.sol function mint(Order calldata order, Route calldata route, Signature calldata signature) external override nonReentrant onlyRole(MINTER_ROLE) belowMaxMintPerBlock(order.usde_amount) { if (order.order_type != OrderType.MINT) revert InvalidOrder(); verifyOrder(order, signature); if (!verifyRoute(route, order.order_type)) revert InvalidRoute(); ...... function verifyRoute(Route calldata route, OrderType orderType) public view override returns (bool) { // routes only used to mint /// @note - will never get executed if (orderType == OrderType.REDEEM) { return true; } .....
Consider removing this check.
transferInRewards()
getUnvestedAmount()
is called inside transferInRewards()
to check if amount is still vesting.
It adds its value to the newVestingAmount
variable & set to vestingAmount
. But it makes no sense because getUnvestedAmount() will only pass the 1st check if its 0 & then later adding 0 to amount is a waste.
File: StakedUSDe.sol function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); uint256 newVestingAmount = amount + getUnvestedAmount(); /// @note - getUnvestedAmount() is always 0 vestingAmount = newVestingAmount; lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
The amount passed could be directly added to vestingAmount without the need of newVestingAmount.
#0 - c4-pre-sort
2023-11-02T02:26:35Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:05:13Z
fatherGoose1 marked the issue as grade-b