security: gate verify_collection behind the collection's original creator - #693
Conversation
…ator verify_collection has no caller-authorization check in either implementation (anchor or pinocchio). Both require some signer, but neither checks that signer's identity against anything. The actual CPI-signing authority is a global `[b"authority"]` PDA, which the program derives and signs with unconditionally for whoever calls the instruction -- and it's shared across every collection anyone ever creates through this program. Since create_collection is also fully permissionless, and mint_nft lets anyone mint an NFT with an unverified `collection` field pointing at any collection_mint, an attacker can: mint their own throwaway NFT claiming membership in someone else's real collection, then call verify_collection themselves, supplying the victim's public collection accounts. The program derives the same global PDA (genuinely that collection's real update authority) and signs -- Metaplex's own checks pass, since from its perspective the signer is exactly right. The attacker's fake NFT is now permanently verified as an authentic member of the victim's collection, a real NFT-marketplace forgery primitive since most marketplaces and wallets only trust the verified badge. Scoping the *signing* PDA per collection would not fix this: PDA derivation is deterministic and public, so an attacker supplying the victim's real collection_mint would still cause the program to derive and sign with that collection's genuine authority. The fix has to check the *caller's* identity. Adds a `collection_authority` PDA (seeded by collection_mint) recording the collection's actual creator at create_collection time, and checks it in verify_collection. create_collection stays fully permissionless; the new account only gates verifying someone else's collection. Pinocchio has no Anchor-style automatic owner/length checks, so those are added explicitly before trusting the account's data. Evidence this was a genuine, unnoticed gap rather than a documented limitation: the anchor README only vaguely hints "authority... can be used to restrict... by adding constraints" without adding any or stating the consequence, and a pinocchio test comment shows the author's actual model was that Metaplex's own checks were sufficient. Neither test suite ever called verify_collection with a different signer than the collection's creator -- zero coverage of the vulnerable path. Verified for both implementations: reverted just the authorization check (kept the new account plumbing), rebuilt, confirmed an outsider's verify_collection call succeeds against the unpatched code; reapplied and confirmed it's rejected with the intended Unauthorized error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Greptile SummaryThe PR closes an NFT collection-verification forgery path by recording each collection's creator in a per-collection PDA and requiring that creator to authorize verification.
Confidence Score: 5/5The PR appears safe to merge and correctly gates collection verification in both implementations. The new per-collection authority state is initialized from the creator, canonically derived during verification, and checked before the program signs the Metaplex CPI; corresponding authorized and unauthorized paths are covered by updated tests. Important Files Changed
Reviews (1): Last reviewed commit: "security: gate verify_collection behind ..." | Re-trigger Greptile |
|
Hey @dev-jodee — this is ready for review whenever you get a chance. CI is green for both the anchor and pinocchio builds, and Greptile's automated pass found no blocking issues. |
Summary
tokens/nft-operationshas an NFT-collection-verification forgery vulnerability, presentidentically in both the
anchorandpinocchioimplementations:verify_collectionCPIs into Metaplex's
VerifyCollectionV1/Verifyinstruction, signed by this program's[b"authority"]PDA — the same PDAcreate_collectionsets as the collection's Metaplexupdate_authority. Neither implementation checks who is allowed to callverify_collection. Anchor requiresauthority: Signer<'info>but never compares it toanything; Pinocchio checks only
payer.is_signer(). The PDA itself is global (fixed seed, noper-collection component) and signs unconditionally for whoever invokes the instruction.
The attack
create_collectionis fully permissionless, andmint_nftlets anyone mint an NFT with anunverified
collectionfield pointing at anycollection_mint(freely settable — anunverified claim requires no permission). So:
mint_nft, claiming membership in a victim's real,already-created collection.
verify_collection, supplying the victim's public collection accounts andtheir own throwaway signer.
update_authority) and signs — Metaplex's own checks pass, since from its perspective thesigner is exactly right. It has no way to know an unrelated caller triggered this.
verified: trueas an authentic member of thevictim's collection — a real NFT-marketplace forgery primitive, since most marketplaces
and wallets only trust the verified badge.
Scoping the signing PDA per collection would not fix this — I checked this carefully
before implementing. PDA derivation is deterministic and public: an attacker supplying the
victim's real
collection_mintwould still cause the program to derive and sign with thatexact collection's genuine authority. The fix has to be an explicit check on the caller's
identity, not a change to what signs.
Why this went unnoticed
tokens/nft-operations/anchor/readme.MD) describesauthorityas:"signer of the transaction. This can be used to restrict the address that can execute the
verify collection method, by adding constraints" — a vague hint that constraints could
be added, but none actually are, and the concrete consequence is never spelled out. Much
softer than this repo's
compression/cnft-vaultREADME, which explicitly and prominentlydiscloses its own analogous gap as an intentional proof-of-concept limitation — I checked
and ruled that one out as already-disclosed; this one reads differently.
"Metaplex
Verifyperforms strict checks: the signer must be the collection's updateauthority (our PDA)... A successful transaction therefore proves the whole flow is
correct." This shows the gap wasn't a deliberate simplification — Metaplex's own checks
really are correct, but they say nothing about who invoked this program's wrapping
instruction.
anchor/tests/litesvm.test.ts,pinocchio/tests/test.ts) ever calledverify_collectionwith a different signer than the one who created the collection — zerocoverage of the vulnerable path.
Fix
Adds a
collection_authorityPDA (seeded bycollection_mint) recording the collection'sactual creator at
create_collectiontime, checked inverify_collection.create_collectionstays fully permissionless — the new account only gates verifyingsomeone else's collection. Per-collection scoping is necessary, not just stylistic: a
global tracking account would just relocate the bug (whoever creates the first collection
would become "the" authorized verifier for every collection).
state.rs(CollectionAuthority { creator: Pubkey }) anderrors.rs(
MintNftError::Unauthorized), wired in viainit/seedsconstraints on both contexts.stores just the raw 32-byte creator pubkey (no discriminator — matches the existing
precedent in
basics/favorites/pinocchio, safe since the account only ever exists at itsderived address if this program's own
create_collectioncreated it there). SincePinocchio gives none of Anchor's automatic owner/length checks,
verify_collectionexplicitly verifies
collection_authority.owner() == program_idand the correct datalength before trusting its contents — dropping that check is exactly the class of gap a
prior round in this series (security: add missing authorization checks in close-account and transfer-sol #667) found in other Pinocchio ports.
Test changes
Both test suites gained a negative test: a second, unrelated signer attempts
verify_collectionon the first signer's collection.Unauthorized.InstructionErrorCustom { code: 1 }and the pairedon-chain log message, not just "the transaction failed" — confirmed empirically what
LiteSVM's error/log shapes actually look like before writing the assertion, rather than
assuming.
Verified for both implementations: reverted just the authorization check (kept the new
account plumbing so it still compiles), rebuilt, confirmed an outsider's
verify_collectioncall succeeds against the unpatched code, reapplied, confirmed it's rejected.
Out of scope
mint_nft's unrestrictedcollection_mintparameter — safe by construction, since anunverified claim grants no privilege on its own.
metadata/master_edition/collection_metadata— thisexample consistently leans on Metaplex's own instruction processors to re-derive and
validate these against the mint; not a gap this fix needs to touch.
restrict..." line will go stale) — worth a follow-up docs-only edit, not done here.
Verification
anchor build --ignore-keys, fulllitesvm.test.tssuite: 4/4 passing.cargo build-sbf+ pinocchio test suite: 4/4 passing.cargo clippy -- -D warningsandcargo fmt --checkclean for the Pinocchio crate (a rootworkspace member, unlike the Anchor crate which is
.workspace-ignored).tsc --noEmitand rootprettier --checkclean for both.🤖 Generated with Claude Code