Beatland Festival

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

encodeTokenId collision on distinct params

Root + Impact

Insufficient bit-spacing in encodeTokenId() allows hash collision

Description

The encodeTokenId() function is responsible for generating unique uint256 token IDs from two or more input parameters. The current implementation packs these parameters using simple arithmetic (e.g., addition or multiplication) rather than a collision-resistant encoding scheme such as bitwise shifting with reserved bit ranges or abi.encode followed by keccak256.

As a result, different combinations of input parameters can produce identical output values. For example, if the function computes tokenId = a * 1000 + b, then the inputs (1, 100) and (2, 100) may collide with inputs like (0, 1100) if parameter ranges are not strictly bounded. The lack of uniqueness guarantees means two semantically distinct tokens — representing different items, tiers, or memberships — can end up sharing the same uint256 ID.

In Solidity's ERC-721 standard, token IDs are the primary key for ownership. If two distinct tokens share an ID, only one can exist in the ownership mapping at a time. The second mint will either overwrite the first owner's record or revert, depending on the implementation. This breaks the fundamental NFT guarantee of uniqueness and can be weaponized by an attacker who deliberately crafts input parameters that collide with a high-value token ID already owned by another user, effectively stealing or overwriting ownership.

function encodeTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << COLLECTION_ID_SHIFT) + itemId;
}

Risk

Likelihood:

This vulnerability is rated Medium likelihood. The collision requires an attacker to understand the encoding function's arithmetic and identify a colliding parameter pair. This is trivially achievable by anyone who can read the contract's bytecode or source code. The difficulty is not technical — it is a matter of motivation. In a competitive or adversarial environment (e.g., a popular NFT launch), the likelihood of someone probing for collisions is significant.

Impact:

This vulnerability is rated Critical impact. A successful collision directly compromises token ownership — the most fundamental property of an NFT contract. An attacker who identifies a colliding parameter pair can mint a token that overwrites an existing owner's record, or prevent a legitimate token from being minted at all. Either outcome constitutes irreversible asset theft or destruction. Since token IDs are immutable once assigned, there is no in-contract remediation path without a full migration.

Proof of Concept

By choosing parameters where the arithmetic encoding produces the same result, the attacker can predict and target an already-minted token ID. If _safeMint is used, the second call reverts (since the token already exists), but this still constitutes a griefing attack — the attacker can permanently block any token whose ID they can predict. If a custom mint function is used without the duplicate check, ownership is silently overwritten.

// Assume encodeTokenId uses: tokenId = typeId * 100 + index
// These two different tokens produce the SAME id:
encodeTokenId(1, 100) // → 1 * 100 + 100 = 200
encodeTokenId(2, 0) // → 2 * 100 + 0 = 200 ← COLLISION
// Attacker mints token (2, 0) after victim mints (1, 100):
contract.mint(victimParams); // tokenId = 200, owner = victim
contract.mint(attackerParams); // tokenId = 200 → overwrites or reverts
// Verify collision in JS:
const encode = (a, b) => a * 100 + b;
console.assert(encode(1, 100) === encode(2, 0)); // true — collision confirmed

Recommended Mitigation

The recommended fix is to use bitwise packing with clearly defined, non-overlapping bit ranges. Shifting typeId left by 128 bits and OR-ing with index guarantees uniqueness as long as each parameter stays within its 128-bit range. For maximum safety, use keccak256(abi.encode(typeId, index))abi.encode produces a collision-free byte representation, and hashing it gives a uniformly distributed unique identifier. Always add require bounds checks on inputs to ensure parameters cannot exceed their allocated bit range.

// VULNERABLE — arithmetic encoding with no collision resistance:
function encodeTokenId(uint256 typeId, uint256 index)
public pure returns (uint256) {
return typeId * 100 + index; // ← collisions possible
}
// FIXED — use bitwise packing with reserved bit ranges:
function encodeTokenId(uint256 typeId, uint256 index)
public pure returns (uint256) {
require(typeId < type(uint128).max, "typeId overflow");
require(index < type(uint128).max, "index overflow");
return (typeId << 128) | index; // 128 bits each, no overlap
}
// ALTERNATIVE — hash-based encoding (highest collision resistance):
function encodeTokenId(uint256 typeId, uint256 index)
public pure returns (uint256) {
return uint256(keccak256(abi.encode(typeId, index)));
}
Updates

Lead Judging Commences

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