Puppy Raffle

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

PuppyRaffle::tokenURI inserts the rarity name without JSON quotes, so every minted puppy's metadata is invalid JSON and its name, image and rarity cannot be parsed

Root + Impact

Description

  • tokenURI should return a data:application/json;base64, URI whose decoded body is valid ERC721 metadata JSON (name, description, attributes, image). Wallets, marketplaces and indexers parse this JSON to show the puppy's image and rarity trait.

  • tokenURI builds the metadata by concatenating string fragments with abi.encodePacked and places rareName between "value": and } with no surrounding quote characters. rareName is always the bare word common, rare or legendary (L40/45/50, assigned at L70-72), so the decoded document contains "value": common}, which is not a valid JSON value. A strict JSON parser therefore rejects the entire document, not just the rarity trait.

// src/PuppyRaffle.sol L189-L215
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
require(_exists(tokenId), "PuppyRaffle: URI query for nonexistent token");
​
uint256 rarity = tokenIdToRarity[tokenId];
string memory imageURI = rarityToUri[rarity];
string memory rareName = rarityToName[rarity];
​
return string(
abi.encodePacked(
_baseURI(),
Base64.encode(
bytes(
abi.encodePacked(
'{"name":"',
name(),
'", "description":"An adorable puppy!", ',
'"attributes": [{"trait_type": "rarity", "value": ', // @> no opening quote before the value
rareName, // @> bare word: common / rare / legendary
'}], "image":"', // @> no closing quote after the value
imageURI,
'"}'
)
)
)
)
);
}

Risk

Likelihood:

  • Every successful selectWinner mints a token through _safeMint(winner, tokenId) (L153), and every such token gets one of the three rarities, all of which map to an unquoted bare word.

  • The malformed output is returned deterministically on every tokenURI call for every existing token; no attacker, special role, timing or input is required.

  • The PoC reproduces it for all three rarities (common, rare, legendary) through the normal enterRaffle / selectWinner flow.

Impact:

  • Consumers that parse the metadata with a strict JSON parser reject the whole document, so the puppy's name, image and rarity trait cannot be read or displayed for 100% of minted tokens; the NFT is the prize the README advertises ("win a cute dog NFT").

  • No ETH is at risk: prize payout, fees, token ownership, transfers and the on-chain tokenIdToRarity mapping are unaffected. Only off-chain reading of tokenURI breaks, which is why Impact is Low.

Proof of Concept

The PoC (test/PoC_PR-09.t.sol) runs three normal raffle rounds, choosing callers so the contract's own rarity formula mints one common, one rare and one legendary puppy. For each token it base64-decodes tokenURI and asserts that the JSON contains the bare "value": <word>}, is byte-identical to the contract's payload rebuilt without quotes (2 bytes shorter than the quoted version), and is rejected by Foundry's strict vm.parseJson; .name and .image also cannot be extracted. A baseline test shows the same document with the rarity value quoted parses and returns the expected name, image and "common". Run it with forge test --match-path test/PoC_PR-09.t.sol -vv.

