When a holder has the SOFT_RESTRICTED_STAKER_ROLE, they can exchange their stUSDe for USDe using StakedUSDeV2 despite the protocol requirement.
Vulnerability Details
The Ethena readme has the following description of legal requirements for the SOFT_RESTRICTED_STAKER_ROLE:
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.
In summary, legal requires are that a SOFT_RESTRICTED_STAKER_ROLE:
MUST NOT deposit USDe to get stUSDe
MUST NOT withdraw USDe for USDe
MAY earn yield by trading stUSDe on the open market
As StakedUSDeV2 is a ERC4626, the stUSDe is a share on the underlying USDe asset. There are two distinct entry points for a user to exchange their share for their claim on the underlying the asset, withdraw and redeem.
Each cater for a different input (withdraw being by asset, redeem being by share), however both invoked the same internal _withdraw function, hence both entry points are affected.
There are two cases where a user with SOFT_RESTRICTED_STAKER_ROLE may have acquired stUSDe:
Brought stUSDe on the open market
Deposited USDe in StakedUSDeV2 before being granted the SOFT_RESTRICTED_STAKER_ROLE
In both cases the user can call either withdraw their holding by calling withdraw or redeem (when cooldown is off), or unstake (if cooldown is on) and successfully exchange their stUSDe for USDe.
Proof of Concept
The following two tests demonstrate the use case of a user staking, then being granted the SOFT_RESTRICTED_STAKER_ROLE, then exchanging their stUSDe for USDe (first using redeem function, the second using withdraw).
The use case for acquiring on the open market, only requiring a different setup, however the exchange behavior is identical and the cooldown enabled cooldownAssets and cooldownShares function still use the same _withdraw as redeem and withdraw, which leads to the same outcome.
bytes32publicconstant SOFT_RESTRICTED_STAKER_ROLE =keccak256("SOFT_RESTRICTED_STAKER_ROLE");bytes32privateconstant BLACKLIST_MANAGER_ROLE =keccak256("BLACKLIST_MANAGER_ROLE");functiontest_redeem_while_soft_restricted() public {// Set up Bob with 100 stUSDeuint256 initialAmount =100ether;_mintApproveDeposit(bob, initialAmount);uint256 stakeOfBob = stakedUSDe.balanceOf(bob);// Alice becomes a blacklist manager vm.prank(owner); stakedUSDe.grantRole(BLACKLIST_MANAGER_ROLE, alice);// Blacklist Bob with the SOFT_RESTRICTED_STAKER_ROLE vm.prank(alice); stakedUSDe.addToBlacklist(bob, false);// Assert that Bob has staked and is now has the soft restricted roleassertEq(usdeToken.balanceOf(bob), 0);assertEq(stakedUSDe.totalSupply(), stakeOfBob);assertEq(stakedUSDe.totalAssets(), initialAmount);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob));// Rewards to StakeUSDe and vestuint256 rewardAmount =50ether;_transferRewards(rewardAmount, rewardAmount); vm.warp(block.timestamp +8hours);// Assert that only the total assets have increased after vestingassertEq(usdeToken.balanceOf(bob), 0);assertEq(stakedUSDe.totalSupply(), stakeOfBob);assertEq(stakedUSDe.totalAssets(), initialAmount + rewardAmount);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob));// Bob withdraws his stUSDe for USDe vm.prank(bob); stakedUSDe.redeem(stakeOfBob, bob, bob);// End state being while being soft restricted Bob redeemed USDe with rewardsassertApproxEqAbs(usdeToken.balanceOf(bob), initialAmount + rewardAmount, 2);assertApproxEqAbs(stakedUSDe.totalAssets(), 0, 2);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); }functiontest_withdraw_while_soft_restricted() public {// Set up Bob with 100 stUSDeuint256 initialAmount =100ether;_mintApproveDeposit(bob, initialAmount);uint256 stakeOfBob = stakedUSDe.balanceOf(bob);// Alice becomes a blacklist manager vm.prank(owner); stakedUSDe.grantRole(BLACKLIST_MANAGER_ROLE, alice);// Blacklist Bob with the SOFT_RESTRICTED_STAKER_ROLE vm.prank(alice); stakedUSDe.addToBlacklist(bob, false);// Assert that Bob has staked and is now has the soft restricted roleassertEq(usdeToken.balanceOf(bob), 0);assertEq(stakedUSDe.totalSupply(), stakeOfBob);assertEq(stakedUSDe.totalAssets(), initialAmount);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob));// Rewards to StakeUSDe and vestuint256 rewardAmount =50ether;_transferRewards(rewardAmount, rewardAmount); vm.warp(block.timestamp +8hours);// Assert that only the total assets have increased after vestingassertEq(usdeToken.balanceOf(bob), 0);assertEq(stakedUSDe.totalSupply(), stakeOfBob);assertEq(stakedUSDe.totalAssets(), initialAmount + rewardAmount);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob));// Bob withdraws his stUSDe for USDe (-1 as dust is lost in asset to share rounding in ERC4626) vm.prank(bob); stakedUSDe.withdraw(initialAmount + rewardAmount -1, bob, bob);// End state being while being soft restricted Bob redeemed USDe with rewardsassertApproxEqAbs(usdeToken.balanceOf(bob), initialAmount + rewardAmount, 2);assertApproxEqAbs(stakedUSDe.totalAssets(), 0, 2);assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); }
Impact
MEDIUM – The holder with the SOFT_RESTRICTED_STAKER_ROLE can exchange their stUSDe for USDe using StakedUSDeV2.
Recommendation
With the function overriding present, to prevent the SOFT_RESTRICTED_STAKER_ROLE from being able to exchange their stUSDs for USDe, make the following change in StakedUSDe