DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

SoulboundProfileNFT::mintProfile validates none of the profile fields, so profiles with an under-age, 0 or 255 age and an empty name and image are minted and admitted by LikeRegistry as full participants in paid matching

Root + Impact

Description

  • The README describes the NFT as the user's "verified dating profile". Before a user can pay to like others and be matched, their dating profile should have a usable name and image and a plausible adult age.

  • mintProfile's only check is profileToToken[msg.sender] == 0 (L31). It stores Profile(name, age, profileImage) (L37) with no bound on the uint8 age and no non-empty check on name or profileImage. LikeRegistry treats any address with profileToToken != 0 as a valid participant (L35-L36) and never reads the profile fields. A profile declaring age 15, 0 or 255 with a blank name and image is therefore minted and published in tokenURI. It can then like, be liked, match and co-own a match multisig.

// src/SoulboundProfileNFT.sol L30-L38: the only check is the one-profile check; age, name and image are stored as given
@> function mintProfile(string memory name, uint8 age, string memory profileImage) external {
@> require(profileToToken[msg.sender] == 0, "Profile already exists");
​
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId);
​
// Store metadata on-chain
@> _profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
​
// src/LikeRegistry.sol L35-L36: any address holding a profile is a full participant
@> require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
@> require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");

Risk

Likelihood:

  • Any user can mint such a profile through a normal, permissionless mintProfile call. A careless or honest entry is accepted as given, whether it is a blank field, age 0 or a true age under 18.

  • Age is self-declared, so on-chain bounds catch only honestly disclosed or nonsensical values. No on-chain check can stop a user who types a false adult age.

Impact:

  • The protocol mints and fully enables "verified dating profiles" with blank names and images and with nonsensical or under-age ages. In the PoC, an age-15 profile with a blank name and image:

    • pays 1 ETH to like an adult;

    • is matched;

    • appears in both getMatches() lists;

    • is owner1 of the deployed MultiSigWallet.

  • tokenURI publishes the stored values (Age 15, 0 or 255, empty name and image) as protocol-generated metadata. No funds are at risk. The effect is that invalid profile data is admitted into the paid matching flow.

Proof of Concept

The PoC deploys SoulboundProfileNFT and LikeRegistry. The baseline shows that an address without a profile cannot like. The tests then show the following:

  • Mint accepts anything: mintProfile("", 15, ""), mintProfile("", 0, "") and mintProfile("Old", 255, "ipfs://x") all succeed, and tokenURI returns exactly the Base64 JSON with those values.

  • Full participation: the age-15 profile with a blank name and image:

    • pays 1 ETH to like an adult;

    • is liked back, which emits Matched;

    • appears in both getMatches() lists;

    • is owner1 of the MultiSigWallet deployed at the predicted address.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
