Beatland Festival

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

Potential truncation and collectionId collision due to tokenId dxceeding the uint128 Limit

Potential truncation and collectionId collision due to tokenId dxceeding the uint128 Limit

Description

The protocol implements a bit-packing mechanism to generate unique tokenIDs for memorabilia NFTs through the FestivalPass.encodeTokenId function. This mechanism divides the capacity of a uint256 (256-bit) variable slot into two equal parts: the left 128-bits for collectionId (via a left shift << 128 operation) and the right 128-bits for itemId.

However, the data types of the parameter variables accepted by the encodeTokenId and decodeTokenId functions, as well as the state of their constituent variables (nextCollectionId and currentItemId), are declared using uint256.

@> function encodeTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << COLLECTION_ID_SHIFT) + itemId;
}
@> function decodeTokenId(uint256 tokenId) public pure returns (uint256 collectionId, uint256 itemId) {
collectionId = tokenId >> COLLECTION_ID_SHIFT;
itemId = uint256(uint128(tokenId));
}

If collectionId or itemId grows beyond its maximum bit space (2^128 - 1 or 0xffffffffffffffffffffffffffffffffff), it will either undergo silent truncation when shifted, or cause data corruption in the tokenId structure. For example, if collectionId reaches 2^128, the operation collectionId << 128 will drop the 129th bit and return a value of 0. As a result, the resulting tokenId will collide with data from collection ID 0 or other original collections.

Risk

Impact Medium

  • Token ID Collision: Collections with very large ID values ​​will have token IDs overlapping with collections with small values. This can disrupt view functions like FestivalPass.uri and FestivalPass.getMemorabiliaDetails.

  • While this is potentially fatal to NFT data integrity, the actual severity on mainnet is categorized as Medium, as it would require a massive 2^128$ collection creation to exceed this threshold.

  • Likelyhood Low
    -The maximum limit of uint128 (2^128 - 1$) is a very large number (+- 3.4 * 10^38). In practice, this limit is almost impossible to reach through normal execution on the blockchain within the project's lifetime, unless there is an ID manipulation function or a drastic counter jump.

PoC

function testFuzz_TruncationCollectionId(uint256 fuzzedCollectionId) public view {
// 1. BOUNDING: makesure input > uint128 until uint256 max
// e.g.: fuzzedCollectionId = MAX_UINT128 + 1 (0x100000000000000000000000000000000)
vm.assume(fuzzedCollectionId > type(uint128).max);
uint256 itemId = 1;
// 2. run encoding & decoding
uint256 tokenId = festivalPass.encodeTokenId(fuzzedCollectionId, itemId);
(uint256 decodedCollectionId, uint256 decodedItemId) = festivalPass.decodeTokenId(tokenId);
// 3. ASSERTION: proofing result of decode function is not the same as raw input
// because the real bit is truncated, decodedCollectionId will be wrong
console.log("fuzzedCollectionId", fuzzedCollectionId);
console.log("decodedCollectionId", decodedCollectionId);
assertNotEq(decodedCollectionId, fuzzedCollectionId);
// Item ID still sage because of masking uint128(tokenId) in decode function
assertEq(decodedItemId, itemId);
}
function testFuzz_TruncationItemId(uint256 fuzzedItemId) public view {
uint256 collectionId = 100;
uint256 shiftedCollection = collectionId << 128;
// 1. BOUNDING: make sure itemId greated than max uint128
vm.assume(fuzzedItemId > type(uint128).max);
vm.assume(fuzzedItemId < (type(uint256).max - shiftedCollection));
// 2. exec func
uint256 tokenId = festivalPass.encodeTokenId(collectionId, fuzzedItemId);
(uint256 decodedCollectionId, uint256 decodedItemId) = festivalPass.decodeTokenId(tokenId);
// 3. ASSERTION
// because exceed 128 bit, when encode run it will take collectionId bit space
console.log("decodedItemId", decodedItemId);
console.log("fuzzedItemId", fuzzedItemId);
assertNotEq(decodedItemId, fuzzedItemId);
// more worst case: collectionId will also broken
assertNotEq(decodedCollectionId, collectionId);
}

Recommended Mitigation

Use OpenZeppelin's SafeCast library to explicitly ensure that the input collectionId and itemId are below the maximum range of uint128 before performing the shift operation. If it exceeds the limit, the transaction will automatically revert.

+ import "@openzeppelin/contracts/utils/math/SafeCast.sol";
function encodeTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
+ uint128 safeCollectionId = SafeCast.toUint128(collectionId);
+ uint128 safeItemId = SafeCast.toUint128(itemId);
+ return (uint256(safeCollectionId) << COLLECTION_ID_SHIFT) + safeItemId;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours 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!