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: 60/147
Findings: 2
Award: $93.25
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
Total of 2 issues
ID | Issue |
---|---|
L-1 | Users can't delegate to other Users for only redeem/mint |
L-2 | Grant role to privileged actors in the constructor itself |
Total of 3 issues
ID | Issue |
---|---|
NC-1 | Zero value is added, can be avoided |
NC-2 | Unnecessary calculation can be avoided |
NC-3 | Add Minter/Redeemer function is missing |
The setDelegatedSigner
delegates a delegatee to have power to mint/redeem. This can cause issues when the delegate wants to only delegate the user to perform one operation ie; mint/redeem. Suppose Alice delegates Bob to mint tokens. She only wants Bob to mint tokens for her. Ideal flow will be Bob mints tokens for Alice. But at the same time if Alice has USDe tokens in her account, then Bob can redeem her tokens as long as he is the delegatee.
This is better addressed by having a ENUM like:
ENUM ACCESS{ INVALID, //-0 MINT, //-1 REDEEM //2 }
And assign it to the delegatedSigner
mapping instead of true/false
function setDelegatedSigner(address _delegateTo) external { - delegatedSigner[_delegateTo][msg.sender] = true; //Replace with enum, default will be invalid + delegatedSigner[_delegateTo][msg.sender] = ACCESS.MINT; emit DelegatedSignerAdded(_delegateTo, msg.sender); }
https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/EthenaMinting.sol#L111C2-L147C1 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L70C2-L81C4
In EthenaMinting.sol and StakedUSDe.sol the roles, REDEEMER_ROLE
, MINTER_ROLE
, GATEKEEPER_ROLE
and BLACKLIST_MANAGER_ROLE
are not assigned when the contract is initilalized but by calling granRole()
externally(as per the tests). It will be better to have the constructor set the roles when the contracts are initilaized as they will be set right away and in any case, wont remain unassigned and cause issues.
In EthenaMinting.sol:
constructor( IUSDe _usde, address[] memory _assets, address[] memory _custodians, address _admin, uint256 _maxMintPerBlock, uint256 _maxRedeemPerBlock, address minter, address redeemer, address gateKeeper ) { --------------------------- ------------------------------- ----------------------------- + _grantRole(REDEEMER_ROLE, minter ); + _grantRole(MINTER_ROLE, redeemer); + _grantRole(GATEKEEPER_ROLE, gateKeeper); }
In StakesUSDe.sol:
constructor(IERC20 _asset, address _initialRewarder, address _owner, address blacklistManager) 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); + _grantRole(BLACKLIST_MANAGER_ROLE, blacklistManager); }
The transferInRewards
in StakedUSDe.sol
has a redundant '0' added into it. The function first checks if (getUnvestedAmount() > 0) revert StillVesting()
then in the nect line it adds the getUnvestedAmount()
into newUnvestedAmount()
, the uint256 value returned by getUnvestedAmount()
will be '0' and there is no need to add it and can be removed.
function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); - uint256 newVestingAmount = amount + getUnvestedAmount(); + uint256 newVestingAmount = amount; vestingAmount = newVestingAmount; lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
The getUnvestedAmount()
in StakedUSD.sol
returns the unvestedAmount by running a calculation based on the lastDistributionTimestamp
and vestingAmount
, it will return zero after the calculation if vestingAmount is zero. So the code can be refactored instead of performing additional calculation.
function getUnvestedAmount() public view returns (uint256) { uint256 timeSinceLastDistribution = block.timestamp - lastDistributionTimestamp; - if (timeSinceLastDistribution >= VESTING_PERIOD) { + if(timtimeSinceLastDistribution >= VESTING_PERIOD || vestingAmount == 0 ){ return 0; } return ((VESTING_PERIOD - timeSinceLastDistribution) * vestingAmount) / VESTING_PERIOD; }
We can see that in EthenaMinting.sol there are removeMinterRole
and removeRedeemerRole
but never functions defined to add these roles(yeah, they can be assigned by calling granRole() externally but so does revokeRole() ) , either remove these two or add functions for adding
those roles for convinience.
+ function addMinterRole(address minter) external onlyRole(GATEKEEPER_ROLE) { //@audit addminter function is missing + _grantRole(MINTER_ROLE, minter); + } + function addRedeemerRole(address redeemer) external onlyRole(GATEKEEPER_ROLE) { + _grantRole(REDEEMER_ROLE, redeemer); + }
#0 - c4-pre-sort
2023-11-02T02:35:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:03:34Z
fatherGoose1 marked the issue as grade-b
🌟 Selected for report: radev_sw
Also found by: 0xSmartContract, 0xweb3boy, Al-Qa-qa, Bauchibred, Bulletprime, D_Auditor, J4X, JCK, K42, Kral01, Sathish9098, ZanyBonzy, albahaca, catellatech, clara, digitizeworx, fouzantanveer, hunter_w3b, invitedtea, jauvany, oakcobalt, pavankv, peanuts, xiao
88.7348 USDC - $88.73
For this analysis, I will center my attention on the scope of the ongoing audit contest 2023-10-Ethena-Labs
. I begin by outlining the code audit approach employed for the in-scope contracts, followed by sharing my perspective on the architecture, and concluding with observations about the implementation's code.
Please note that unless explicitly stated otherwise, any architectural risks or implementation issues mentioned in this document are not to be considered vulnerabilities or recommendations for altering the architecture or code solely based on this analysis. As an auditor, I acknowledge the importance of thorough evaluation for design decisions in a complex project, taking associated risks into consideration as one single part of an overarching process. It is also important to recognize that the project team may have already assessed these risks and determined the most suitable approach to address or coexist with them.
Time spent: 55 hours
The initial step involved examining audit documentation and scope to grasp the audit's concepts and boundaries, and prioritize my efforts. It is to be mentioned that because the documentation provided by the team was top notch and with a relatively low SLoC, it was very easy to grasp a general outline of the codebase quickly.
The setup was straightforward and I faced no issues. The already provided tests served as a documentation which provided insight into how the codebase is supposed to be used and will be used when it launches. The tests were top notch and had very impressive code coverage for the contracts in scope.
After getting an initial understanding and setting up the tests, I began to thoroughly examine the codebase by entering from the User's perspective and began to explore each process flows to go 'deeper' into the codebase. I commented heavily on the code and taking down notes as I was going through the codebase.
I began formulating specific assumptions that, if compromised, could pose security risks to the system. This process aids me in determining the most effective exploitation strategies. While not a comprehensive threat modeling exercise, it shares numerous similarities with one.
After my code analysis, I began to explore each potential vulnerability that came to my mind while writing tests. My primary objective in this phase is to challenge critical assumptions, generate new ones in the process, and enhance this process by leveraging coded proofs of concept to expedite the development of successful exploits. I was going through an iterative process of explore, find leads, write tests to test my assumptions and uncover new leads.
The most effective approach to bring more value to sponsors (and, hopefully, to auditors) is to document what can be gained by exploiting each vulnerability. This assessment helps in evaluating whether these exploits can be strategically combined to create a more significant impact on the system's security. In some cases, seemingly minor and moderate issues can compound to form a critical vulnerability when leveraged wisely. This has to be balanced with any risks that users may face.
Ethena labs introduces USDe which is a stablecoin that solves the stablecoin trilemma by solving the following problems:
Here's how the USDe token works:
MINTER_ROLE
which is an insider role in Ethena. The transfer of the user's assets and the minted synthetic dollars happen in a single transaction.There is also a staking system which allows users to deposit USDe and get stUSDe, which represents the interest of the Staked USDe, stUSDe is a token that increases in value by leveraging on the yields deposited into the protocol. The staking rewards and staked USDe withdrawls are controlled by a cooldown period.
USDe.sol : This contract defines the USDe stablecoin that allows another contract to mint USDe coins. The only allowed contract is the one with the MINTER
role which is the EthenaMinting contract as per the docs. We can say that only the EthenaMinting
contract is allowed to mint the USDe token, which is a nice safety feature. This contract inherits from Ownable2Step
, ERC20Burnable
, ERC20Permit
from the OZ library.
EthenaMinting.sol : This is the main contract which allows the mint
and redeem
functionality of the protocol. USDe.sol grants this contract the ability to mit USDe ie; with the MINTER
role. The main entry points in this contract are mint
and redeem
which can only be called by the MINTER_ROLE
and REDEEMER_ROLE
respectively. These roles are internal Ethena roles whom receive the orders and signatures from the Users and call the appropriate function after running checks. There is a maxMintPerBlock
and maxRedeemPerBlock
which are safeguards against when the MINTER/REDEEMER role is compromised, to decrease the impact of lost funds. The protocol mentioned that the 'highest loss that could occur through mint and redeem was atmost ~300K which was negligible given the scope of USDe token' . This is a nice security feature in case the authorized roles are ever compromised. There is another role GATEKEEPER_ROLE
which acts as an admin in this contract, this role has the ability to pause the mint/redeem by setting the max values as zero, this role can also remove and add new MINTER_ROLE and REDEEMER_ROLE. This contract uses the ReentrancyGuard
, SafeERC20
, ECDSA
, EnumerableSet
from the OZ library.
StakedUSDe.sol : This is the base contract that implements the core staking functionality. This is inherited by the StakedUSDeV2
contract. This contract implements the _withdraw
and _deposit
functions with proper permission checks. The four major roles in this contract are REWARDER_ROLE
, SOFT_RESTRICTED_STAKER_ROLE
, FULL_RESTRICTED_STAKER_ROLE
and BLACKLIST_MANAGER_ROLE
. The two blacklist roles are for controlling addresses by limiting them of functionalities such as depositing and withdrawing. The manager can add and remove addresses from the blacklist. The REWARDER_ROLE
transfers the rewards to the contract that should be available for distribution. There is a minShares
check which is a nice addition to avoid donation attacks. This contract inherits from SingleAdminAccessControl
, ReentrancyGuard
and ERC20Permit
.
StakedUSDeV2.sol : This is the entry point for the end user. There is a cooldown mechanism that can be configured upto 90 days which serves as a cooldown for each user to withdraw their assets by unstaking. When want to withdraw their assets, the corresponding asset of the user is send to the USDeSilo
contract which holds the asset USDe till the cooldown period of the user is over. Then after cooldown, the user can call unstake
and withdraw their USDe from the silo contract. This contract inherits the StakedUSDe
contract.
USDeSilo.sol : This contract is to temporarily hold USDe during redemption cooldown of Users. There is modifier to check if the withdraw
function is called by the Staking contract. This contract can be seen as a small temporary vault that holds the USDe tokens before redeeming.
SingleAdminAccessControl.sol : This contract is a simplified version of the the OZ access control libary implemented by Ethena to be used by their contracts. As the name suggests, it grants the top admin role to one single entity. This contract has the necessary functions such as transferAdmin
, grantRole
, revokeRole
and a modifier notAdmin
. This contract inherits from IERC531
and AccessControl
from OZ library.
USDe
minter - can mint any amount of USDe
tokens to any address. Expected to be the EthenaMinting
contractUSDe
owner - can set token minter
and transfer ownership to another addressStakedUSDe
admin - can rescue tokens from the contract and also to redistribute a fully restricted staker's stUSDe
balance, as well as give roles to other addresses (for example the FULL_RESTRICTED_STAKER_ROLE
role)StakedUSDeV2
admin - has all power of "StakedUSDe
admin" and can also call the setCooldownDuration
methodREWARDER_ROLE
- can transfer rewards into the StakedUSDe
contract that will be vested over the next 8 hoursBLACKLIST_MANAGER_ROLE
- can do/undo full or soft restriction on a holder of stUSDe
SOFT_RESTRICTED_STAKER_ROLE
- address with this role can't stake his USDe
tokens or get stUSDe
tokens minted to himFULL_RESTRICTED_STAKER_ROLE
- address with this role can't burn his stUSDe
tokens to unstake his USDe
tokens, neither to transfer stUSDe
tokens. His balance can be manipulated by the admin of StakedUSDe
MINTER_ROLE
- can actually mint USDe
tokens and also transfer EthenaMinting
's token or ETH balance to a custodian addressREDEEMER_ROLE
- can redeem collateral assets for burning USDe
EthenaMinting
admin - can set the maxMint/maxRedeem amounts per block and add or remove supported collateral assets and custodian addresses, grant/revoke rolesGATEKEEPER_ROLE
- can disable minting/redeeming of USDe
and remove MINTER_ROLE
and REDEEMER_ROLE
roles from authorized accountsHere are some improvements that I noticed that can be added to the codebase, note that these are recommendations and sgould be implemented after proper validation that it doesn't create any new issues.
The setDelegatedSigner
delegates a delegatee to have power to mint/redeem. This can cause issues when the delegate wants to only delegate the user to perform one operation ie; mint/redeem. Suppose Alice delegates Bob to mint tokens. She only wants Bob to mint tokens for her. Ideal flow will be Bob mints tokens for Alice. But at the same time if Alice has USDe tokens in her account, then Bob can redeem her tokens as long as he is the delegatee.
This is better addressed by having a ENUM like:
ENUM ACCESS{ INVALID, //-0 MINT, //-1 REDEEM //2 }
And assign it to the delegatedSigner
mapping instead of true/false
function setDelegatedSigner(address _delegateTo) external { - delegatedSigner[_delegateTo][msg.sender] = true; //Replace with enum, default will be invalid + delegatedSigner[_delegateTo][msg.sender] = ACCESS.MINT; emit DelegatedSignerAdded(_delegateTo, msg.sender); }
https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/EthenaMinting.sol#L111C2-L147C1 https://github.com/code-423n4/2023-10-ethena/blob/ee67d9b542642c9757a6b826c82d0cae60256509/contracts/StakedUSDe.sol#L70C2-L81C4
In EthenaMinting.sol and StakedUSDe.sol the roles, REDEEMER_ROLE
, MINTER_ROLE
, GATEKEEPER_ROLE
and BLACKLIST_MANAGER_ROLE
are not assigned when the contract is initilalized but by calling granRole()
externally(as per the tests). It will be better to have the constructor set the roles when the contracts are initilaized as they will be set right away and in any case, wont remain unassigned and cause issues.
In EthenaMinting.sol:
constructor( IUSDe _usde, address[] memory _assets, address[] memory _custodians, address _admin, uint256 _maxMintPerBlock, uint256 _maxRedeemPerBlock, address minter, address redeemer, address gateKeeper ) { --------------------------- ------------------------------- ----------------------------- + _grantRole(REDEEMER_ROLE, minter ); + _grantRole(MINTER_ROLE, redeemer); + _grantRole(GATEKEEPER_ROLE, gateKeeper); }
In StakesUSDe.sol:
constructor(IERC20 _asset, address _initialRewarder, address _owner, address blacklistManager) 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); + _grantRole(BLACKLIST_MANAGER_ROLE, blacklistManager); }
The transferInRewards
in StakedUSDe.sol
has a redundant '0' added into it. The function first checks if (getUnvestedAmount() > 0) revert StillVesting()
then in the nect line it adds the getUnvestedAmount()
into newUnvestedAmount()
, the uint256 value returned by getUnvestedAmount()
will be '0' and there is no need to add it and can be removed.
function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); - uint256 newVestingAmount = amount + getUnvestedAmount(); + uint256 newVestingAmount = amount; vestingAmount = newVestingAmount; lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
The getUnvestedAmount()
in StakedUSD.sol
returns the unvestedAmount by running a calculation based on the lastDistributionTimestamp
and vestingAmount
, it will return zero after the calculation if vestingAmount is zero. So the code can be refactored instead of performing additional calculation.
function getUnvestedAmount() public view returns (uint256) { uint256 timeSinceLastDistribution = block.timestamp - lastDistributionTimestamp; - if (timeSinceLastDistribution >= VESTING_PERIOD) { + if(timtimeSinceLastDistribution >= VESTING_PERIOD || vestingAmount == 0 ){ return 0; } return ((VESTING_PERIOD - timeSinceLastDistribution) * vestingAmount) / VESTING_PERIOD; }
We can see that in EthenaMinting.sol there are removeMinterRole
and removeRedeemerRole
but never functions defined to add these roles(yeah, they can be assigned by calling granRole() externally but so does revokeRole() ) , either remove these two or add functions for adding
those roles for convinience.
+ function addMinterRole(address minter) external onlyRole(GATEKEEPER_ROLE) { //@audit addminter function is missing + _grantRole(MINTER_ROLE, minter); + } + function addRedeemerRole(address redeemer) external onlyRole(GATEKEEPER_ROLE) { + _grantRole(REDEEMER_ROLE, redeemer); + }
This protocol contains a large amount of centralization, the single DEFAULT_ADMIN_ROLE
controls almost everything in this protocol and can prove very fatal if there is a risk of the address being compromised. In case of a compromisation;
function transferAdmin(address newAdmin) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newAdmin == msg.sender) revert InvalidAdminChange(); _pendingDefaultAdmin = newAdmin; emit AdminTransferRequested(_currentDefaultAdmin, newAdmin); }
REWARDER_ROLE
, MINTER_ROLE
, REDEEMER_ROLE
, GATEKEEPER_ROLE
function grantRole(bytes32 role, address account) public override onlyRole(DEFAULT_ADMIN_ROLE) notAdmin(role) { _grantRole(role, account); }
Given the importance and scope of this project, I strongly believe that this much access given to a single entity can prove very fatal in case of any compromise. I strongly advice the Ethena team to look toward this.
While audits
help in identifying
code-level issues
in the current implementation and potentially the code deployed
in production, the Ethena
team is encouraged to consider incorporating monitoring activities in the production environment. Ongoing monitoring of deployed contracts helps identify potential threats and issues affecting production environments. With the goal of providing a complete security assessment
, the monitoring recommendations
section raises several actions addressing trust assumptions and out-of-scope components that can benefit from on-chain monitoring
.
unexpectedly
large withdrawal, may indicate unusual behavior
of the contracts or an ongoing attack.These may indicate a user interface
bug, an ongoing attack or other unexpected
edge cases.
55 hours
#0 - c4-pre-sort
2023-11-01T14:26:01Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-10T19:35:21Z
fatherGoose1 marked the issue as grade-a