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: 21/147
Findings: 3
Award: $285.46
🌟 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 FULL_RESTRCITED_STAKER_ROLE
is used for sanction/stolen funds and these addresses cannot move their funds and withdraw their stUSDe. The _withdraw()
function is then overriden with these checks:
225: function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) 226: internal 227: override 228: nonReentrant 229: notZero(assets) 230: notZero(shares) 231: { 232: if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { 233: revert OperationNotAllowed(); 234: }
The problem is that these checks only check the caller and the receiver and not the actual owner and if you take a look at the ERC4626.sol
contract _withdraw()
function, you can see that the owner of the shares can approve someone to withdraw on their behalf:
255: function _withdraw( 256: address caller, 257: address receiver, 258: address owner, 259: uint256 assets, 260: uint256 shares 261: ) internal virtual { 262: if (caller != owner) { 263: _spendAllowance(owner, caller, shares); 264: }
So the blacklisted address would easily call approve()
to approve a different non-blacklisted address that will then call withdraw and spend allowance - burning the shares of the blacklisted address and receiving the USDe
Fullblacklisted addresses can easily withdraw their funds that are supposed to be freeezed and avoid having their stolen/sanction funds seized.
Paste this into StakedUSDe.blacklist.t.sol
and as you will see the FULL_RESTRICTED_STAKER_ROLE
can easily withdraw funds by approving someone to withdraw on their behalf
function test_fullBlacklistApproveWithdraw() public { _mintApproveDeposit(alice, _amount, false); console.log("BALANCE OF ALICE BEFORE:", stakedUSDe.balanceOf(alice)); // Alice gets blacklisted vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); //Alice approves to Bob so he can withdraw vm.prank(alice); stakedUSDe.approve(bob, _amount); //Bob then withdraws Alices funds that are supposed to be freezed vm.prank(bob); stakedUSDe.redeem(_amount, makeAddr("receiver"), alice); console.log("BALANCE OF ALICE AFTER:", stakedUSDe.balanceOf(alice)); console.log("USDe BALANCE OF RECEIVER:", usdeToken.balanceOf(makeAddr("receiver"))); }
Foundry
There needs to be a check in the overriden _withdraw()
function that the _owner
is not blacklisted
if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver) || hasRole(FULL_RESTRICTED_STAKER_ROLE, _owner) ) { revert OperationNotAllowed(); }
ERC4626
#0 - c4-pre-sort
2023-11-01T02:41:07Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T02:41:14Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:43:57Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-11-01T19:44:06Z
raymondfam marked the issue as primary issue
#4 - c4-pre-sort
2023-11-01T19:44:12Z
raymondfam marked the issue as high quality report
#5 - c4-sponsor
2023-11-08T15:01:26Z
FJ-Riveros (sponsor) confirmed
#6 - c4-judge
2023-11-13T19:30:08Z
fatherGoose1 marked the issue as satisfactory
#7 - fatherGoose1
2023-11-14T15:07:55Z
Valid Medium. This report highlights how to violate Ethena's business rules regarding legal compliance.
#8 - c4-judge
2023-11-14T17:18:48Z
fatherGoose1 marked the issue as selected for report
#9 - c4-judge
2023-11-17T02:55:03Z
fatherGoose1 marked issue #499 as primary and marked this issue as a duplicate of 499
161.7958 USDC - $161.80
As the readme mentions, SOFT_RESTRICTED_STAKER_ROLE shouldnt be able to deposit or withdraw their USDe/stUSDe:
https://github.com/code-423n4/2023-10-ethena/tree/main#stakedusdev2sol
Due to legal requirements, there's a SOFT_RESTRICTED_STAKER_ROLE and FULL_RESTRICTED_STAKER_ROLE. The former is for addresses based in countries we are not allowed to provide yield to, for example USA. 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
The problem is that the _withdraw() function only checks if the addresses have FULL_RESTRCITED_STAKER_ROLE
but not the SOFT_RESTRICTED_STAKER_ROLE
SOFT_RESTRICTED_STAKER_ROLE
will be able to withdraw stUSDe for USDe even if it shouldnt as the docs mention.
225: function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) 226: internal 227: override 228: nonReentrant 229: notZero(assets) 230: notZero(shares) 231: { 232: if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { 233: revert OperationNotAllowed(); 234: }
As you can see the checks only check if the caller and receiver have the FULL_RESTRICTED_STAKER_ROLE
but not the SOFT_RESTRICTED_STAKER_ROLE
Manual Review
Add a check for SOFT_RESTRICTED_STAKER_ROLE
too in the overriden _withdraw()
function
if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); }
Invalid Validation
#0 - c4-pre-sort
2023-11-01T03:02:47Z
raymondfam marked the issue as low quality report
#1 - c4-pre-sort
2023-11-01T03:02:51Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-11-01T03:03:04Z
raymondfam marked the issue as duplicate of #52
#3 - c4-judge
2023-11-10T21:44:52Z
fatherGoose1 marked the issue as satisfactory
🌟 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
If the user decides to withdraw while some of his funds are already in the cooldown period, the userCooldown.cooldownEnd
will only keep increasing because its not unique for each of the users withdrawal.
The user will then have to wait much longer for all of his funds to get withdrawn even though he already waited some time for the first withdrawal.
100: cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
The user can have funds already in the cooldown period so the cooldownEnd
will be set but if he decides to withdraw more then the cooldownEnd
will be increased again.
The user will have to wait much longer to unstake all of his funds even though he already waited and will receive the funds later than supposed to.
Add this to StakedUSDeV2.cooldownEnabled.t.sol
, this test demonstrates that the cooldownEnd
keeps increasing and is not unique for each withdrawal of the user.
function testCooldownEnd() public { vm.prank(owner); stakedUSDe.setCooldownDuration(15 days); _mintApproveDeposit(alice, 100e18); uint256 shares = stakedUSDe.balanceOf(alice); //Alice decides to withdraw half of her shares vm.prank(alice); stakedUSDe.cooldownShares(shares / 2, alice); (uint104 cooldownEnd, ) = stakedUSDe.cooldowns(alice); vm.warp(block.timestamp + 7 days); console.log("The cooldown ends in %s days", (cooldownEnd - block.timestamp) / 1 days); //Alice then later decides that she wants to withdraw the rest //But the previous funds arent claimed yet vm.prank(alice); stakedUSDe.cooldownShares(shares/ 2, alice); //Alice now has to wait 15 days to claim all her funds even tho she already waited 7 days (uint104 cooldownEnd2, ) = stakedUSDe.cooldowns(alice); console.log("The cooldown now ends in %s days", (cooldownEnd2 - block.timestamp) / 1 days); }
Foundry
Consider making each of the withdrawal unique by having 2 mappings and then the cooldownEnd
will be different for each of the users withdrawal.
mapping(address => uint256) public id; mapping(address => mapping(uint256 id => UserCooldown)) public cooldowns;
Timing
#0 - c4-pre-sort
2023-11-01T02:54:42Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T02:54:49Z
raymondfam marked the issue as duplicate of #4
#2 - c4-pre-sort
2023-11-01T19:39:04Z
raymondfam marked the issue as duplicate of #514
#3 - c4-judge
2023-11-10T21:26:55Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2023-11-17T17:04:09Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-11-27T20:08:28Z
fatherGoose1 marked the issue as grade-b