Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: high
Invalid

Intended protocol flow can be bypassed, users could bypass `PriorityPool` and deposit directly into `VaultDepositController`, which could make protocol unable to deposit due to deposit limit

Summary

In simple terms the intended flow of the protocol is that user initiate deposit in PriorityPool, then from PriorityPool after certain checks and conditions tokens should be deposited in StakingPool and after that funds should go to OperatorVCS or CommunityVCS which then deposit tokens into desired vaults and strategies. So flow should look something like this:

User -> PriorityPool -> StakingPool -> OperatorVCS or CommunityVCS -> Operator Vault or Community Vault -> Operator or Community Pool on Chainlink

Throughout this intended flow deposit amount is tracked succesfully and protocol make sure that max deposit limit is not breached. This is enforced also throughout functions:

deposit function on PriorityPool calls deposit function on StakingPool that have onlyPriorityPool modifier which make sure only PriorityPool should be able to deposit in StakingPool as we can see here:

function deposit(
address _account,
uint256 _amount,
bytes[] calldata _data
) external onlyPriorityPool {

Which eventually deposits funds to desired strategies by calling deposit function in VaultControllerStrategy which is abstract contract that have onlyStakingPool modifier which make sure the function is called only by StakingPool.The deposit function via delegatecall calls deposit function on VaultDepositController as we can see here:

/**
* @notice Deposits tokens from the staking pool into vaults
* @param _amount amount to deposit
* @param _data encoded vault deposit order
*/
function deposit(uint256 _amount, bytes calldata _data) external virtual onlyStakingPool {
if (vaultDepositController == address(0)) revert VaultDepositControllerNotSet();
(bool success, ) = vaultDepositController.delegatecall(
abi.encodeWithSelector(VaultDepositController.deposit.selector, _amount, _data)
);
if (!success) revert DepositFailed();
}

Which intended design is to deposit tokens from StakingPool into Vaults as mentioned in Nat spec above (and also in the first paragraph of this finding).

The deposit function in VaultDepositController looks like this:

/**
* @notice Deposits tokens from the staking pool into vaults
* @dev called by VaultControllerStrategy using delegatecall
* @param _amount amount to deposit
* @param _data encoded vault deposit order
*/
function deposit(uint256 _amount, bytes calldata _data) external {
token.safeTransferFrom(msg.sender, address(this), _amount);
(uint256 minDeposits, uint256 maxDeposits) = getVaultDepositLimits();
uint256 toDeposit = token.balanceOf(address(this));
uint64[] memory vaultIds = abi.decode(_data, (uint64[]));
uint256 deposited = _depositToVaults(toDeposit, minDeposits, maxDeposits, vaultIds);
totalDeposits += deposited;
totalPrincipalDeposits += deposited;
if (deposited < toDeposit) {
token.safeTransfer(address(stakingPool), toDeposit - deposited);
}
}

The intended design is that function should be only called via delegatecall, this also confirm the flow that is mentioned in the beginning of this finding.

Vulnerability Details

The issue here is that the deposit function does not check if it is called only by delegatecall and if the caller is correct contract.
Anyone could call the deposit function in VaultDepositController and effectively bypass the intended flow of the protocol.
The protocol that way would not be able to track deposited amount correctly.
Malicious user (or multiple users) could bypass the intended flow and deposit directly to strategies, the deposited amount could eventually reach deposit cap, which would make protocol unable to deposit the regular way because maximum amount of tokens that the strategy can hold would be reached. The protocol would try to deposit to different strategies if first one fail to do so but this can be done effectively to to other strategies which eventually could make protocol unable to deposit. This can impact also users they would not be able to deposit to the protocol and they could possibly incur loses.

uint256 canDeposit = stakingPool.canDeposit();
if (canDeposit != 0) {
uint256 toDepositIntoPool = toDeposit <= canDeposit ? toDeposit : canDeposit;
stakingPool.deposit(_account, toDepositIntoPool, _data);
toDeposit -= toDepositIntoPool;
}

After passing canDeposit, the malicious user could frontrun and execute their malicious act in mem pool before depositfunction is called, so when it is called it would revert but the PriorityPooldoes not catch that and would think deposit was succesfull and it will deduct toDepositIntoPoolamount from toDepositamount, the remaining amount would be queued or transfered back to user, but the deducted difference would be stuck in PriorityPooland users would incure loss that way. The best case scenario is that user maybe would not incur direct loses because their amount would be returned to them or queued if they want to do that, but they would get denial of service (DoS) when they should not incur that and they should be able to deposit.

Impact

Protocol intended flow can be bypassed and users possibly could incur loss of funds.

Tools Used

Manual Review.

Recommendations

This can be prevented by adding whitelisting addresses and onlyDelegateCall modifier.
Make the following changes to the VaultDepositController :

.
.
.
// address of fund flow controller
IFundFlowController public fundFlowController;
// total number of tokens currently unbonded in the Chainlink staking contract
uint256 public totalUnbonded;
+ // address of allowed contract
+ address public allowedCaller;
.
.
.
+ modifier onlyDelegateCall() {
+ require(address(this) != msg.sender, "Must be called via delegatecall");
+ require(msg.sender == allowedCaller, "Unauthorized delegatecall caller");
+ _;
+ }
.
.
.
+ // Set the address of the allowed contract (e.g., VaultControllerStrategy)
+ function setAllowedCaller(address _allowedCaller) external onlyOwner {
+ allowedCaller = _allowedCaller;
+ }
.
.
.
/**
* @notice Deposits tokens from the staking pool into vaults
* @dev called by VaultControllerStrategy using delegatecall
* @param _amount amount to deposit
* @param _data encoded vault deposit order
*/
+ function deposit(uint256 _amount, bytes calldata _data) external onlyDelegateCall {
.
.
.
/**
* @notice Withdraws tokens from vaults and sends them to staking pool
* @dev called by VaultControllerStrategy using delegatecall
* @param _amount amount to withdraw
* @param _data encoded vault withdrawal order
*/
+ function withdraw(uint256 _amount, bytes calldata _data) external onlyDelegateCall {
.
.
.

This will ensure that only the intended VaultControllerStrategy contract can call deposit and withdraw via delegatecall, preventing unauthorized contracts from bypassing the protocol’s intended flow and access control.

Updates

Lead Judging Commences

inallhonesty Lead Judge
9 months ago
inallhonesty Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

mrmorningstar Submitter
9 months ago
inallhonesty Lead Judge
9 months ago
inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.