Stader Labs - 0xWaitress's results

Decentralized ETH liquid staking protocol with 4 ETH bond for anyone to be a node operator.

General Information

Platform: Code4rena

Start Date: 02/06/2023

Pot Size: $100,000 USDC

Total HM: 15

Participants: 75

Period: 7 days

Judge: Picodes

Total Solo HM: 5

Id: 249

League: ETH

Stader Labs

Findings Distribution

Researcher Performance

Rank: 6/75

Findings: 4

Award: $3,465.78

QA:
grade-a
Gas:
grade-b

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: Co0nan

Also found by: 0xWaitress, Co0nan

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-292

Awards

2118.3566 USDC - $2,118.36

External Links

Lines of code

https://github.com/code-423n4/2023-06-stader/blob/main/contracts/ValidatorWithdrawalVault.sol#L66-L69

Vulnerability details

Impact

a fixed amount of validator's SD collateral would be slashed even if it's collateral is slightly less than the penalty

Proof of Concept

the amount of poolThreshold.minThreshold would be slashed anyway regardless of the difference/deficit at which the validator's collateral differ from it's penalty.

settleFunds

    function settleFunds() external override {
 ...
        if (operatorShare < penaltyAmount) {
            ISDCollateral(staderConfig.getSDCollateral()).slashValidatorSD(validatorId, poolId);
            penaltyAmount = operatorShare;
        }

and in the slashValidatorSD

    function slashValidatorSD(uint256 _validatorId, uint8 _poolId) external override nonReentrant {
        address operator = UtilLib.getOperatorForValidSender(_poolId, _validatorId, msg.sender, staderConfig);
        isPoolThresholdValid(_poolId);
        PoolThresholdInfo storage poolThreshold = poolThresholdbyPoolId[_poolId];
        uint256 sdToSlash = convertETHToSD(poolThreshold.minThreshold);
        slashSD(operator, sdToSlash);
    }

Tools Used

pass the difference to slash to the slashValidatorSD function and slash accordingly (even with some penalty multiplier).

Assessed type

Other

#0 - Picodes

2023-06-14T18:58:13Z

It looks like this is by design

#1 - c4-judge

2023-06-14T19:01:59Z

Picodes marked the issue as duplicate of #165

#2 - c4-judge

2023-07-03T12:08:20Z

Picodes marked the issue as duplicate of #292

#3 - c4-judge

2023-07-03T12:08:35Z

Picodes marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0xWaitress

Also found by: Josiah, LaScaloneta, RaymondFam, T1MOH, peanuts

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
selected for report
sponsor acknowledged
edited-by-warden
M-13

Awards

1115.3148 USDC - $1,115.31

External Links

Lines of code

https://github.com/code-423n4/2023-06-stader/blob/main/contracts/Auction.sol#L48-L50

Vulnerability details

Impact

no bidder has incentive to bid the Auction except doing last-minute MEV due to fixed endBlock

Proof of Concept

The auction of SD Token has a fixed endBlock. bidder(s) would like to get SD Token with least ETH and they are all incentivized to just bid at the last block, leading to loss of protocol principle (during the auction).

    function createLot(uint256 _sdAmount) external override whenNotPaused {
        lots[nextLot].startBlock = block.number;
        lots[nextLot].endBlock = block.number + duration;
        lots[nextLot].sdAmount = _sdAmount;

Genearally, auction with fixed endtime has the known vulnerability of being bidden at the last block, essentially the validator/MEVer who has the ability to slip in transaction at that block has the highest likelihood to get the bid. This basically give them advantage, and would lead to the auction to end at lower price.

Tools Used

Extend the final endBlock at each bid. This can be activated at the end of 1h for example to ensure the highest bidder can take the auction on a fairly manner.

Assessed type

MEV

#0 - c4-judge

2023-06-10T14:40:01Z

Picodes marked the issue as primary issue

#1 - c4-sponsor

2023-06-18T14:17:55Z

manoj9april marked the issue as sponsor acknowledged

#2 - manoj9april

2023-06-18T14:20:36Z

MEV bots are welcome to be bidders.

#3 - c4-sponsor

2023-06-20T07:27:37Z

manoj9april marked the issue as sponsor disputed

#4 - Picodes

2023-07-02T11:01:45Z

This is a valid vulnerability in my opinion. MEV are of course welcome to bid but the problem is that the current auction system doesn't allow for a proper price discovery. From a game theory standpoint, no one has any interest in revealing their bid before the end of the auction, and when bidding you tend to not reveal your max bid but try to guess the "second-highest bid". Therefore the current design can quickly lead to a suboptimal final price as someone may have increased its bid given more time.

Note as well that it is currently relatively cheap to censor a transaction for a few blocks by bribing block builders. So with the current design value will may easily be lost trying to censor other bidders or bribing miners to be included.

#5 - c4-judge

2023-07-02T21:21:40Z

Picodes marked the issue as selected for report

#6 - manoj9april

2023-07-05T09:25:02Z

Yes, We agree with your point. But for now we would prefer go with current simpler approach and improve based your recommendation in later phases.

I think this should be considered as low severity.

#7 - c4-sponsor

2023-07-05T09:25:56Z

manoj9april marked the issue as sponsor acknowledged

#8 - c4-sponsor

2023-07-05T09:26:02Z

manoj9april marked the issue as disagree with severity

Awards

210.4947 USDC - $210.49

Labels

bug
grade-a
QA (Quality Assurance)
sponsor acknowledged
edited-by-warden
Q-36

External Links

  1. calculation of operatorShare can be skipped if collateralETH is 0 (for permissionedPool)
        uint256 TOTAL_STAKED_ETH = staderConfig.getStakedEthPerNode();
        uint256 collateralETH = getCollateralETH(_poolId);
        uint256 usersETH = TOTAL_STAKED_ETH - collateralETH;
        uint256 protocolFeeBps = getProtocolFee(_poolId);
        uint256 operatorFeeBps = getOperatorFee(_poolId);

        uint256 _userShareBeforeCommision = (_totalRewards * usersETH) / TOTAL_STAKED_ETH;

        protocolShare = (protocolFeeBps * _userShareBeforeCommision) / staderConfig.getTotalFee();
       
>>>>>can be skipped if collateralETH is 0        operatorShare = (_totalRewards * collateralETH) / TOTAL_STAKED_ETH;
        operatorShare += (operatorFeeBps * _userShareBeforeCommision) / staderConfig.getTotalFee();

        userShare = _totalRewards - protocolShare - operatorShare;
  1. onlyExistingPoolId iterates the list to find the existingPool which is very gas intensive. Use openzeppelin EnumerableSet to reduce gas
    function getQueuedValidatorCountByPool(uint8 _poolId)
        external
        view
        override
        onlyExistingPoolId(_poolId)
        returns (uint256)
    {
        address nodeRegistry = getNodeRegistry(_poolId);
        return INodeRegistry(nodeRegistry).getTotalQueuedValidatorCount();
    }

    /// @inheritdoc IPoolUtils
    function getActiveValidatorCountByPool(uint8 _poolId)
        public
        view
        override
        onlyExistingPoolId(_poolId)
        returns (uint256)
    {
        address nodeRegistry = getNodeRegistry(_poolId);
        return INodeRegistry(nodeRegistry).getTotalActiveValidatorCount();
    }

    /// @inheritdoc IPoolUtils
    function getSocializingPoolAddress(uint8 _poolId)
        public
        view
        override
        onlyExistingPoolId(_poolId)
        returns (address)
    {
        return IStaderPoolBase(poolAddressById[_poolId]).getSocializingPoolAddress();
    }
  1. extractNonBidSD should use safeTransfer instead of transfer for consistency
    function extractNonBidSD(uint256 lotId) external override {
        LotItem storage lotItem = lots[lotId];
        if (block.number <= lotItem.endBlock) revert AuctionNotEnded();
        if (lotItem.highestBidAmount > 0) revert LotWasAuctioned();
        if (lotItem.sdAmount == 0) revert AlreadyClaimed();

        uint256 _sdAmount = lotItem.sdAmount;
        lotItem.sdAmount = 0;
        if (!IERC20(staderConfig.getStaderToken()).transfer(staderConfig.getStaderTreasury(), _sdAmount)) {
            revert SDTransferFailed();
        }
        emit UnsuccessfulSDAuctionExtracted(lotId, _sdAmount, staderConfig.getStaderTreasury());
    }
  1. OperatorRewardsController.claim of 0 should either be prohibited or short-circuited from sendValue to avoid unnecessary call.
    function claim() external whenNotPaused {
        address operator = msg.sender;
        uint256 amount = balances[operator];
        balances[operator] -= amount;

        address operatorRewardsAddr = UtilLib.getOperatorRewardAddress(msg.sender, staderConfig);
        UtilLib.sendValue(operatorRewardsAddr, amount);
        emit Claimed(operatorRewardsAddr, amount);
    }
  1. lack of function to remove node operator from permissionList on PermissionedNodeRegistry

``solidity // mapping of whitelisted permissioned node operator mapping(address => bool) public override permissionList;

7. user can only specify a full ETH as expectedAmount, but sometimes they might want to specify a decimal amount but now is not working since minEThRequiredToFinalizeRequest is dividing by 10 ** 18 ```solidity UserWithdrawInfo memory userWithdrawInfo = userWithdrawRequests[requestId]; uint256 requiredEth = userWithdrawInfo.ethExpected; uint256 lockedEthX = userWithdrawInfo.ethXAmount; uint256 minEThRequiredToFinalizeRequest = Math.min(requiredEth, (lockedEthX * exchangeRate) / DECIMALS);
  1. add a way to sunset the allowance of a auctionContract on SD Collateral by setting its allowance to 0. Right now the maxApproveSD function would set allowance for auctionContract to type(uint256).max. But if a new auctionContract is updated, the allowance of the old auctionContract cannot be revoked.
    /// @notice for max approval to auction contract for spending SD tokens
    function maxApproveSD() external override {
        UtilLib.onlyManagerRole(msg.sender, staderConfig);
        address auctionContract = staderConfig.getAuctionContract();
        UtilLib.checkNonZeroAddress(auctionContract);
        IERC20(staderConfig.getStaderToken()).approve(auctionContract, type(uint256).max);
    }
  1. enableSafeMode and disableSafeMode in StaderOracle does not check if the safeMode is already enabled or disabled. This is different from all other functions which has this input validation.
    function enableSafeMode() external override {
        UtilLib.onlyManagerRole(msg.sender, staderConfig);
        safeMode = true;
        emit SafeModeEnabled();
    }

    function disableSafeMode() external override onlyRole(DEFAULT_ADMIN_ROLE) {
        safeMode = false;
        emit SafeModeDisabled();
    }

Recommendation

add a status check before setting the safeMode to the designed value.

#0 - c4-judge

2023-06-14T07:08:56Z

Picodes marked the issue as grade-a

#1 - c4-sponsor

2023-06-21T13:11:28Z

manoj9april marked the issue as sponsor acknowledged

Awards

21.6219 USDC - $21.62

Labels

bug
G (Gas Optimization)
grade-b
edited-by-warden
G-27

External Links

  1. onlyExistingPoolId iterates the entire list to find the existingPool which is very gas intensive. Use openzeppelin EnumerableSet to reduce gas
    function getQueuedValidatorCountByPool(uint8 _poolId)
        external
        view
        override
        onlyExistingPoolId(_poolId)
        returns (uint256)
    {
        address nodeRegistry = getNodeRegistry(_poolId);
        return INodeRegistry(nodeRegistry).getTotalQueuedValidatorCount();
    }

    /// @inheritdoc IPoolUtils
    function getActiveValidatorCountByPool(uint8 _poolId)
        public
        view
        override
        onlyExistingPoolId(_poolId)
        returns (uint256)
    {
        address nodeRegistry = getNodeRegistry(_poolId);
        return INodeRegistry(nodeRegistry).getTotalActiveValidatorCount();
    }

    /// @inheritdoc IPoolUtils
    function getSocializingPoolAddress(uint8 _poolId)
        public
        view
        override
        onlyExistingPoolId(_poolId)
        returns (address)
    {
        return IStaderPoolBase(poolAddressById[_poolId]).getSocializingPoolAddress();
    }

2.calculateMEVTHeftPenality just need a length return from rate oracle

    /// @inheritdoc IPenalty
    function calculateMEVTheftPenalty(bytes32 _pubkeyRoot) public override returns (uint256) {
        // Retrieve the epochs in which the validator violated the fee recipient change rule.
        uint256[] memory violatedEpochs = IRatedV1(ratedOracleAddress).getViolationsForValidator(_pubkeyRoot);

        // each strike attracts `mevTheftPenaltyPerStrike` penalty
        return violatedEpochs.length * mevTheftPenaltyPerStrike;
    }

Recommendation

add a function in rateOracle that only returns the length of the violatedEpochs.

#0 - c4-judge

2023-06-14T05:58:20Z

Picodes marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter