Neutrl Contracts
Cantina Security Report
Organization
- @Neutrl
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
3 findings
3 fixed
0 acknowledged
Informational
4 findings
3 fixed
1 acknowledged
Medium Risk1 finding
Fully restricted user can withdraw funds
Severity
- Severity: Medium
Submitted by
Anurag Jain
Summary
FULL_RESTRICTED_STAKER_ROLE
role users are not allowed to withdraw funds normally. However it was observed that if user was backlisted postcooldownAssets/cooldownShares
call then user can withdraw funds even though they are blacklisted.Finding Description
- User
A
stakes amount100
NUSD and say gets100
sNUSD - After sometime User wants to withdraw funds so User calls
cooldownAssets
with amount100
NUSD - Admin is suspicious of User
A
and blacklists by assigningFULL_RESTRICTED_STAKER_ROLE
role to UserA
- Post cooldown period, User
A
callsunstake
- Since
unstake
does not check for blacklisting status of caller, hence funds get withdrawn to UserA
chosen recipient, bypassing the blacklisted role
Impact Explanation
FULL_RESTRICTED_STAKER_ROLE
can bypass restriction and withdraw funds usingunstake
Proof of Concept
Place test at
test\unit\concrete\sNUSD\erc4626\sNUSD_erc4626.t.sol
function test_POC() external whenCooldownDurationIsNotZero { // Setup cooldown deal(address(sNusd), normalUser, testAmount); vm.startPrank(normalUser); nusd.approve(address(sNusd), testAmount); sNusd.deposit(testAmount, normalUser); sNusd.cooldownAssets(testAmount); vm.stopPrank(); vm.startPrank(users.admin); sNusd.grantRole(FULL_RESTRICTED_STAKER_ROLE, normalUser); vm.stopPrank(); // Fast forward time to end of cooldown uint24 cooldownDuration = sNusd.cooldownDuration(); vm.warp(block.timestamp + cooldownDuration + 1); // Now unstake vm.startPrank(normalUser); uint256 initialBalance = nusd.balanceOf(normalUser); sNusd.unstake(normalUser); uint256 finalBalance = nusd.balanceOf(normalUser); // Verify balance increased and cooldown was reset assertTrue(finalBalance > initialBalance, "NUSD balance should increase after unstaking"); // Check cooldown was reset (uint104 cooldownEnd, uint152 underlyingAmount) = sNusd.cooldowns(normalUser); assertEq(cooldownEnd, 0, "Cooldown end should be reset to zero"); assertEq(underlyingAmount, 0, "Underlying amount should be reset to zero"); vm.stopPrank(); }
Recommendation
Add a check in
unstake
function to ensure thatmsg.sender
is not having roleFULL_RESTRICTED_STAKER_ROLE
Low Risk3 findings
Duplicate tokens allowed while adding yield token
Severity
- Severity: Low
Submitted by
Anurag Jain
Description
Admin can mistakenly add duplicate yield token which is currently accepted. At deletion only the first copy of token in array is deleted, leaving the duplicate one still active. This could allow unintended token to be considered for yield distribution
Proof of Concept
Place the test at
yield_distribution.t.sol
. Test revert showing token is still activefunction test_DuplicateTokenPOC() external whenTheCallerIsDEFAULT_ADMIN_ROLE { yieldDistributor.addYieldToken(address(mockUSDC)); yieldDistributor.addYieldToken(address(mockUSDC)); // Duplicate token addition (start with index 2,3 as setup already use index 0,1) (address token, bool isActive) = yieldDistributor.yieldTokens(2); (address token2, bool isActive2) = yieldDistributor.yieldTokens(3); assertEq(token, address(mockUSDC)); assertEq(token2, address(mockUSDC)); yieldDistributor.removeYieldToken(address(mockUSDC)); // Verify token is now inactive (token2, isActive2) = yieldDistributor.yieldTokens(3); assertEq(token2, address(mockUSDC)); assertFalse(isActive); }
Recommendation
Disallow addition of duplicate yield token
Missing status check allow replaying cancel request
Severity
- Severity: Low
Submitted by
Anurag Jain
Description
It was observed that Request status was not verified while cancelling order. This means even a
COMPLETED
orCANCELLED
order can be re-cancelledThe replay of cancel order causes loss of funds, as everytime
order.collateralAmount
gets transferred toorder.benefactor
Proof of Concept
- Assume below order at Request id
1
mintRequestStatus[1] = RequestStatus.PENDINGorder.collateralAmount = 1000order.benefactor = User Aorder.collateralAsset = CollA
- Keeper calls
cancelMintRequest
to cancel this request id1
- This causes
1000
CollA to transferred to User A and status changed toRequestStatus.CANCELLED
- Lets say, Keeper accidentally again calls
cancelMintRequest
on request id1
- Since there is no status check,
cancelMintRequest
again causes1000
CollA to transferred to User A and status changed toRequestStatus.CANCELLED
Note
Similar issue is on
cancelRedemptionRequest
Recommendation
Add below check in
cancelMintRequest
,if (mintRequestStatus[_requestId] != RequestStatus.PENDING) revert InvalidRequestStatus();
Add below check in
cancelRedemptionRequest
,if (redemptionRequestStatus[_requestId] != RequestStatus.PENDING) revert InvalidRequestStatus();
unlockAsset contain reentrancy risk if ERC777 token is added as locked asset
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
ladboy233
Description
Once the lock expires, the user can withdraw the locked.
However, the code updates the state and external transfer call and subject to reentrancy attack.
IERC20(_asset).safeTransfer(msg.sender, lock.amount); delete userLocks[msg.sender][_asset]; totalLocked[_asset] -= lock.amount;
If the locked asset is an ERC777 token with a registered hook, a malicious actor can exploit the reentrancy opportunity by triggering the callback and repeatedly invoking the
unlockAsset
function, allowing them to withdraw the asset multiple times.Recommendation
delete userLocks[msg.sender][_asset];totalLocked[_asset] -= lock.amount; ERC20(_asset).safeTransfer(msg.sender, lock.amount);
Informational4 findings
Typos, Edges cases & Minor Recommendations
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description / Recommendation
Typos
Make below changes for
isMintWhitelistEnforced
:- /// @notice Whether the whitelist enforcement is enabled+ /// @notice Whether the mint whitelist enforcement is enabled
Router.sol Make below changes for
isMintWhitelisted
:- /// @notice Mapping of user addresses to their whitelist status+ /// @notice Mapping of user addresses to their mint whitelist status
Edges cases & Minor Recommendations
- Basic sanity check could be added
_custodian != address(0)
in_setCustodian
- Only allow changing vesting period if unvested amount is
0
else it would impact share price immediately (since totalAssets would change) which would be unexpected by users.
- Since
Router
is non upgradable, soexpiry
should be set correctly so that there is no issues later in future integration
Something like:
// where Waiting_Period is configurable by Adminexpiry: block.timestamp + Waiting_Period
- Can include arg
startIndex
andendIndex
ingetPendingMintRequests
andgetPendingRedemptionRequests
which would prevent Gas issue if number of request ids becomes too large
_updateVestingAmount
once called is locked for vesting period.- Thus if
redistributeLockedAmount
calls_updateVestingAmount
then yield distribution viatransferInRewards
would fail at same time and would only succeed once vesting period is over
function _updateVestingAmount(uint256 _newVestingAmount) internal { if (getUnvestedAmount() > 0) revert StillVesting(); vestingAmount = _newVestingAmount; lastDistributionTimestamp = block.timestamp; }
- Currently, Redemption and Mint above max limit will be queued but would fail on execution causing Keeper to finally cancel the transaction from queue
- Check for
_belowMaxRedeemPerBlock
and_belowMaxMintPerBlock
could also be made at Router level for early revert
- Centralization risk is present at multiple instances in code
- One such instance is by using
redistributeLockedAmount
.recoverToken
does not allowDEFAULT_ADMIN_ROLE
to takeNUSD
but admin still could useredistributeLockedAmount
withto
as Admin controlled address to getsNUSD
.
Deployment script uses WETH hardcoded address which may change in other chain like Arbitrum. This should be taken care if deployment script need to changed for other chains (along with chain id restriction)
router.setRedeemWhitelisted can be removed to save gas
Description
The function aims to mint a tiny share and burn the share to avoid the donation attack.
While
router.setMintWhitelisted
is necessary to grant deployer mint capacity,The share is burnt so
deployer
cannot redeem the share.Recommendation
Remove
router.setRedeemWhitelisted(deployer, true)
to save gas.Consider only emit _requestId when the keeper process the mint and redemption request
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When mint and redemption requests are canceled, only the
_requestId
is emitted.emit MintRequestCancelled(_requestId);
However, when
mint
andredemption
requests are served, the full order detail is still emitted.emit ServeRequestMint(_requestId, order);
The order details contains a field
additionalData
and thisadditionalData
can be arbitrarily long.struct Order { OrderType orderType; uint256 expiry; address benefactor; address beneficiary; address collateralAsset; /// @dev When minting, this is the amount of collateral to deposit /// @dev When redeeming, this is the minimum amount of collateral to receive uint256 collateralAmount; /// @dev When minting, this is the minimum amount of NUSD to receive /// @dev When redeeming, this is the amount of NUSD to burn uint256 nusdAmount; /// @dev Additional data for the order, in case of future use for minters / redeemers bytes additionalData;
The order keeper must cover the gas costs even when additionalData is excessively long, causing the keeper to consistently incur losses due to high gas consumption.
Recommendation
Only emit
requestId
when redemption and mint request are served by keeper..emit ServeRequestMint(_requestId);
and
emit ServeRequestRedemption(_requestId);
Deployment script fails in case when USDE traded below 1e18
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When the protocol attempts to mint the initial share to avoid donation attack, both the
_collateralAmount,
and the_minNusdAmount
are 10e18router.mint(deployer, deploymentArgs.usde, 10e18, 10e18, "");
function mint( address _beneficiary, address _collateralAsset, uint256 _collateralAmount, uint256 _minNusdAmount, bytes calldata _additionalData ) external whenNotPaused enforceMintWhitelist(msg.sender) {
if the USDE traded below 1e18, then the transaction can revert because of 0 slippage tolerance.
Recommendation
uint256 minSlippage = vm.envUint("NEUTRAL_SNUSD__POST_SLIPPAGE_AMOUNT"); IERC20(deploymentArgs.usde).approve(address(router), minSlippage); router.mint(deployer, deploymentArgs.usde, 10e18, minSlippage, ""); uint256 balance = nusd.balanceOf(deployer); nusd.approve(deploymentAddress.sNusd, balance); sNusd.deposit(balance, deployer); // kill shares with donation to WETH// assertEq(sNusd.totalSupply(), 10e18); uint256 mintedShare = sNusd.balanceOf(deployer); sNusd.transfer(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, mintedShare); assertEq(sNusd.totalSupply(), mintedShare); vm.stopBroadcast();