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: 45/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
According to the documentation of Ethena:
FULL_RESTRCITED_STAKER_ROLE is for sanction/stolen funds, or if we get a request from law enforcement to freeze funds. Addresses fully restricted cannot move their funds, and only Ethena can unfreeze the address. Ethena also have the ability to repossess funds of an address fully restricted"
However, due to deficiencies in the implementation, the FULL_RESTRICTED_STAKER_ROLE can be bypassed, and a user with this role can withdraw staked USDe in exchange for their stUSDe tokens. This is done by using a second address that is not blocked, and approve it to manage the funds on behalf of the blocked address.
This is an example test that demonstrates the described vulnerability. You can drop it into test/foundry/staking/StakedUSDe.blacklist.t.sol.
function test_fullBlacklist_bypass() public { _mintApproveDeposit(alice, _amount, false); vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); vm.startPrank(alice); stakedUSDe.approve(bob, _amount); vm.stopPrank(); console.log("Alice's shares before withdraw: %s", stakedUSDe.balanceOf(alice)); console.log("Bob's assets before withdraw: %s", usdeToken.balanceOf(bob)); vm.startPrank(bob); stakedUSDe.withdraw(_amount, bob, alice); vm.stopPrank(); console.log("Alice's shares after withdraw: %s", stakedUSDe.balanceOf(alice)); console.log("Bob's assets after withdraw: %s", usdeToken.balanceOf(bob)); }
In this case, the function withdraw() was used, a similar exploit can be constructed for cooldownAssets() / cooldownShares() because they utilize the same underlying helper function as withdraw() - ERC4626._withdraw():
function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, shares); } ... _burn(owner, shares); SafeERC20.safeTransfer(_asset, receiver, assets); emit Withdraw(caller, receiver, owner, assets, shares); }
In it, we can see that the _burn() function is called with the 'owner' parameter, which in this case is blocklisted. Burn, consequently, calls _beforeTokenTransfer(account, address(0), amount), which should revert.
/** * @dev Hook that is called before any transfer of tokens. This includes * minting and burning. Disables transfers from or to of addresses with the FULL_RESTRICTED_STAKER_ROLE role. */ function _beforeTokenTransfer(address from, address to, uint256) internal virtual override { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { revert OperationNotAllowed(); } if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { revert OperationNotAllowed(); } }
In the above implementation, we observe that the condition for reverting _beforeTokenTransfer is 'from' to have the role FULL_RESTRICTED_STAKER_ROLE and 'to' to be different than address(0). In the case of _burn, 'to' is indeed address(0), which is why the function does not revert, allowing the blocked user to withdraw their funds.
An order from law enforcement can be bypassed, which could lead to significant financial sanctions for Ethena in certain jurisdictions. Based on this and the low complexity of the attack, I believe that the correct severity is High.
Manual Review
/** * @dev Hook that is called before any transfer of tokens. This includes * minting and burning. Disables transfers from or to of addresses with the FULL_RESTRICTED_STAKER_ROLE role. */ function _beforeTokenTransfer(address from, address to, uint256) internal virtual override { - if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { + if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from)) { revert OperationNotAllowed(); } if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { revert OperationNotAllowed(); } }
Access Control
#0 - c4-pre-sort
2023-11-01T01:49:39Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T01:49:46Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:45:38Z
raymondfam marked the issue as duplicate of #666
#3 - c4-judge
2023-11-13T19:35:25Z
fatherGoose1 marked the issue as satisfactory
#4 - 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
function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount(); uint256 shares = previewWithdraw(assets); cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; cooldowns[owner].underlyingAmount += assets; _withdraw(_msgSender(), address(silo), owner, assets, shares); return shares; }
Scenario
User calls cooldownAssets() for X assets. After this operation, cooldowns[owner].cooldownEnd = timestamp + 14 days cooldowns[owner].cooldownEnd = X;
After 10 days, the user decides they want to redeem more assets and calls cooldownAssets() with assets Y. After this operation: cooldowns[owner].cooldownEnd = now + 14 days; cooldowns[owner].cooldownEnd = X+Y;
So, the user will have to wait 14 days from that moment until they are able to unstake X+Y assets, regardless of the 10 days that have already passed. During periods of high volatility, the inability to withdraw funds in portions may lead to losses for the user. It's also important to note that the description of the functions doesn't clarify that calling it again resets the timer.
I think that it is a good idea to add the ability to create a new cooldown for each cooldownAssets() / cooldownShares() call.
#0 - c4-pre-sort
2023-11-02T01:06:54Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:14:09Z
fatherGoose1 marked the issue as grade-b