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: 47/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
Sanctioned Users will possess the capability to unstake, contrary to the intended protocol design that seeks to prohibit them from unstaking: https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L31-L32
/// @notice The role which prevents an address to transfer, stake, or unstake. The owner of the contract can redirect address staking balance if an address is in full restricting mode. bytes32 private constant FULL_RESTRICTED_STAKER_ROLE = keccak256("FULL_RESTRICTED_STAKER_ROLE");
The underlying issue is found in the code segment at: https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L232 Specifically, the problem arises from a lack of comprehensive checks within the StakedUSDe._withdraw function. While it does verify the caller and receiver for the FULL_RESTRICTED_STAKER_ROLE, it does not include the owner in this verification.
if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed();
However, in StakedUSDeV2 according to docs, "If cooldown duration is set to zero, the StakedUSDeV2 behavior changes to follow ERC4626 standard and disables cooldownShares and cooldownAssets methods. If cooldown duration is greater than zero, the ERC4626 withdrawal and redeem functions are disabled, breaking the ERC4626 standard, and enabling the cooldownShares and the cooldownAssets functions." https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L13C9-L13C360
In this context, a user who had previously deposited funds and was subsequently fully restricted by an admin can orchestrate a withdrawal by granting an allowance to a random address. The random address can then trigger the StakedUSDeV2.cooldownAssets() function on behalf of the restricted user. This action sets the cooldownEnd and underlyingAmount attributes on behalf of the restricted user:
cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; cooldowns[owner].underlyingAmount += assets;
Once these values are set on behalf of the restricted user, a call is made to StakedUSDe._withdraw, here: https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L103
_withdraw(_msgSender(), address(silo), owner, assets, shares);
Let's take a look at what happens there:
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(); } super._withdraw(caller, receiver, _owner, assets, shares); _checkMinShares(); }
from the function above, we can see that the caller address will pass the check, the receiver will also pass the test since the silo address was passed as the receiver, however, no check for the owner who is actually restricted, so the entire check passes and the assets are sent to the silo.
Following the execution of this call, the restricted owner merely needs to wait for the cooldown period to elapse before initiating the StakedUSDeV2.unstake() function. This function includes a call to the silo for transferring funds to the receiver address. The silo only verifies that the call originates from StakedUSDeV2 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L86 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/USDeSilo.sol#L28-L29
It is worth noting that there is a function for the admin to divert funds of the restricted address, however, the process is manual and requires a multi-sig wallet of the admin which could provide enough time for the restricted owner to quickly pull out.
This can be seen to play out in a test: pass inside StakedUSDeV2.cooldownEnabled.t.sol
//Testing If a a Blocked address/user can actually withdraw despite the restriction. function test_If_a_BlockedUser_Can_Withdraw() external { bytes32 FULL_RESTRICTED_STAKER_ROLE = keccak256( "FULL_RESTRICTED_STAKER_ROLE" ); address bobby = makeAddr("bob"); StakedUSDe stakedUSDeContract; vm.startPrank(owner); stakedUSDeContract = new StakedUSDe( IUSDe(address(usdeToken)), makeAddr("rewarder"), owner ); vm.stopPrank(); //User Deposit and gives approval to another address _mintApproveDeposit(alice, 1e18); assertEq(stakedUSDe.balanceOf(alice), 1e18); assertEq(usdeToken.balanceOf(alice), 0); vm.prank(alice); stakedUSDe.approve(bobby, 1e18); //Owner Restricts Alice vm.startPrank(owner); stakedUSDeContract.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); assertEq( stakedUSDeContract.hasRole(FULL_RESTRICTED_STAKER_ROLE, alice), true ); //By default from deploymemt ensureCoolDown is atice since coolDownDuration is 90 days vm.startPrank(bobby); stakedUSDe.cooldownAssets(1e18, alice); vm.stopPrank(); //Owner(Restricted Owner) withdraws skip(90 days); //fast forward to cool down vm.startPrank(alice); stakedUSDe.unstake(alice); assertEq(stakedUSDe.balanceOf(alice), 0); assertEq(usdeToken.balanceOf(alice), 1e18); //Check that Alice still has role FULL_RESTRICTED_STAKER_ROLE assertEq( stakedUSDeContract.hasRole(FULL_RESTRICTED_STAKER_ROLE, alice), true ); }
Running 1 test for test/foundry/staking/StakedUSDeV2.cooldownEnabled.t.sol:StakedUSDeV2CooldownTest [PASS] test_If_a_BlockedUser_Can_Withdraw() (gas: 3447736) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 34.46ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review and Foundry
Checking if the owner has FULL_RESTRICTED_STAKER_ROLE in StakedUSDe._withdraw will enforce the restriction.
if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed();
Invalid Validation
#0 - c4-pre-sort
2023-10-31T15:01:36Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T15:01:45Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:45:08Z
raymondfam marked the issue as duplicate of #666
#3 - c4-judge
2023-11-13T19:33:04Z
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
In StakedUSDe.transferInRewards()
, a check was done to ensure that getUnvestedAmount()
returns zero, the next line then adds the return value of getUnvestedAmount()
to amount to get newVestingAmount
, a computation that seems unnecessary. vestingAmount
could just have been set to equal amount
directly.
function transferInRewards( uint256 amount ) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); uint256 newVestingAmount = amount + getUnvestedAmount(); //@audit redundant vestingAmount = newVestingAmount; lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
remove the redundant line of code.
function transferInRewards( uint256 amount ) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); vestingAmount = amount; //set vestingAMount directly lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/EthenaMinting.sol#L126-L131 Include zero checks for every address being passed in to maintain consistency in security measures.
for (uint256 i = 0; i < _assets.length; i++) { addSupportedAsset(_assets[i]); if (address(_assets[i]) == address(0)) revert InvalidUSDeAddress(); } for (uint256 j = 0; j < _custodians.length; j++) { addCustodianAddress(_custodians[j]); if (address(_custodians[i]) == address(0)) revert InvalidUSDeAddress(); }
#0 - c4-pre-sort
2023-11-02T03:46:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T16:41:58Z
fatherGoose1 marked the issue as grade-b