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: 71/147
Findings: 2
Award: $18.76
🌟 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
Issue | |
---|---|
QA‑01 | verifyOrder() currently accepts an expired signature and emits a wrong event |
QA‑02 | The SOFT_RESTRICTED_STAKER_ROLE is a legal case in the making |
QA‑03 | Max mint per block could actually be exceeded |
QA‑04 | _transferCollateral() should be made more efficient |
QA‑05 | Setters should always have equality checkers |
QA‑06 | Potential role oversight while depositing |
verifyOrder()
currently accepts an expired signature and emits a wrong eventNo inclusive checks means the order verification process currently accepts an already expired signature.
Take a look at verifyOrder()
/// @notice assert validity of signed order function verifyOrder(Order calldata order, Signature calldata signature) public view override returns (bool, bytes32) { bytes32 taker_order_hash = hashOrder(order); address signer = ECDSA.recover(taker_order_hash, signature.signature_bytes); if (!(signer == order.benefactor || delegatedSigner[signer][order.benefactor])) revert InvalidSignature(); if (order.beneficiary == address(0)) revert InvalidAmount(); if (order.collateral_amount == 0) revert InvalidAmount(); if (order.usde_amount == 0) revert InvalidAmount(); //@audit-issue if (block.timestamp > order.expiry) revert SignatureExpired(); return (true, taker_order_hash); }
The current condition only checks if the block timestamp has surpassed the order's expiry. This means even if the order expires it would be validated the check would not consider the signature expired, when in fact it should be.
Make these changes to verifyOrder()
/// @notice assert validity of signed order function verifyOrder(Order calldata order, Signature calldata signature) public view override returns (bool, bytes32) { bytes32 taker_order_hash = hashOrder(order); address signer = ECDSA.recover(taker_order_hash, signature.signature_bytes); if (!(signer == order.benefactor || delegatedSigner[signer][order.benefactor])) revert InvalidSignature(); - if (order.beneficiary == address(0)) revert InvalidAmount(); + if (order.beneficiary == address(0)) revert InvalidAddresst(); if (order.collateral_amount == 0) revert InvalidAmount(); if (order.usde_amount == 0) revert InvalidAmount(); - if (block.timestamp > order.expiry) revert SignatureExpired(); + if (block.timestamp >= order.expiry) revert SignatureExpired(); return (true, taker_order_hash); }
SOFT_RESTRICTED_STAKER_ROLE
is a legal case in the makingThe current configuration of the SOFT_RESTRICTED_STAKER_ROLE
allows addresses with this role to circumvent deposit restrictions by transferring their USDe
tokens to another address they control. This could easily lead to a legal case since one can link these transfers to each other and if restrictions were to be placed on a user from a specific country and they are not fixed could easily land the team a lawsuit.
The below has been stated under Known Issues
SOFT_RESTRICTED_STAKER_ROLE
can be bypassed by user buying/selling stUSDe on the open market But that's not the only case as there are other methods one could by pass this
SOFT_RESTRICTED_STAKER_ROLE
.USDe
tokens from this address (with SOFT_RESTRICTED_STAKER_ROLE
) to another address owned by the same entity.USDe
tokens. This operation will succeed, demonstrating the loophole.Review and possibly refine the permissions associated with the SOFT_RESTRICTED_STAKER_ROLE
to ensure that they align with the intended functionality, since this could lead to legal issues, on one hand I would suggest to just scrap it and instead have only the FULL_RESTRICTED_STAKER_ROLE
NB: First this is only submittted as a QA since it's been listed to be a main invariant, but since it requires admin mistake it sits on the fence of med/QA
As stated under the Main Invariants
section of the contest's readMe
Max mint per block should never be exceeded. But current code implementation does not follow this based on two reasons:
setMaxMintPerBlock()
it sets the new max mint for that specific block and any block that comes after it, this can be seen below:function setMaxMintPerBlock(uint256 _maxMintPerBlock) external onlyRole(DEFAULT_ADMIN_ROLE) { _setMaxMintPerBlock(_maxMintPerBlock); } function _setMaxMintPerBlock(uint256 _maxMintPerBlock) internal { uint256 oldMaxMintPerBlock = maxMintPerBlock; maxMintPerBlock = _maxMintPerBlock; emit MaxMintPerBlockChanged(oldMaxMintPerBlock, maxMintPerBlock); }
The above easily means that for the current block the max mint could always be altered
To ensure that more than max is not minted then there is a need to introduce timing to the process and ensure that the current max mint can't be changed for the current timestamp. Got it. Here's the revised report:
_transferCollateral()
should be made more efficientLow, since at most this could only be 1 wei
but might still lead to issues regarding ratio
if this ever amounts to a large number.
Take a look at _transferCollateral()
:
function _transferCollateral( uint256 amount, address asset, address benefactor, address[] calldata addresses, uint256[] calldata ratios ) internal { // cannot mint using unsupported asset or native ETH even if it is supported for redemptions if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset(); IERC20 token = IERC20(asset); uint256 totalTransferred = 0; for (uint256 i = 0; i < addresses.length; ++i) { uint256 amountToTransfer = (amount * ratios[i]) / 10_000; token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); totalTransferred += amountToTransfer; } uint256 remainingBalance = amount - totalTransferred; //@audit if (remainingBalance > 0) { token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance); } }
As seen, the _transferCollateral
function, designed to transfer supported assets to a range of custody addresses based on defined ratios, but it has an inefficiency in how it handles the remaining balance after all the transfers.
As seen, if there is a remaining balance, it is sent to the last address in the addresses
array:
if (remainingBalance > 0) { token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance); }
This approach assumes that the last address is always the intended recipient of any remaining balance. It doesn't provide flexibility and can cause unexpected results if, for example, the addresses
array order changes, or this amounts to a considerable sum for that specific address
leading to a break in the ratio logic.
Allow specification of which address among the addresses
array should receive any remaining balance.
The transferAdmin
function in its current state does not validate if the newAdmin
being set is different from the _currentDefaultAdmin
. This oversight can lead to unnecessary operations where the admin role is "transferred" to the same address that already possesses it, wasting gas and potentially causing confusion among the stakeholders.
Consider the function:
//@audit setters equality check function transferAdmin(address newAdmin) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newAdmin == msg.sender) revert InvalidAdminChange(); _pendingDefaultAdmin = newAdmin; emit AdminTransferRequested(_currentDefaultAdmin, newAdmin); }
Here's the sequence that demonstrates the gap:
_currentDefaultAdmin
) calls the transferAdmin
function with their own address as the newAdmin
.newAdmin
address is the same as the msg.sender
.newAdmin
is the same as _currentDefaultAdmin
.newAdmin
is the same as _currentDefaultAdmin
.Modify the function to include a check comparing newAdmin
to _currentDefaultAdmin
. If they are the same, the function should revert the transaction.
solidity if (newAdmin == _currentDefaultAdmin) revert RedundantAdminChange();
While depositing there seem to be a check for users who have been assigned the SOFT_RESTRICTED_STAKER_ROLE
but no consideration is applied for users with the FULL_RESTRICTED_STAKER_ROLE
.
The following check is performed to prevent users with the SOFT_RESTRICTED_STAKER_ROLE
from using the function:
if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); }
However, no check is done for users with the FULL_RESTRICTED_STAKER_ROLE
Introduce a check for the FULL_RESTRICTED_STAKER_ROLE
#0 - c4-pre-sort
2023-11-02T03:01:47Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T16:55:52Z
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
14.2357 USDC - $14.24
The audit began with a comprehensive review of the provided documentation, not limiting to just the files in scope. Following this, insights were extracted from previous audit reports over several hours. A detailed, line-by-line security assessment of the source lines of code (sLOC) ensued. The final stage involved engaging with the development team on Discord to comprehend unique implementations, discuss potential vulnerabilities, and validate critical observations.
The protocol's efficiency is closely linked to its hedging strategy.
In volatile market conditions, the protocol may fail to liquidate funds from centralized exchanges, potentially hindering users from withdrawing their full balance.
For user redemptions, it's imperative that the protocol admin moves adequate funds from custody to the "Ethena Minting" contract.
Concerningly, there's no on-chain guarantee for the redeemability of "USDe". The team possesses the discretion to withhold funds.
Notable roles and their privileges:
maxMintPerBlock
and maxRedeemPerBlock
.USDe
token address.MINTER_ROLE
.StakedUSDeV2.sol
:
Owner/DEFAULT_ADMIN_ROLE
can administer roles.stUSDe
from specified wallets.REWARDER_ROLE
can vest USDe
tokens via transferInRewards()
.Points of Vulnerability:
Ethena Labs
to mint USDe
without the requisite underlying tokens.USDe
's underlying assets can be extracted indiscriminately.stUSDe
tokens are susceptible to unilateral confiscation by the staking contract's administrative role.stUSDe
holders necessitate manual deposits.Mitigation Strategies:
maxMintPerBlock
and _maxRedeemPerBlock
are supposed to have defined values. However, they aren't verified on-chain, allowing an admin to arbitrarily set values.FULL_RESTRICTED_STAKER_ROLE
and redistributing their tokens.GATEKEEPER_ROLE
concept poses challenges. Although designed as a safeguard, an errant address could cause irreversible damage. Instead of merely revoking privileges post-factum, it's wiser to assign roles proactively based on need and then withdraw them post-usage.EthenaMinting
contract doesn't hold underlying LST assets, which reside in custodial wallets. This structure, while possibly efficient, exposes potential liquidity challenges for USDe
redemptions.GATEKEEPER
roles only when necessary and revoking them afterward, would be more judicious.SOFT_RESTRICTED_STAKER_ROLE
entails partial restrictions. Addresses with this role can't deposit but can perform other functions. Such an address might transfer their USDe
tokens elsewhere and then deposit.The testing coverage is admirable, with both line and statement coverage meeting standards. Nevertheless, a coverage exceeding 90% is always preferable for enhanced security.
While the Solidity Visual Developer tool is already in use, incorporating additional plugins, especially for Visual Studio Code, could be advantageous.
Although the current coverage is satisfactory, striving for a 90% coverage would significantly enhance protocol reliability.
The current setup could benefit from a more strategic implementation of event tracking to ensure maximum utility.
My attempt on reviewing the in-scope codebase spanned 12 hours distributed over 3 days:
The codebase was a very great learning experience, though it was a pretty hard nut to crack, being that it's like an update contest and most easy to catch bugs have already been spotted and mitigated from the previous contest.
11 hours
#0 - c4-pre-sort
2023-11-01T14:31:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-10T19:21:02Z
fatherGoose1 marked the issue as grade-b