​
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
import "../src/MultiSig.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
​
/// DD-10: mintProfile accepts any uint8 age (0-17, 0, 255) and empty name/image,
/// and LikeRegistry admits such a profile as a full participant (like, match, multisig).
contract PoC_DD10 is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
​
address minor = makeAddr("minor");
address adult = makeAddr("adult");
address blank = makeAddr("blank");
address ageMax = makeAddr("ageMax");
address noProfile = makeAddr("noProfile");
​
event ProfileMinted(address indexed user, uint256 tokenId, string name, uint8 age, string profileImage);
event Matched(address indexed user1, address indexed user2);
​
function setUp() public {
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
vm.deal(minor, 10 ether);
vm.deal(adult, 10 ether);
vm.deal(blank, 10 ether);
vm.deal(noProfile, 10 ether);
}
​
function _expectedURI(string memory name, uint256 age, string memory image) internal pure returns (string memory) {
return string(
Base64.encode(
abi.encodePacked(
'{"name":"',
name,
'", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(age),
"}], ",
'"image":"',
image,
'"}'
)
)
);
}
​
/// Baseline: the only participation gate is holding a profile NFT.
function test_baseline_noProfileCannotLike() public {
vm.prank(adult);
nft.mintProfile("Alice", 30, "ipfs://alice");
​
vm.prank(noProfile);
vm.expectRevert(bytes("Must have a profile NFT"));
registry.likeUser{value: 1 ether}(adult);
}
​
/// mintProfile accepts age 15 (minor), age 0, age 255, and empty name/image.
function test_mintAcceptsUnderageAndBlankProfiles() public {
// Under-age profile with empty name and image: succeeds, event carries age 15.
vm.expectEmit(true, false, false, true, address(nft));
emit ProfileMinted(minor, 1, "", 15, "");
vm.prank(minor);
nft.mintProfile("", 15, "");
assertEq(nft.profileToToken(minor), 1, "minor profile minted");
assertEq(nft.ownerOf(1), minor);
​
// Age 0 with blank fields also accepted.
vm.prank(blank);
nft.mintProfile("", 0, "");
assertEq(nft.profileToToken(blank), 2, "age-0 blank profile minted");
​
// Age 255 (nonsensical upper bound) also accepted.
vm.prank(ageMax);
nft.mintProfile("Old", 255, "ipfs://x");
assertEq(nft.profileToToken(ageMax), 3, "age-255 profile minted");
​
// Protocol-generated metadata publishes the stored values verbatim.
assertEq(nft.tokenURI(1), _expectedURI("", 15, ""), "tokenURI publishes Age 15 and blank name/image");
assertEq(nft.tokenURI(2), _expectedURI("", 0, ""), "tokenURI publishes Age 0");
assertEq(nft.tokenURI(3), _expectedURI("Old", 255, "ipfs://x"), "tokenURI publishes Age 255");
console.log("tokenURI(1) payload (base64 of JSON with Age 15, empty name/image):");
console.log(nft.tokenURI(1));
}
​
/// The under-age, blank profile is a full participant: it likes, is liked,
/// matches, and co-owns the deployed 2-of-2 MultiSigWallet.
function test_underageBlankProfileLikesMatchesAndCoOwnsMultisig() public {
vm.prank(minor);
nft.mintProfile("", 15, "");
vm.prank(adult);
nft.mintProfile("Alice", 30, "ipfs://alice");
​
// Minor pays 1 ETH to like the adult: accepted.
vm.prank(minor);
registry.likeUser{value: 1 ether}(adult);
assertTrue(registry.likes(minor, adult), "minor's like recorded");
​
// Adult likes back: match completes, multisig deployed.
address expectedWallet = vm.computeCreateAddress(address(registry), vm.getNonce(address(registry)));
assertEq(expectedWallet.code.length, 0, "no wallet before match");
​
vm.expectEmit(true, true, false, false, address(registry));
emit Matched(adult, minor);
vm.prank(adult);
registry.likeUser{value: 1 ether}(minor);
​
// Matches recorded for both sides.
vm.prank(minor);
address[] memory minorMatches = registry.getMatches();
assertEq(minorMatches.length, 1);
assertEq(minorMatches[0], adult, "minor matched with adult");
vm.prank(adult);
address[] memory adultMatches = registry.getMatches();
assertEq(adultMatches.length, 1);
assertEq(adultMatches[0], minor, "adult matched with minor");
​
// A MultiSigWallet co-owned by the under-age profile now exists.
assertGt(expectedWallet.code.length, 0, "match multisig deployed");
MultiSigWallet wallet = MultiSigWallet(payable(expectedWallet));
assertEq(wallet.owner1(), minor, "multisig owner1 is the age-15 profile");
assertEq(wallet.owner2(), adult, "multisig owner2 is the adult");
​
// Registry holds both like payments (2 ETH) - no age check anywhere in the flow.
assertEq(address(registry).balance, 2 ether);
console.log("minor profile age in tokenURI: 15; multisig:", expectedWallet);
}
}

Run from the repository root, with the file saved as test/PoC_DD-10.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_DD-10.t.sol -vv

Result: Suite result: ok. 3 passed; 0 failed; 0 skipped.

Recommended Mitigation

Validate the fields at mint. Because the age is self-declared, the age bound is a sanity check, not identity verification, which has to happen off-chain.

--- a/src/SoulboundProfileNFT.sol
+++ b/src/SoulboundProfileNFT.sol
@@ -29,6 +29,9 @@
/// @notice Mint a soulbound NFT representing the user's profile.
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
+ require(age >= 18 && age <= 120, "Invalid age");
+ require(bytes(name).length > 0, "Name required");
+ require(bytes(profileImage).length > 0, "Image required");
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!