File test/PoC_PR-09.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_PR-09.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
​
import {Test, console} from "forge-std/Test.sol";
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
import {Base64} from "lib/base64/base64.sol";
​
/// PR-09: tokenURI (L191-L215) builds the ERC721 metadata JSON by concatenation and inserts the
/// rarity name WITHOUT quotes (L205-L207):
/// '"attributes": [{"trait_type": "rarity", "value": ', rareName, '}], "image":"'
/// rareName is the bare word common / rare / legendary (L40/45/50, L70-72), so the decoded
/// payload contains `"value": common}` which is not valid JSON. A strict JSON parser (here:
/// Foundry's serde_json-backed vm.parseJson*) rejects the WHOLE document, so name, image and
/// rarity cannot be read for any minted puppy.
contract PoC_PR09 is Test {
PuppyRaffle raffle;
uint256 constant FEE = 1e18;
uint256 constant DURATION = 1 days;
string constant PREFIX = "data:application/json;base64,";
​
string constant COMMON_URI = "ipfs://QmSsYRx3LpDAb1GZQm7zZ1AuHZjfbPkD6J7s9r41xu1mf8";
string constant RARE_URI = "ipfs://QmUPjADFGEKmfohdTaNcWhp7VGk26h5jXDA7v3VtTnTLcW";
string constant LEGENDARY_URI = "ipfs://QmYx6GsYAKnNzZ9A6NvEKV9nf1VaDzJrqDR23Y8YSkebLU";
​
uint160 nextAddr = 0x100000; // fresh, code-less player addresses
​
function setUp() public {
raffle = new PuppyRaffle(FEE, address(0xFEE), DURATION);
vm.deal(address(this), 1000 ether);
}
​
// ---------------------------------------------------------------- helpers
​
/// Runs one full raffle round (4 fresh EOAs enter, time passes, `caller` draws) and
/// returns the id of the puppy that selectWinner minted.
function _mintVia(address caller) internal returns (uint256 tokenId) {
address[] memory p = new address[](4);
for (uint256 i = 0; i < 4; i++) {
p[i] = address(nextAddr++);
}
raffle.enterRaffle{value: FEE * 4}(p);
vm.warp(block.timestamp + DURATION + 1);
tokenId = raffle.totalSupply();
vm.prank(caller);
raffle.selectWinner();
assertEq(raffle.totalSupply(), tokenId + 1, "selectWinner must mint exactly one puppy");
}
​
/// Finds a caller whose selectWinner draw yields `wanted` rarity (same formula as L139-146).
function _callerFor(uint256 wanted) internal view returns (address) {
for (uint160 s = 0xC0FFEE; s < 0xC0FFEE + 5000; s++) {
uint256 r = uint256(keccak256(abi.encodePacked(address(s), block.difficulty))) % 100;
uint256 got;
if (r <= 70) got = 70;
else if (r <= 95) got = 25;
else got = 5;
if (got == wanted) return address(s);
}
revert("no caller found");
}
​
/// Strips the data-URI prefix and base64-decodes what tokenURI returned.
function _decodedMetadata(uint256 tokenId) internal view returns (string memory) {
bytes memory uri = bytes(raffle.tokenURI(tokenId));
bytes memory pre = bytes(PREFIX);
require(uri.length > pre.length, "uri too short");
for (uint256 i = 0; i < pre.length; i++) {
require(uri[i] == pre[i], "unexpected uri prefix");
}
bytes memory b64 = new bytes(uri.length - pre.length);
for (uint256 i = 0; i < b64.length; i++) {
b64[i] = uri[i + pre.length];
}
return string(Base64.decode(string(b64)));
}
​
function _contains(string memory hay, string memory needle) internal pure returns (bool) {
bytes memory h = bytes(hay);
bytes memory n = bytes(needle);
if (n.length > h.length) return false;
for (uint256 i = 0; i + n.length <= h.length; i++) {
bool ok = true;
for (uint256 j = 0; j < n.length; j++) {
if (h[i + j] != n[j]) {
ok = false;
break;
}
}
if (ok) return true;
}
return false;
}
​
/// The exact payload the contract emits (L201-L210), parameterised on whether the rarity
/// value is quoted (the fix) or bare (the shipped code).
function _payload(string memory rareName, string memory image, bool quoted) internal pure returns (string memory) {
string memory q = quoted ? '"' : "";
return string(
abi.encodePacked(
'{"name":"',
"Puppy Raffle",
'", "description":"An adorable puppy!", ',
'"attributes": [{"trait_type": "rarity", "value": ',
q,
rareName,
q,
'}], "image":"',
image,
'"}'
)
);
}
​
function _parses(string memory json) internal view returns (bool) {
try vm.parseJson(json) returns (bytes memory) {
return true;
} catch {
return false;
}
}
​
function _imageReadable(string memory json) internal view returns (bool) {
try vm.parseJsonString(json, ".image") returns (string memory) {
return true;
} catch {
return false;
}
}
​
function _nameReadable(string memory json) internal view returns (bool) {
try vm.parseJsonString(json, ".name") returns (string memory) {
return true;
} catch {
return false;
}
}
​
// ---------------------------------------------------------------- baseline
​
/// Baseline: the parser is a working strict JSON parser. The SAME document with the rarity
/// value quoted (the one-character-pair fix) parses and yields name, image and rarity.
function test_baseline_quotedMetadataParses() public {
string memory fixedJson = _payload("common", COMMON_URI, true);
console.log("fixed :", fixedJson);
assertTrue(_parses(fixedJson), "quoted metadata should parse");
assertEq(vm.parseJsonString(fixedJson, ".name"), "Puppy Raffle");
assertEq(vm.parseJsonString(fixedJson, ".image"), COMMON_URI);
assertEq(vm.parseJsonString(fixedJson, ".attributes[0].value"), "common");
}
​
// ---------------------------------------------------------------- the break
​
/// Every puppy minted through the normal raffle flow, for each of the three rarities, has a
/// tokenURI whose decoded JSON contains a bare `"value": <word>}` and is rejected by a strict
/// JSON parser: neither the whole document nor `.name` / `.image` can be read.
function test_tokenURI_isInvalidJson_forEveryRarity() public {
uint256[3] memory rarities = [uint256(70), uint256(25), uint256(5)];
string[3] memory names = ["common", "rare", "legendary"];
string[3] memory images = [COMMON_URI, RARE_URI, LEGENDARY_URI];
​
for (uint256 k = 0; k < 3; k++) {
uint256 id = _mintVia(_callerFor(rarities[k]));
assertEq(raffle.tokenIdToRarity(id), rarities[k], "rarity drawn as targeted");
​
string memory json = _decodedMetadata(id);
console.log("token id", id);
console.log("decoded:", json);
​
// Root cause: the rarity word is emitted unquoted.
string memory bare = string(abi.encodePacked('"value": ', names[k], "}"));
string memory quotedV = string(abi.encodePacked('"value": "', names[k], '"}'));
assertTrue(_contains(json, bare), "decoded metadata contains bare rarity token");
assertFalse(_contains(json, quotedV), "rarity value is not quoted");
​
// The on-chain output is byte-identical to the bare variant, and differs from the
// valid document only by the two missing quote characters.
assertEq(json, _payload(names[k], images[k], false), "tokenURI == bare payload");
assertEq(bytes(_payload(names[k], images[k], true)).length, bytes(json).length + 2);
assertTrue(_parses(_payload(names[k], images[k], true)), "fixed variant parses");
​
// Impact: a strict JSON parser rejects the real metadata entirely.
assertFalse(_parses(json), "tokenURI metadata must fail to parse");
assertFalse(_nameReadable(json), ".name unreadable");
assertFalse(_imageReadable(json), ".image unreadable");
}
assertEq(raffle.totalSupply(), 3, "three puppies minted, all with broken metadata");
}
}

Recommended Mitigation

abi.encodePacked(
'{"name":"',
name(),
'", "description":"An adorable puppy!", ',
- '"attributes": [{"trait_type": "rarity", "value": ',
+ '"attributes": [{"trait_type": "rarity", "value": "',
rareName,
- '}], "image":"',
+ '"}], "image":"',
imageURI,
'"}'
)
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!