56810 sc medium accesstoken cross contract signature replay allows unauthorized minting on other collections

Submitted on Oct 20th 2025 at 21:18:48 UTC by @Queerantagonism for Audit Comp | Belongarrow-up-right

  • Report ID: #56810

  • Report Type: Smart Contract

  • Report severity: Medium

  • Target: https://github.com/belongnet/checkin-contracts/blob/main/contracts/v2/tokens/AccessToken.sol

  • Impacts:

    • Unauthorized minting of NFTs

Description

  • Primary affected files:

    • contracts/v2/tokens/AccessToken.sol

    • contracts/v2/utils/SignatureVerifier.sol (root cause)

Mint signatures used by AccessToken (both static and dynamic price flows) are not bound to the target collection contract.

The SignatureVerifier digests include only the chainId and payload fields, but omit the collection’s verifyingContract (address(this)) and lack nonce/deadline/used-hash replay protection.

As a result, any signature issued for Collection A by the backend signer can be replayed to mint on Collection B (sharing the same signer on the same chain), enabling unauthorized minting on the wrong collection.

Impact

  • Unauthorized minting of NFTs on collections that never authorized the mint.

  • Circumvents backend curation and approvals.

  • Mints NFTs in unintended collections, diluting supply and confusing provenance.

  • Misattributes revenue (fees routed per the receiving collection, not the intended one).

Severity rationale: The bug allows minting NFTs in a collection that never authorized the mint; this is realistic when a platform reuses the same backend signer across collections and signatures are leaked or reused.

What Makes This Exploitable?

  • Factory configurations commonly use a single signer for multiple collections.

  • AccessToken.validateSignature checks only Signer + chainId (via SignatureVerifier), not the collection address.

  • No nonce, deadline, or used-hash prevents replay.

  • The digest does not include address(this); a signature valid for A remains valid for B if both share the same backend signer and chainId. Because there is no nonce/deadline, it can be replayed indefinitely.

Root Cause Analysis

Signature binding misses verifyingContract and replay controls.

In contracts/v2/utils/SignatureVerifier.sol:

  • Static mints: checkStaticPriceParameters hashes: keccak256(abi.encodePacked( receiver, tokenId, tokenUri, whitelisted, block.chainid )) Missing: address(this), nonce, deadline, and used-digest tracking.

  • Dynamic mints: checkDynamicPriceParameters hashes: keccak256(abi.encodePacked( receiver, tokenId, tokenUri, price, block.chainid )) Missing: address(this), nonce, deadline, and used-digest tracking.

In contracts/v2/tokens/AccessToken.sol:

  • mintStaticPrice and mintDynamicPrice call the SignatureVerifier checks above and then mint. There is no additional binding to address(this) or replay guard.

Because the digest does not include the collection address, a signature valid for A remains valid for B if both share the same backend signer and chainId. Additionally, because there is no nonce/deadline, it can be replayed indefinitely.

Attack Scenario

  1. Platform deploys two collections A and B using the same signer S (common with a shared backend).

  2. Backend signs a mint payload for A (static or dynamic).

  3. An attacker or legitimate minter calls AccessTokenB.mint*(…) providing that same signature.

  4. Signature passes (signer + chainId), NFT is minted on B, even though B never authorized it.

Expected: A signature produced for a specific collection can only be used on that collection, once, before its deadline. Actual: The same signature can be re-used on any other collection with the same signer (and many times), enabling unauthorized mints.

Proof of Concept

Run Commands

yarn hardhat clean --config ./hardhat.poc.config.ts && yarn hardhat compile --config ./hardhat.poc.config.ts && yarn hardhat test --config ./hardhat.poc.config.ts --grep "AccessToken cross-contract signature replay" test/poc_access_token_replay.spec.ts

The test passes with both cases:

  • colB.mintStaticPrice succeeds using a signature “for A” ⇒ unauthorized mint on B.

  • colA.mintStaticPrice also succeeds with the same signature.

Remediation

  • Switch to EIP-712 typed data:

    • Domain: { name, version, chainId, verifyingContract }

    • Types: MintStatic / MintDynamic with nonce & deadline

    • Verify against EIP-712 digest and mark usedDigest.

  • Replace abi.encodePacked with abi.encode (or hash dynamic fields) to avoid encoding ambiguity.

  • Include verifyingContract (address(this)) in the digest and implement replay protections (nonce, deadline, used-digest tracking).

Regression Tests to Add

  • Negative: Signature for A must fail on B (binding to verifyingContract).

  • Negative: Reuse of the same signature on A must fail (usedDigest consumed).

  • Negative: Expired signature must fail (deadline in the past).

  • Positive: Fresh signature for A must succeed once.

Was this helpful?