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_ROLErole users are not allowed to withdraw funds normally. However it was observed that if user was backlisted postcooldownAssets/cooldownSharescall then user can withdraw funds even though they are blacklisted.Finding Description
- User
Astakes amount100NUSD and say gets100sNUSD - After sometime User wants to withdraw funds so User calls
cooldownAssetswith amount100NUSD - Admin is suspicious of User
Aand blacklists by assigningFULL_RESTRICTED_STAKER_ROLErole to UserA - Post cooldown period, User
Acallsunstake - Since
unstakedoes not check for blacklisting status of caller, hence funds get withdrawn to UserAchosen recipient, bypassing the blacklisted role
Impact Explanation
FULL_RESTRICTED_STAKER_ROLEcan bypass restriction and withdraw funds usingunstakeProof of Concept
Place test at
test\unit\concrete\sNUSD\erc4626\sNUSD_erc4626.t.solfunction 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
unstakefunction to ensure thatmsg.senderis 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
COMPLETEDorCANCELLEDorder can be re-cancelledThe replay of cancel order causes loss of funds, as everytime
order.collateralAmountgets transferred toorder.benefactorProof of Concept
- Assume below order at Request id
1
mintRequestStatus[1] = RequestStatus.PENDINGorder.collateralAmount = 1000order.benefactor = User Aorder.collateralAsset = CollA- Keeper calls
cancelMintRequestto cancel this request id1 - This causes
1000CollA to transferred to User A and status changed toRequestStatus.CANCELLED - Lets say, Keeper accidentally again calls
cancelMintRequeston request id1 - Since there is no status check,
cancelMintRequestagain causes1000CollA to transferred to User A and status changed toRequestStatus.CANCELLED
Note
Similar issue is on
cancelRedemptionRequestRecommendation
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
unlockAssetfunction, 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 enabledRouter.sol Make below changes for
isMintWhitelisted:- /// @notice Mapping of user addresses to their whitelist status+ /// @notice Mapping of user addresses to their mint whitelist statusEdges cases & Minor Recommendations
- Basic sanity check could be added
_custodian != address(0)in_setCustodian
- Only allow changing vesting period if unvested amount is
0else it would impact share price immediately (since totalAssets would change) which would be unexpected by users.
- Since
Routeris non upgradable, soexpiryshould 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
startIndexandendIndexingetPendingMintRequestsandgetPendingRedemptionRequestswhich would prevent Gas issue if number of request ids becomes too large
_updateVestingAmountonce called is locked for vesting period.- Thus if
redistributeLockedAmountcalls_updateVestingAmountthen yield distribution viatransferInRewardswould 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
_belowMaxRedeemPerBlockand_belowMaxMintPerBlockcould 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.recoverTokendoes not allowDEFAULT_ADMIN_ROLEto takeNUSDbut admin still could useredistributeLockedAmountwithtoas 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.setMintWhitelistedis necessary to grant deployer mint capacity,The share is burnt so
deployercannot 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
_requestIdis emitted.emit MintRequestCancelled(_requestId);However, when
mintandredemptionrequests are served, the full order detail is still emitted.emit ServeRequestMint(_requestId, order);The order details contains a field
additionalDataand thisadditionalDatacan 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
requestIdwhen 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_minNusdAmountare 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();