DatingDapp

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

SoulboundProfileNFT::tokenURI prefixes the empty default _baseURI() instead of "data:application/json;base64,", so every profile's tokenURI is a schemeless base64 string that standard ERC721 metadata consumers cannot resolve

Root + Impact

Description

  • tokenURI should return a URI that resolves to the token's metadata JSON. For JSON built on-chain, that is a data URI: data:application/json;base64, followed by the Base64 of the JSON.

  • tokenURI (L87-L107) returns abi.encodePacked(_baseURI(), Base64.encode(json)). SoulboundProfileNFT does not override _baseURI(), and in the pinned OpenZeppelin 5.0.2 it returns "" (ERC721.sol L100-L102). The returned value is therefore the bare Base64 of the JSON, for example eyJuYW1lIjoi.... It has no data: prefix and no : anywhere, so it carries no URI scheme of any kind. Standard ERC721 metadata consumers, such as wallets and explorers, get a schemeless relative reference that does not resolve to the metadata.

// src/SoulboundProfileNFT.sol L87-L90: the JSON's Base64 is prefixed with _baseURI(), which is not overridden
return string(
abi.encodePacked(
@> _baseURI(),
Base64.encode(
​
// lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol L100-L102 (v5.0.2): the inherited default
@> function _baseURI() internal view virtual returns (string memory) {
@> return "";
@> }

Risk

Likelihood:

  • Every minted profile is affected. The PoC shows the same schemeless output for four different profiles.

  • The harm appears when a standard ERC721 metadata consumer is the display path for a profile. The token is soulbound, so marketplace display matters little, and no in-scope contract reads tokenURI.

Impact:

  • Standard consumers receive a relative reference instead of a data URI, so no profile's name, age or image is rendered through the ERC721 metadata interface.

  • The data can still be recovered by base64-decoding the string by hand or by reading the ProfileMinted event. No funds are at risk. The IERC721Metadata interface is implemented; only the value it returns is malformed.

Proof of Concept

The PoC mints a profile ("Alice", 25, "ipfs://QmAliceImage") and rebuilds the contract's exact JSON in the test. It then asserts that tokenURI(1):

  • equals Base64.encode(json) byte for byte;

  • differs from "data:application/json;base64," + Base64.encode(json);

  • does not start with data:;

  • contains no : at all, so it carries no URI scheme of any kind;

  • starts with eyJ, the Base64 of {".

A second test shows the same for three more profiles.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
​
import {Test, console} from "forge-std/Test.sol";
import {SoulboundProfileNFT} from "../src/SoulboundProfileNFT.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
​
contract PoC_DD05 is Test {
SoulboundProfileNFT nft;
address alice = makeAddr("alice");
​
string constant PREFIX = "data:application/json;base64,";
​
function setUp() public {
nft = new SoulboundProfileNFT();
}
​
function _expectedJson(string memory name, uint8 age, string memory image) internal pure returns (bytes memory) {
return abi.encodePacked(
'{"name":"',
name,
'", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(age),
"}], ",
'"image":"',
image,
'"}'
);
}
​
function _startsWith(bytes memory s, bytes memory p) internal pure returns (bool) {
if (s.length < p.length) return false;
for (uint256 i; i < p.length; i++) {
if (s[i] != p[i]) return false;
}
return true;
}
​
function _contains(bytes memory s, bytes1 c) internal pure returns (bool) {
for (uint256 i; i < s.length; i++) {
if (s[i] == c) return true;
}
return false;
}
​
function test_DD05_tokenURI_isBareBase64_withoutDataURIScheme() public {
string memory name = "Alice";
uint8 age = 25;
string memory image = "ipfs://QmAliceImage";
​
vm.prank(alice);
nft.mintProfile(name, age, image);
uint256 tokenId = nft.profileToToken(alice);
assertEq(tokenId, 1, "profile minted");
​
string memory uri = nft.tokenURI(tokenId);
bytes memory uriBytes = bytes(uri);
console.log("tokenURI(1) =", uri);
​
// What the contract actually returns: the bare base64 of the JSON, nothing in front of it.
string memory bareBase64 = Base64.encode(_expectedJson(name, age, image));
assertEq(uri, bareBase64, "tokenURI is exactly Base64(json) with an empty _baseURI() prefix");
​
// What an ERC721 metadata consumer needs: a data URI carrying the JSON.
string memory wellFormed = string(abi.encodePacked(PREFIX, bareBase64));
console.log("expected data URI =", wellFormed);
assertTrue(keccak256(uriBytes) != keccak256(bytes(wellFormed)), "tokenURI is NOT the data: URI");
assertFalse(_startsWith(uriBytes, bytes("data:")), "no 'data:' scheme");
assertFalse(_startsWith(uriBytes, bytes(PREFIX)), "no 'data:application/json;base64,' prefix");
​
// A URI scheme requires a ':' delimiter. The base64 alphabet never contains ':',
// so the returned value cannot carry ANY scheme (data:, ipfs:, https:, ...).
assertFalse(_contains(uriBytes, bytes1(":")), "no ':' anywhere -> schemeless string");
assertTrue(_startsWith(uriBytes, bytes("eyJ")), "starts with base64 of '{\"' (raw JSON blob)");
}
​
function test_DD05_everyToken_isAffected() public {
address[3] memory users = [makeAddr("u1"), makeAddr("u2"), makeAddr("u3")];
string[3] memory names = ["Bob", "Carol", "Dave"];
for (uint256 i; i < 3; i++) {
vm.prank(users[i]);
nft.mintProfile(names[i], uint8(20 + i), "https://example.com/img.png");
uint256 id = nft.profileToToken(users[i]);
bytes memory u = bytes(nft.tokenURI(id));
assertFalse(_startsWith(u, bytes("data:")), "token lacks data: scheme");
assertFalse(_contains(u, bytes1(":")), "token URI is schemeless");
console.log("token", id, "schemeless: true");
}
}
}

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

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

Result: Suite result: ok. 2 passed; 0 failed; 0 skipped. The logs print tokenURI(1) = eyJuYW1lIjoiQWxpY2Ui... next to the expected data:application/json;base64,eyJuYW1lIjoiQWxpY2Ui....

Recommended Mitigation

Prefix the data-URI header, or override _baseURI() to return it.

--- a/src/SoulboundProfileNFT.sol
+++ b/src/SoulboundProfileNFT.sol
@@ -86,7 +86,7 @@
string memory imageURI = _profiles[tokenId].profileImage;
return string(
abi.encodePacked(
- _baseURI(),
+ "data:application/json;base64,",
Base64.encode(
bytes( // bytes casting actually unnecessary as 'abi.encodePacked()' returns a bytes
abi.encodePacked(
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!