Vulnerability: Potential Reentrancy Into Strategies
Severity: medium

Description:
The `StrategyManager` contract is the entry point for deposits into and withdrawals from strategies. More specifically, to `deposit` into a strategy, a staker calls `depositIntoStrategy` (or anyone calls `depositIntoStrategyWithSignature` with the staker's signature) then the asset is transferred from the staker to the strategy contract. After that, the strategy's `deposit` function is called, followed by some bookkeeping in the `StrategyManager`. For withdrawals (and slashing), the `StrategyManager` calls the strategy's `withdraw` function, which transfers the given amount of the asset to the given recipient. Both token transfers are a potential source of reentrancy if the token allows it.\\nThe `StrategyManager` uses OpenZeppelin's `ReentrancyGuardUpgradeable` as reentrancy protection, and the relevant functions have a `nonReentrant` modifier. The `StrategyBase` contract - from which concrete strategies should be derived - does not have reentrancy protection. However, the functions `deposit` and `withdraw` can only be called from the `StrategyManager`, so reentering these is impossible.\\nNevertheless, other functions could be reentered, for example, `sharesToUnderlyingView` and `underlyingToSharesView`, as well as their (supposedly) non-view counterparts.\\nLet's look at the `withdraw` function in `StrategyBase`. First, the `amountShares` shares are burnt, and at the end of the function, the equivalent amount of `token` is transferred to the depositor:\\n```\\nfunction withdraw(address depositor, IERC20 token, uint256 amountShares)\\n    external\\n    virtual\\n    override\\n    onlyWhenNotPaused(PAUSED\\_WITHDRAWALS)\\n    onlyStrategyManager\\n{\\n    require(token == underlyingToken, "StrategyBase.withdraw: Can only withdraw the strategy token");\\n    // copy `totalShares` value to memory, prior to any decrease\\n    uint256 priorTotalShares = totalShares;\\n    require(\\n        amountShares <= priorTotalShares,\\n        "StrategyBase.withdraw: amountShares must be less than or equal to totalShares"\\n    );\\n\\n    // Calculate the value that `totalShares` will decrease to as a result of the withdrawal\\n    uint256 updatedTotalShares = priorTotalShares - amountShares;\\n    // check to avoid edge case where share rate can be massively inflated as a 'griefing' sort of attack\\n    require(updatedTotalShares >= MIN\\_NONZERO\\_TOTAL\\_SHARES || updatedTotalShares == 0,\\n        "StrategyBase.withdraw: updated totalShares amount would be nonzero but below MIN\\_NONZERO\\_TOTAL\\_SHARES");\\n    // Actually decrease the `totalShares` value\\n    totalShares = updatedTotalShares;\\n\\n    /\\*\\*\\n \\* @notice calculation of amountToSend \\*mirrors\\* `sharesToUnderlying(amountShares)`, but is different since the `totalShares` has already\\n \\* been decremented. Specifically, notice how we use `priorTotalShares` here instead of `totalShares`.\\n \\*/\\n    uint256 amountToSend;\\n    if (priorTotalShares == amountShares) {\\n        amountToSend = \\_tokenBalance();\\n    } else {\\n        amountToSend = (\\_tokenBalance() \\* amountShares) / priorTotalShares;\\n    }\\n\\n    underlyingToken.safeTransfer(depositor, amountToSend);\\n}\\n```\\n\\nIf we assume that the `token` contract has a callback to the recipient of the transfer before the actual balance changes take place, then the recipient could reenter the strategy contract, for example, in sharesToUnderlyingView:\\n```\\nfunction sharesToUnderlyingView(uint256 amountShares) public view virtual override returns (uint256) {\\n    if (totalShares == 0) {\\n        return amountShares;\\n    } else {\\n        return (\\_tokenBalance() \\* amountShares) / totalShares;\\n    }\\n}\\n```\\n\\nThe crucial point is: If the callback is executed before the actual balance change, then `sharesToUnderlyingView` will report a bad result because the shares have already been burnt. Still, the token balance has not been updated yet.\\nFor deposits, the token transfer to the strategy happens first, and the shares are minted after that:\\n```\\nfunction \\_depositIntoStrategy(address depositor, IStrategy strategy, IERC20 token, uint256 amount)\\n    internal\\n    onlyStrategiesWhitelistedForDeposit(strategy)\\n    returns (uint256 shares)\\n{\\n    // transfer tokens from the sender to the strategy\\n    token.safeTransferFrom(msg.sender, address(strategy), amount);\\n\\n    // deposit the assets into the specified strategy and get the equivalent amount of shares in that strategy\\n    shares = strategy.deposit(token, amount);\\n```\\n\\n```\\nfunction deposit(IERC20 token, uint256 amount)\\n    external\\n    virtual\\n    override\\n    onlyWhenNotPaused(PAUSED\\_DEPOSITS)\\n    onlyStrategyManager\\n    returns (uint256 newShares)\\n{\\n    require(token == underlyingToken, "StrategyBase.deposit: Can only deposit underlyingToken");\\n\\n    /\\*\\*\\n \\* @notice calculation of newShares \\*mirrors\\* `underlyingToShares(amount)`, but is different since the balance of `underlyingToken`\\n \\* has already been increased due to the `strategyManager` transferring tokens to this strategy prior to calling this function\\n \\*/\\n    uint256 priorTokenBalance = \\_tokenBalance() - amount;\\n    if (priorTokenBalance == 0 || totalShares == 0) {\\n        newShares = amount;\\n    } else {\\n        newShares = (amount \\* totalShares) / priorTokenBalance;\\n    }\\n\\n    // checks to ensure correctness / avoid edge case where share rate can be massively inflated as a 'griefing' sort of attack\\n    require(newShares != 0, "StrategyBase.deposit: newShares cannot be zero");\\n    uint256 updatedTotalShares = totalShares + newShares;\\n    require(updatedTotalShares >= MIN\\_NONZERO\\_TOTAL\\_SHARES,\\n        "StrategyBase.deposit: updated totalShares amount would be nonzero but below MIN\\_NONZERO\\_TOTAL\\_SHARES");\\n\\n    // update total share amount\\n    totalShares = updatedTotalShares;\\n    return newShares;\\n}\\n```\\n\\nThat means if there is a callback in the token's `transferFrom` function and it is executed after the balance change, a reentering call to `sharesToUnderlyingView` (for example) will again return a wrong result because shares and token balances are not “in sync.”\\nIn addition to the reversed order of token transfer and shares update, there's another vital difference between `withdraw` and deposit: For withdrawals, the call to the token contract originates in the strategy, while for deposits, it is the strategy manager that initiates the call to the token contract (before calling into the strategy). That's a technicality that has consequences for reentrancy protection: Note that for withdrawals, it is the strategy contract that is reentered, while for deposits, there is not a single contract that is reentered; instead, it is the contract system that is in an inconsistent state when the reentrancy happens. Hence, reentrancy protection on the level of individual contracts is not sufficient.\\nFinally, we want to discuss though which functions in the strategy contract the system could be reentered. As mentioned, `deposit` and `withdraw` can only be called by the strategy manager, so these two can be ruled out. For the examples above, we considered `sharesToUnderlyingView`, which (as the name suggests) is a `view` function. As such, it can't change the state of the contract, so reentrancy through a `view` function can only be a problem for other contracts that use this function and rely on its return value. However, there is also a potentially state-changing variant, `sharesToUnderlying`, and similar potentially state-changing functions, such as `underlyingToShares` and `userUnderlying`. Currently, these functions are not actually state-changing, but the idea is that they could be and, in some concrete strategy implementations that inherit from `StrategyBase`, will be. In such cases, these functions could make wrong state changes due to state inconsistency during reentrancy.\\nThe examples above assume that the token contract allows reentrancy through its `transfer` function before the balance change has been made or in its `transferFrom` function after. It might be tempting to argue that tokens which don't fall into this category are safe to use. While the examples discussed above are the most interesting attack vectors we found, there might still be others: To illustrate this point, assume a token contract that allows reentrancy through `transferFrom` only before any state change in the token takes place. The token `transfer` is the first thing that happens in `StrategyManager._depositIntoStrategy`, and the state changes (user shares) and calling the strategy's `deposit` function occur later, this might look safe. However, if the `deposit` happens via `StrategyManager.depositIntoStrategyWithSignature`, then it can be seen, for example, that the staker's nonce is updated before the internal `_depositIntoStrategy` function is called:\\n```\\nfunction depositIntoStrategyWithSignature(\\n    IStrategy strategy,\\n    IERC20 token,\\n    uint256 amount,\\n    address staker,\\n    uint256 expiry,\\n    bytes memory signature\\n)\\n    external\\n    onlyWhenNotPaused(PAUSED\\_DEPOSITS)\\n    onlyNotFrozen(staker)\\n    nonReentrant\\n    returns (uint256 shares)\\n{\\n    require(\\n        expiry >= block.timestamp,\\n        "StrategyManager.depositIntoStrategyWithSignature: signature expired"\\n    );\\n    // calculate struct hash, then increment `staker`'s nonce\\n    uint256 nonce = nonces[staker];\\n    bytes32 structHash = keccak256(abi.encode(DEPOSIT\\_TYPEHASH, strategy, token, amount, nonce, expiry));\\n    unchecked {\\n        nonces[staker] = nonce + 1;\\n    }\\n    bytes32 digestHash = keccak256(abi.encodePacked("\\x19\\x01", DOMAIN\\_SEPARATOR, structHash));\\n\\n\\n    /\\*\\*\\n \\* check validity of signature:\\n \\* 1) if `staker` is an EOA, then `signature` must be a valid ECSDA signature from `staker`,\\n \\* indicating their intention for this action\\n \\* 2) if `staker` is a contract, then `signature` must will be checked according to EIP-1271\\n \\*/\\n    if (Address.isContract(staker)) {\\n        require(IERC1271(staker).isValidSignature(digestHash, signature) == ERC1271\\_MAGICVALUE,\\n            "StrategyManager.depositIntoStrategyWithSignature: ERC1271 signature verification failed");\\n    } else {\\n        require(ECDSA.recover(digestHash, signature) == staker,\\n            "StrategyManager.depositIntoStrategyWithSignature: signature not from staker");\\n    }\\n\\n    shares = \\_depositIntoStrategy(staker, strategy, token, amount);\\n}\\n```\\n\\nHence, querying the staker's nonce in reentrancy would still give a result based on an “incomplete state change.” It is, for example, conceivable that the staker still has zero shares, and yet their nonce is already 1. This particular situation is most likely not an issue, but the example shows that reentrancy can be subtle.

Impact:

Recommended mitigation:
This is fine if the token doesn't allow reentrancy in the first place. As discussed above, among the tokens that do allow reentrancy, some variants of when reentrancy can happen in relation to state changes in the token seem more dangerous than others, but we have also argued that this kind of reasoning can be dangerous and error-prone. Hence, we recommend employing comprehensive and defensive reentrancy protection based on reentrancy guards such as OpenZeppelin's ReentrancyGuardUpgradeable, which is already used in the `StrategyManager`.\\nUnfortunately, securing a multi-contract system against reentrancy can be challenging, but we hope the preceding discussion and the following pointers will prove helpful:\\nExternal functions in strategies that should only be callable by the strategy manager (such as `deposit` and withdraw) should have the `onlyStrategyManager` modifier. This is already the case in the current codebase and is listed here only for completeness.\\nExternal functions in strategies for which item 1 doesn't apply (such as `sharesToUnderlying` and underlyingToShares) should query the strategy manager's reentrancy lock and revert if it is set.\\nIn principle, the restrictions above also apply to `public` functions, but if a `public` function is also used internally, checks against reentrancy can cause problems (if used in an `internal` context) or at least be redundant. In the context of reentrancy protection, it is often easier to split `public` functions into an `internal` and an `external` one.\\nIf `view` functions are supposed to give reliable results (either internally - which is typically the case - or for other contracts), they have to be protected too.\\nThe previous item also applies to the StrategyManager: `view` functions that provide correct results should query the reentrancy lock and revert if it is set.\\nSolidity automatically generates getters for `public` state variables. Again, if these (external view) functions must deliver correct results, the same measures must be taken for explicit `view` functions. In practice, the state variable has to become `internal` or `private`, and the getter function must be hand-written.\\nThe `StrategyBase` contract provides some basic functionality. Concrete strategy implementations can inherit from this contract, meaning that some functions may be overridden (and might or might not call the overridden version via super), and new functions might be added. While the guidelines above should be helpful, derived contracts must be reviewed and assessed separately on a case-by-case basis. As mentioned before, reentrancy protection can be challenging, especially in a multi-contract system.