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: 49/147
Findings: 2
Award: $123.66
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: josephdara
Also found by: 0xAadi, 0xmystery, 0xpiken, Arz, Beosin, Eeyore, HChang26, J4X, KIntern_NA, Limbooo, RamenPeople, SpicyMeatball, Team_Rocket, Yanchuan, castle_chain, degensec, ge6a, lanrebayode77, mert_eren, sorrynotsorry, tnquanghuy0512
119.1406 USDC - $119.14
The intended logic is broken
We thought the FULL_RESTRICTED_STAKER_ROLE
holder Alice could approve Bob for her assets after she's blacklisted and can bypass the cooldown flow. Accordingly Bob can initiate the cooldown in the name of Alice.
But the _beforeTokenTransfer
is implemeted to prevent the token transfer.
Contract: StakedUSDe.sol 240: /** 241: * @dev Hook that is called before any transfer of tokens. This includes 242: * minting and burning. Disables transfers from or to of addresses with the FULL_RESTRICTED_STAKER_ROLE role. 243: */ 244: 245: function _beforeTokenTransfer(address from, address to, uint256) internal virtual override { 246: if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { 247: revert OperationNotAllowed(); 248: } 249: if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { 250: revert OperationNotAllowed(); 251: } 252: } 253:
However, below coded POC passes with success.
Please insert below into StakedUSDeV2CooldownBlacklistTest
or StakedUSDeV2CooldownTest
function testtobypass(uint256 amount) public { amount = bound(amount, 1 ether, 1e40); _mintApproveDeposit(alice, amount, false); assertEq(stakedUSDe.balanceOf(alice), amount); vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); vm.startPrank(alice); usdeToken.approve(bob, amount); stakedUSDe.approve(bob, amount); vm.stopPrank(); vm.startPrank(bob); stakedUSDe.cooldownAssets(amount, alice); assertEq(stakedUSDe.balanceOf(alice), 0); }
Manual Review
We're not sure why the POC passes and submitted this for your perusals.
Other
#0 - c4-pre-sort
2023-10-31T18:52:39Z
raymondfam marked the issue as low quality report
#1 - c4-pre-sort
2023-10-31T18:52:44Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-10-31T18:52:51Z
raymondfam marked the issue as duplicate of #7
#3 - c4-pre-sort
2023-11-01T19:45:21Z
raymondfam marked the issue as duplicate of #666
#4 - c4-judge
2023-11-13T19:34:39Z
fatherGoose1 marked the issue as satisfactory
#5 - c4-judge
2023-11-14T15:20:53Z
fatherGoose1 changed the severity to 2 (Med Risk)
🌟 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/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L95C12-L95C26) and [cooldownShares](https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L111
The user funds is strictly restricted
The cooldownAssets and cooldownShares start a cooldown to claim the converted underlying asset.
The logic of both functions serves the same, one is to select the number of USDe, and one is to select the number of stUSDe.
Contract: StakedUSDeV2.sol 96: function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { 97: if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount(); 98: 99: uint256 shares = previewWithdraw(assets); 100: 101: cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; 102: cooldowns[owner].underlyingAmount += assets; 103: 104: _withdraw(_msgSender(), address(silo), owner, assets, shares); 105: 106: return shares; 107: }
At Line:101, it assigns the timestamp where the user can call unstake
and withdraw their assets from the Silo.
Since cooldownAssets
and cooldownShares
add 90 days cooldownDuration
to the current timestamp and reset the cooldownEnd
on every call, every unstake
call can succeed after 90 days of cooldown calls. So accordingly, it provides only 4 withdrawal opportunities a year.
Even the docs mention that the cooldown period will be set to 14 days as below, the current implementation is for the MAX_COOLDOWN_DURATION
Contract: StakedUSDeV2.sol 43: constructor(IERC20 _asset, address initialRewarder, address owner) StakedUSDe(_asset, initialRewarder, owner) { 44: silo = new USDeSilo(address(this), address(_asset)); 45: --> cooldownDuration = MAX_COOLDOWN_DURATION; 46: }
While the cooldownDuration
has a setter, the actual problem is that the contract doesn't separate the cooldown calls and accepts them as a unique unstake event. The users should be waiting for the last cooldown even if they initiated a cooldown 2 months before.
Suppose the user calls cooldownAssets
with the amount of X.
After 2 months, the same user calls cooldownAssets
with the amount of Y. Now the Silo holds X+Y correcponding USDe.
After 1 month, normally, the X should have been completed the cooldown period, but it's now overwritten and will have to wait an other 2 months to be release unless another cooldown call is made.
The system should separate these cooldown calls and assign them nonces/counters to release the funds from the Silo. Else, the system punishes the loyal stakers.
Manual Review
Implement a counter/nonce system for the cooldownShares/Assets calls. Accordingly, validate the nonce's corresponding cooldownEnd
to release the funds from the Silo. This can be accomplished wtih the changes below;
struct UserCooldown { uint104 cooldownEnd; uint256 underlyingAmount; + bool paid; }
+ uint256 public coolDownNonce; // a new public variable used as a counter.
- mapping(address => UserCooldown) public cooldowns; + mapping(address => mapping(uint256 coolDownNonce => UserCooldown)) public cooldowns
function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount(); uint256 shares = previewWithdraw(assets); + coolDownNonce++; - cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; + UserCooldown storage userCooldown = cooldowns[owner][coolDownNonce]; + userCooldown.cooldownEnd = uint104(block.timestamp) + cooldownDuration; + userCooldown.underlyingAmount = assets; // Set to assets as each nonce is unique + userCooldown.paid = false; // Initialize the payment status _withdraw(_msgSender(), address(silo), owner, assets, shares); return shares; }
- function unstake(address receiver) external { + function unstake(uint256 specificCoolDownNonce, address receiver) external { - UserCooldown storage userCooldown = cooldowns[msg.sender]; + UserCooldown storage userCooldown = cooldowns[msg.sender][specificCoolDownNonce]; uint256 assets = userCooldown.underlyingAmount; - if (block.timestamp >= userCooldown.cooldownEnd) { // Check if cooldown has ended and if the amount has not been paid yet + if (block.timestamp >= userCooldown.cooldownEnd && !userCooldown.paid) { + userCooldown.paid = true; // Mark as paid userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
Other
#0 - c4-pre-sort
2023-10-31T03:37:40Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T03:37:58Z
raymondfam marked the issue as duplicate of #4
#2 - c4-pre-sort
2023-11-01T19:36:33Z
raymondfam marked the issue as duplicate of #514
#3 - c4-judge
2023-11-10T21:27:01Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2023-11-17T17:04:10Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-11-20T20:19:09Z
fatherGoose1 marked the issue as grade-b