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

Anyone can claim `LoveToken` from `Airdrop::claim`

Summary

The Airdrop::claim function allows people who don't have a soulmate to claim LoveTokens. This issue arises from a lack of proper validation to ensure that only people with a soulmate can execute the Airdrop::claim function.

Vulnerability Details

In the README for Airdrop::claim function is said:
claim: Allows only those with a soulmate to collect 1 LoveToken per day.

But the Airdrop::claim function does not perform a check to verify the ownership of a Soulmate Token before allowing the claim:

function claim() public {
// No LoveToken for people who don't love their soulmates anymore.
if (soulmateContract.isDivorced(msg.sender)) revert Airdrop__CoupleIsDivorced();
// Calculating since how long soulmates are reunited
uint256 numberOfDaysInCouple = (block.timestamp -
@> soulmateContract.idToCreationTimestamp(
@> soulmateContract.ownerToId(msg.sender))) / daysInSecond;
uint256 amountAlreadyClaimed = _claimedBy[msg.sender];
if (
amountAlreadyClaimed >=
numberOfDaysInCouple * 10 ** loveToken.decimals()
) {
revert Airdrop__PreviousTokenAlreadyClaimed();
}
uint256 tokenAmountToDistribute = (numberOfDaysInCouple *
10 ** loveToken.decimals()) - amountAlreadyClaimed;
// Dust collector
if (
tokenAmountToDistribute >=
loveToken.balanceOf(address(airdropVault))
) {
tokenAmountToDistribute = loveToken.balanceOf(
address(airdropVault)
);
}
_claimedBy[msg.sender] += tokenAmountToDistribute;
emit TokenClaimed(msg.sender, tokenAmountToDistribute);
loveToken.transferFrom(
address(airdropVault),
msg.sender,
tokenAmountToDistribute
);
}

The function calculates the number of days since the relationship is started:
uint256 numberOfDaysInCouple = (block.timestamp - soulmateContract.idToCreationTimestamp(soulmateContract.ownerToId(msg.sender))) /daysInSecond;
The function also assumes that the soulmateContract.ownerToId(msg.sender) will return a valid id. If the function is called from person who doesn't have a soulmate, the return id for this person will be 0, because his address doesn't exist in the ownerToId mapping. Therefore, the return value will be 0 and this person will be able to claim LoveTokens.
Also, if a person is waiting for a soulmate, he/she already has a valid id, but doesn't have a soulmate and a minted token. If a person is waiting at least 1 day for a soulmate, he/she can claim a LoveToken.

Impact

This vulnerability allows any user without soulmate to claim LoveTokens. We already said that the return value from soulmateContract.ownerToId(msg.sender) for a non existing address in the ownerToId mapping will be 0.
If there is at least one couple in the protocol, their token id is equal to 0. So there is a creation timestamp associated with that id. Therefore, if at least one day has passed since the start of the first couple's relationship, the person/persons without soulmate can claim 1 LoveToken for every day that this couple is in relationship.
The claim tokens are assigned to msg.sender: uint256 amountAlreadyClaimed = _claimedBy[msg.sender];. So the claimed tokens from the people without soulmate will not steal the tokens for the couple with id 0, but will reduce or drain the total amount of tokens in the airdropVault.
The following test test_UserWithoutSoulmateClaim shows the described issue. You can add the test in the test file AirdropTest.t.sol and execute it with the command: forge test --match-test "test_UserWithoutSoulmateClaim".

//Everyone can claim a LoveToken
function test_UserWithoutSoulmateClaim() public {
_mintOneTokenForBothSoulmates();
//If there is a couple, it is required to pass at least one day since the couple exists in order the unauthorized user to claim LoveToken
vm.warp(block.timestamp + 10 days);
//Charlie doesn't have a soulmate
address charlie = makeAddr("charlie");
vm.prank(charlie);
airdropContract.claim();
//Charlie successfully claimed 10 LoveTokens
assertTrue(loveToken.balanceOf(charlie) == 10 ether);
}

Another case is when there are no couples in the protocol. That is the worst case scenario, because the function soulmateContract.idToCreationTimestamp will return 0. And the numberOfDaysInCouple would be calculated as the entire time since the Unix epoch (January 1, 1970), divided by the number of seconds in a day. This would result in a large number representing the total number of days since the Unix epoch. In that case the person without soulmate can claim large number of LoveTokens. If many users make this (or one user with different addresses), the total supply of airdropVault will be drained.
The following test function test_UserWithoutSoulmateClaimThereAreNoCoupleCreated demonstrates this case. The time of calling the function is 12 February 2024 5:32:59 PM. So the claimed tokens should be 19765.

