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: 28/147
Findings: 2
Award: $214.85
🌟 Selected for report: 1
🚀 Solo Findings: 0
210.3346 USDC - $210.33
A requirement is stated that a user with the SOFT_RESTRICTED_STAKER_ROLE
is not allowed to withdraw USDe
for stUSDe
.
The code does not satisfy that condition, when a holder has the SOFT_RESTRICTED_STAKER_ROLE
, they can exchange their
stUSDe
for USDe
using StakedUSDeV2
.
The Ethena readme has the following decription of legal requirements for the Soft Restricted Staker Role: https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/README.md?plain=1#L98
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
:
As StakedUSDeV2
is a ERC4626
, the stUSDe
is a share on the underlying USDe
asset.
There are two distinct entrypoints 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 entrypoints are affected.
There are two cases where a user with SOFT_RESTRICTED_STAKER_ROLE
may have acquired stUSDe
:
stUSDe
on the open marketUSDe
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
.
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 withdrawm
).
The use case for acquiring on the open market, only requiring a different setup, however the exchange behaviour 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.
(Place code into StakedUSDe.t.sol
and run with forge test
)
https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/test/foundry/staking/StakedUSDe.t.sol
bytes32 public constant SOFT_RESTRICTED_STAKER_ROLE = keccak256("SOFT_RESTRICTED_STAKER_ROLE"); bytes32 private constant BLACKLIST_MANAGER_ROLE = keccak256("BLACKLIST_MANAGER_ROLE"); function test_redeem_while_soft_restricted() public { // Set up Bob with 100 stUSDe uint256 initialAmount = 100 ether; _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 role assertEq(usdeToken.balanceOf(bob), 0); assertEq(stakedUSDe.totalSupply(), stakeOfBob); assertEq(stakedUSDe.totalAssets(), initialAmount); assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); // Rewards to StakeUSDe and vest uint256 rewardAmount = 50 ether; _transferRewards(rewardAmount, rewardAmount); vm.warp(block.timestamp + 8 hours); // Assert that only the total assets have increased after vesting assertEq(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 rewards assertApproxEqAbs(usdeToken.balanceOf(bob), initialAmount + rewardAmount, 2); assertApproxEqAbs(stakedUSDe.totalAssets(), 0, 2); assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); } function test_withdraw_while_soft_restricted() public { // Set up Bob with 100 stUSDe uint256 initialAmount = 100 ether; _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 role assertEq(usdeToken.balanceOf(bob), 0); assertEq(stakedUSDe.totalSupply(), stakeOfBob); assertEq(stakedUSDe.totalAssets(), initialAmount); assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); // Rewards to StakeUSDe and vest uint256 rewardAmount = 50 ether; _transferRewards(rewardAmount, rewardAmount); vm.warp(block.timestamp + 8 hours); // Assert that only the total assets have increased after vesting assertEq(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 rewards assertApproxEqAbs(usdeToken.balanceOf(bob), initialAmount + rewardAmount, 2); assertApproxEqAbs(stakedUSDe.totalAssets(), 0, 2); assertTrue(stakedUSDe.hasRole(SOFT_RESTRICTED_STAKER_ROLE, bob)); }
Manual review, Foundry test
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
- if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { + if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller)) { revert OperationNotAllowed(); }
Access Control
#0 - c4-pre-sort
2023-10-31T06:28:05Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T06:28:31Z
raymondfam marked the issue as duplicate of #52
#2 - c4-judge
2023-11-10T21:40:19Z
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:58:31Z
fatherGoose1 marked the issue as selected for report
#5 - kayinnnn
2023-12-15T16:15:35Z
For this issue, the docs were incorrect to say withdrawal by soft restricted role is not allowed. only depositing is not allowed.
#6 - thebrittfactor
2023-12-18T14:56:16Z
For transparency, the disputed
label was added at sponsor request.
🌟 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
Number of each QA issue type found.
Id | Issue |
---|---|
L-1 | USDe should use two-step assignment for ownership |
NC-1 | NatSpec - Incorrect access control for transferInRewards |
NC-2 | NatSpec - Incorrect access control for addToBlacklist |
NC-3 | NatSpec - Incorrect access control for removeFromBlacklist |
NC-4 | Unused SafeERC20 library |
constructor(address admin) ERC20("USDe", "USDe") ERC20Permit("USDe") { if (admin == address(0)) revert ZeroAddressException(); _transferOwnership(admin); }
The constructor of USDe
is directly assigning ownership to the input parameter address admin
, bypassing the two-step
functionality inherited from Ownable2Step
.
Direct ownership assigment has input risk, where if the input was incorrect, with there being no verification ownership would be lost. It's safer to implement a two-step process where the new address is first proposed, then later confirmed, allowing for more control and the chance to catch errors or malicious activity.
Use Ownable2Step.transferOwnership
that requires the admin
call acceptOwnership
before ownership is actually
transferred from msg.sender
.
constructor(address admin) ERC20("USDe", "USDe") ERC20Permit("USDe") { if (admin == address(0)) revert ZeroAddressException(); - _transferOwnership(admin); + transferOwnership(admin); }
The NatSpec notice for the extenral
function StakedUSDe.transferInRewards
incorrectly states the access control as
being the owner
, where it is actually REWARDER_ROLE
.
/** * @notice Allows the owner to transfer rewards from the controller contract into this contract. * @param amount The amount of rewards to transfer. */ function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) {
Although an external
function, the reduction in access control only affects the owner, which will be an account under
the Ethena team's control (i.e. users would not call the function via Etherscan an expect correct execution,
as they would not be the owner).
Add test as a member of the Staking ACL tests
and run with forge test --match-test test_access_control_transferInRewards
bytes32 public constant REWARDER_ROLE = keccak256("REWARDER_ROLE"); function test_access_control_transferInRewards() public { assertTrue(stakedUSDe.hasRole(DEFAULT_ADMIN_ROLE, owner)); assertFalse(stakedUSDe.hasRole(REWARDER_ROLE, owner)); vm.expectRevert( "AccessControl: account 0xe05fcc23807536bee418f142d19fa0d21bb0cff7 is missing role 0xbeec13769b5f410b0584f69811bfd923818456d5edcf426b0e31cf90eed7a3f6" ); vm.prank(owner); stakedUSDe.transferInRewards(0 ether); }
- * @notice Allows the owner to transfer rewards from the controller contract into this contract. + * @notice Allows the rewarder to transfer rewards from the controller contract into this contract.
The NatSpec notice for the external
function StakedUSDe.addToBlacklist
incorrectly states the access control as
allowing DEFAULT_ADMIN_ROLE
, where it only allows the blacklist managers.
/** * @notice Allows the owner (DEFAULT_ADMIN_ROLE) and blacklist managers to blacklist addresses. * @param target The address to blacklist. * @param isFullBlacklisting Soft or full blacklisting level. */ function addToBlacklist(address target, bool isFullBlacklisting) external onlyRole(BLACKLIST_MANAGER_ROLE) notOwner(target)
Although an external
function, the reduction in access control only affects the owner, which will be an account under
the Ethena team's control (i.e. users would not call the function via Etherscan an expect correct execution,
as they would not be the owner).
Add test as a member of the Staking ACL tests
and run with forge test --match-test test_access_control_addToBlacklist
function test_access_control_addToBlacklist() public { assertTrue(stakedUSDe.hasRole(DEFAULT_ADMIN_ROLE, owner)); assertFalse(stakedUSDe.hasRole(BLACKLIST_MANAGER_ROLE, owner)); vm.expectRevert( "AccessControl: account 0xe05fcc23807536bee418f142d19fa0d21bb0cff7 is missing role 0xf988e4fb62b8e14f4820fed03192306ddf4d7dbfa215595ba1c6ba4b76b369ee" ); vm.prank(owner); stakedUSDe.addToBlacklist(alice, true); }
- * @notice Allows the owner (DEFAULT_ADMIN_ROLE) and blacklist managers to blacklist addresses. + * @notice Allows blacklist managers to blacklist addresses.
The NatSpec notice for the external
function StakedUSDe.removeFromBlacklist
incorrectly states the access control as
allowing DEFAULT_ADMIN_ROLE
, where it only allows the blacklist managers.
/** * @notice Allows the owner (DEFAULT_ADMIN_ROLE) and blacklist managers to un-blacklist addresses. * @param target The address to un-blacklist. * @param isFullBlacklisting Soft or full blacklisting level. */ function removeFromBlacklist(address target, bool isFullBlacklisting) external onlyRole(BLACKLIST_MANAGER_ROLE) notOwner(target)
Add test as a member of the Staking ACL tests
and run with forge test --match-test test_access_control_removeFromBlacklist
function test_access_control_removeFromBlacklist() public { assertTrue(stakedUSDe.hasRole(DEFAULT_ADMIN_ROLE, owner)); assertFalse(stakedUSDe.hasRole(BLACKLIST_MANAGER_ROLE, owner)); vm.expectRevert( "AccessControl: account 0xe05fcc23807536bee418f142d19fa0d21bb0cff7 is missing role 0xf988e4fb62b8e14f4820fed03192306ddf4d7dbfa215595ba1c6ba4b76b369ee" ); vm.prank(owner); stakedUSDe.removeFromBlacklist(alice, true); }
- * @notice Allows the owner (DEFAULT_ADMIN_ROLE) and blacklist managers to un-blacklist addresses. + * @notice Allows blacklist managers to un-blacklist addresses.
In USDeSilo.sol
the SafeERC20
library is imported and used for the IERC20
reference it holds, but no functions
of that library are being used.
The library and it's import can be safely removed, simplifying the code and reducing the size (and gas deployment cost) for the contract.
- import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
- using SafeERC20 for IERC20;
#0 - c4-pre-sort
2023-11-02T02:49:34Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T16:57:28Z
fatherGoose1 marked the issue as grade-b