//If there are no couples yet, the unauthorized user can claim 19765 LoveTokens
function test_UserWithoutSoulmateClaimThereAreNoCoupleCreated() public {
//the unix time at 12 February 2024 5:32:59 PM
vm.warp(1707751979);
//Charlie doesn't have a soulmate
address charlie = makeAddr("charlie");
vm.prank(charlie);
airdropContract.claim();
//Charlie successfully claimed 19765 LoveTokens
assertTrue(loveToken.balanceOf(charlie) == 19765 ether);
//David doesn't have a soulmate
address david = makeAddr("david");
vm.prank(david);
airdropContract.claim();
assertTrue(loveToken.balanceOf(david) == 19765 ether);
}

Additionally, if a person is waiting for his/her soulmate more than 1 day, he/she can claim LoveToken, 1 token for every day of waiting. He/she will have a valid id, but doesn't have a soulmate and a minted token. The following test shows that Charlie is waiting from 2 days for a soulmate and can claim 2 LoveTokens:

function test_UserWaitingSoulmateClaim() public {
//soulmate1 and soulmate2 are already a couple
_mintOneTokenForBothSoulmates();
//Charlie doesn't have a soulmate
address charlie = makeAddr("charlie");
vm.prank(charlie);
//Charlie is waiting for a soulmate
soulmateContract.mintSoulmateToken();
vm.warp(block.timestamp + 2 days);
//Charlie claims a LoveToken
vm.prank(charlie);
airdropContract.claim();
//Charlie's id is 1
console2.log(soulmateContract.ownerToId(charlie));
//Charlie successfully claimed 2 ether
assertTrue(loveToken.balanceOf(charlie) == 2 ether);
}

Tools Used

VS Code, Foundry

Recommendations

Add a check in Airdrop::claim function to ensure that people without a soulmate can not claim LoveTokens.
One possible way will be to check if the address of the returned id is associated with the address of msg.sender and if the msg.sender has a soulmate:

function claim() public {
// No LoveToken for people who don't love their soulmates anymore.
if (soulmateContract.isDivorced(msg.sender)) revert Airdrop__CoupleIsDivorced(); // check if soulmates are divorced
+ uint256 id = soulmateContract.ownerToId(msg.sender);
+ require ((soulmateContract.idToOwners(id, 0) == msg.sender ||
+ soulmateContract.idToOwners(id, 1) == msg.sender) &&
+ && soulmateContract.idToOwners(id, 1) != address(0)), "The caller doesn't have soulmate");
// Calculating since how long soulmates are reunited
+ uint256 numberOfDaysInCouple = (block.timestamp -
+ soulmateContract.idToCreationTimestamp(id)) / daysInSecond;
- uint256 numberOfDaysInCouple = (block.timestamp -
- soulmateContract.idToCreationTimestamp(
- soulmateContract.ownerToId(msg.sender)
- )) / daysInSecond;
uint256 amountAlreadyClaimed = _claimedBy[msg.sender];
if (
amountAlreadyClaimed >=
numberOfDaysInCouple * 10 ** loveToken.decimals()
) {
revert Airdrop__PreviousTokenAlreadyClaimed();
}
uint256 tokenAmountToDistribute = (numberOfDaysInCouple *
10 ** loveToken.decimals()) - amountAlreadyClaimed;
// Dust collector
if (
tokenAmountToDistribute >=
loveToken.balanceOf(address(airdropVault))
) {
tokenAmountToDistribute = loveToken.balanceOf(
address(airdropVault)
);
}
_claimedBy[msg.sender] += tokenAmountToDistribute;
emit TokenClaimed(msg.sender, tokenAmountToDistribute);
loveToken.transferFrom(
address(airdropVault),
msg.sender,
tokenAmountToDistribute
);
}

In order the function to work correctly the Soulmate::idToOwner should be public and should be added to the ISoulmate interface. Then if you execute again the described test functions, they will fail:

[FAIL. Reason: revert: The caller doesn't have soulmate] test_UserWithoutSoulmateClaim() (gas: 219577)
[FAIL. Reason: revert: The caller doesn't have soulmate] test_UserWithoutSoulmateClaimThereAreNoCoupleCreated() (gas: 25132)
[FAIL. Reason: revert: The caller doesn't have soulmate] test_UserWaitingSoulmateClaim() (gas: 269363)
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.