A verifier's one job is to never say Valid about a file that is not.
This document says what counts as a vulnerability here, how to report
one, and what this project has already found in itself.
A vulnerability is anything that makes this verifier report Valid
or Trusted for an asset that c2patool (the C2PA reference
implementation, pinned per milestone in docs/milestones.md) or the C2PA
specification would refuse — a tampered asset, a manifest that binds to
nothing, a signature that does not verify, a chain that reaches no
anchor — or anything that lets a crafted file make the verifier throw an
uncaught exception, exhaust memory or time, or read outside the file.
Not a vulnerability: a file this verifier refuses that c2patool
accepts (the verifier is allowed to be stricter, and every such case is
named in the drift alarms in tests/Pest.php and in
docs/comparison.md); a format or feature it does not support yet (it
says so with a general.error); a Valid verdict on a file whose
signer you do not trust — Valid without your anchors is what the
specification prescribes; Trusted needs your list.
Please do not open a public issue for a suspected wrong Valid. Send the
file (or a way to reproduce one) and what c2patool says about it to the
maintainer through the contact on the repository's profile; you will get
an acknowledgement within a week. A fix follows the project's own rule —
a spec amendment, a test that is red on the file, then the change — and
the report is credited in the note of that step unless you ask otherwise.
There is no bug bounty.
Advisories are published once the package has users outside this
project. Until then every finding is recorded here, under Findings so
far, and in the CHANGELOG of the release that fixes it, with the
versions it affected. The finding fixed in 0.5.1 was handled this way:
it was reported privately as a draft advisory, and that draft was closed
unpublished on 2026-10-08, when Packagist's downloads and the plugin
directory's installs showed no use beyond the project's own.
Verified: the manifest store's extraction (JPEG APP11, PNG caBX, the GIF
C2PA_GIF Application Extension, the RIFF C2PA chunk of WebP, WAV and AVI, the ID3v2 GEOB frame of MP3 and FLAC, and the
ISOBMFF uuid box of MP4, MOV, AVIF and HEIC, and, when the caller turns
text on, the C2PATextManifestWrapper of a plain text), JUMBF and
CBOR structure with bounds, claim v1/v2 syntax, the COSE_Sign1 signature
under the leaf certificate, the hashed URI of every assertion the claim
names, the hard binding over the asset — c2pa.hash.data and, for
ISOBMFF, c2pa.hash.bmff.v2 and .v3 including the Merkle tree of a
fragmented stream — the leaf certificate's C2PA profile, the chain to a
configured anchor, the RFC 3161 timestamp (signature, imprint, TSA chain)
and the validity window it supplies, the OCSP responses a signer staples
into its own signature, the actions assertion's opening rule, and the
manifests an ingredient assertion names — each validated in its own
right, update manifests included, with a fault reported against the
ingredient that brought it in.
Not verified (each refused or named, never silently accepted): a
manifest no ingredient assertion reaches (ignored, as C2PA 2.4
§15.11.3.3 directs, and rendered so the caller sees it is there), an
ingredient naming a manifest the store does not hold
(ingredient.unknownProvenance), CAWG identity assertions (refused),
c2pa.hash.boxes and c2pa.hash.collection.data (refused by name),
c2pa.hash.data.part / c2pa.hash.multi-asset (a second asset's hashes —
unread here as in c2patool), the content rules of assertions other than
the actions opening rule, and any revocation that needs the network —
an online OCSP query, an Authority Information Access fetch, a CRL. A
stapled response that cannot be read, cannot be verified, or is about
another certificate is reported as signingCredential.ocsp.skipped
rather than believed, and a file carrying none says so too. A remote
manifest declared by URL is reported, never fetched.
The 111 obligations of C2PA 2.4 that apply to the formats this verifier
reads are listed one by one in docs/conformance.md, with the 11 it does
not meet named. None of those 11 can make it report Valid about a file
whose bytes changed; that claim is set out there, per gap. It was made
once before and was wrong: see PRED-IMG-004 below.
The project keeps its own record. Thirty-three cases of a wrong Valid or
Trusted have been found in it, all by the maintainers, one by the
security review of a downstream module: two before any
release, one after 0.1.0, five, with eight ways to crash the verifier,
in the security review of 2026-09-25, fixed in 0.2.2, one fixed in
0.2.3, two, with four ways to exhaust the verifier, in a review of
2026-09-27, fixed in 0.2.4, four more from that review's lower
findings, fixed in 0.2.5, one found by reading the reference
engine's own fixes, fixed in 0.2.6, one found by a review of src/,
fixed in 0.2.8, one found by a review of the whole of src/ before
0.3.0, fixed in it, one reported privately, fixed in 0.5.1, one
found by the trust matrix, fixed in 0.5.2, five found by a review of
the whole code base before 0.8.0, fixed in it, two found by a fuzzer
for the manifest graph, fixed in 0.8.1, and six, with five ways to
crash the verifier and four to exhaust it, found by a security review of
the whole code before 0.10.0, fixed in it:
-
2026-09-22, no hard binding (
notes/step-47-no-hard-binding.md). A correctly signed manifest with noc2pa.hash.dataassertion — a manifest that binds to no pixels at all — wasValid, andTrustedwith its signer's root as an anchor, because the data-hash check ran only when that assertion's hashed URI matched and never when there was none.c2patoolrefuses such a file outright. No writer omits the binding, so 68 real files and 70 870 fuzzed ones never showed it; it was found by asking the code what it does when the assertion is absent. Closed the same day (SPEC-013 amendment 10) with a re-signed variant that is red without the fix. -
2026-09-22, no actions assertion (
notes/step-48-absence-audit.md,notes/step-49-actions-check.md). A 2.x manifest without an actions assertion, or one that does not open withc2pa.created/c2pa.opened, wasValid;c2patoolrefuses it. Found by the same method applied to every gate; closed with SPEC-018. -
2026-09-24, an exclusion wider than the store — present in
0.1.0, fixed after it (notes/step-108-exclusion-wider-than-store.md). The three officialtruepic-20230212-*test files exclude the file head, including the whole EXIF segment, in the same range as the manifest store. C2PA 2.4 says that range may hold only the store and padding. This verifier checked only that the range covers the store (SPEC-012 amendment 5), so a copy with its EXIF capture date changed is stillTrustedwith the signer's root as an anchor.c2patool0.27.22 agrees with this verifier;c2patool0.28.0 rejects the file, and its new rule is how this was found. It is present in0.1.0. Fixed the same day by SPEC-012 amendment 7 (step 109): an exclusion that holds any part of the store must hold nothing else. Over 864 runs, that changed the verdict of these three corpus files and no others. -
2026-09-25, a security review of the whole code base — present in
0.1.0to0.2.1, fixed in0.2.2. None of these needs a signing key. A wrong verdict:- an issuer in the chain walk was never checked to be a certificate
authority, so a signer under an anchor could issue a leaf on any name
and be
Trusted(step 148, SPEC-014 amendment 4); - an ingredient assertion of a manifest the graph never reaches could
cancel a real fault of one it does, so a changed asset could stay
ValidorTrusted(step 149, SPEC-021 amendment 6); - a standard manifest without a hard binding borrowed its parent's, with the exclusion widened, when any update manifest was in the store (step 150, SPEC-022 amendment 6);
- a fragment's Merkle location was not range-checked, so a withheld fragment could be replaced by a copy of another (step 151, SPEC-028 amendment 1);
- up to seven bytes after the last ISOBMFF box were not hashed (step 152, SPEC-027 amendment 4).
A crash, a hang or a corrupted report: an empty DER element in a timestamp token or an OCSP response (step 153); a very long INTEGER (step 154); and a CBOR memory bomb, unbounded ISOBMFF reads, a media type that is not UTF-8, OpenSSL warnings on standard output, input that cannot seek, and PHP stream wrappers in the command (step 155, SPEC-043). Each is described in its note under
notes/, with the test that was red before the fix. - an issuer in the chain walk was never checked to be a certificate
authority, so a signer under an anchor could issue a leaf on any name
and be
-
2026-09-25, a certificate time with a fraction — present in
0.1.0to0.2.2, fixed in0.2.3(notes/step-156-certificate-validity.md). The validity window came from PHP'sopenssl_x509_parse(), which misreads a time with a fraction of a second: a notAfter of20250101000000.5Zbecame 2500-12-31, so a certificate that expired on 2025-01-01 wasTrusted. Bothc2patoolversions call it expired. RFC 5280 forbids the fraction, so only a CA that breaks it can issue one. Found while measuring the verifier under php-wasm, where the same function also shifts the time by the host's timezone. Closed by SPEC-044: the validity is read from the certificate's own DER. -
2026-09-27, a review from the side of the first user — fixed in
0.2.4(notes/step-157-review-from-the-plugin.md). The WordPress plugin that bundles the library runs it on every upload, so the library was reviewed again before the plugin was published. Two wrong verdicts:- a manifest that nothing references, and so is never validated,
could declare an assertion of the active manifest redacted; with that
box removed, the file stayed
ValidorTrusted. Present in0.2.1to0.2.3. Bothc2patoolversions sayInvalid(step 158, SPEC-035 amendment 5); - under the legacy
trust.trust_anchors, which anchor time-stamping authorities as well as signers, a certificate whose only EKU is Time Stamping could sign aTrustedmanifest. Present in0.1.0to0.2.3. Bothc2patoolversions still sayTrusted; this verifier no longer does (step 159, SPEC-031 amendment 3).
Memory or time exhausted, or an exception: a JSON assertion with no size limit (4 MB was fatal at 256M), one assertion hashed once per reference to it (20.6 s for 1,000), CBOR string chunks that cost no item (47 s for 14 million in the unsigned COSE header), and an empty embedded-file description box that threw
ValueError(step 161, SPEC-045). - a manifest that nothing references, and so is never validated,
could declare an assertion of the active manifest redacted; with that
box removed, the file stayed
-
2026-09-27, the certificate chain — present in
0.1.0to0.2.4, fixed in0.2.5(notes/step-164-chain-constraints-measured.md). The review's lower findings, measured on a throw-away hierarchy against bothc2patoolversions andopenssl verify, gave four wrongTrusted:- a leaf outside an intermediate's name constraints;
- an unknown critical extension in the leaf;
- an unknown critical extension in an intermediate;
- a chain read from the unprotected header under label 33, or from both headers.
c2patoolrefuses all four. They were closed by SPEC-046 and SPEC-047. The same release refuses, beyondc2patool, an unprotected chain in a claim v2 or later, and MD5 or SHA-1 signatures in the path (SPEC-047, SPEC-048). -
2026-09-28, an RSA public exponent of 1 — present in
0.1.0to0.2.5, fixed in0.2.6(notes/step-174-engine-0.91.1-measured.md). c2pa-rs 0.91.1 began refusing RSA keys whose public exponent is below 3 or even (CAI-13156). Measured on a throw-away hierarchy: a leaf with exponent 1, whose PS256 signature needs no private key, wasTrustedhere and in bothc2patoolversions. The chain walk also accepted an intermediate with such a key. SPEC-049 refuses the exponent in the leaf and, beyondc2patool, anywhere between the anchor and the leaf. -
2026-09-30, a hash algorithm the specification does not allow — present in
0.1.0to0.2.7, fixed in0.2.8(notes/step-185-spec051.md,notes/step-188-spec052.md). A review ofsrc/found two places that took a hash algorithm's name from the file and held it only to what PHP can compute:- the merkle map of a fragmented ISOBMFF file: six edited bytes
(
sha256→fooooo) madeverify()throw aValueError, with no key needed (SPEC-051); - an ingredient reference: a signed reference naming
crc32b, with the rightcrc32b, wasTrustedhere andInvalidinc2patool0.27.22, which hashes the manifest box with the ingredient claim's algorithm only (SPEC-052). It needs a signer who chosecrc32b.
The same review found the BMFF exclusion plan unbounded: an MP4 with 4000 empty boxes and 8 exclusions took 11.4 s, and larger ones exhausted memory, without a key (SPEC-053,
notes/step-191-spec053.md). - the merkle map of a fragmented ISOBMFF file: six edited bytes
(
-
2026-10-05, a name constraint over a name that is not UTF-8 — present in
0.2.5to0.2.9, fixed in0.3.0(notes/step-247-whole-review.md). The name-constraint check folded attribute values with a/uregular expression, which returns nothing on bytes that are not UTF-8, so every such value became empty and two different names compared equal. Measured on a throw-away hierarchy: an intermediate permitting onlyO = "Caf\xE9"(a T61String), a leaf withO = "Other\xFF":Trustedhere;c2patool0.28.1ValidwithsigningCredential.untrusted; OpenSSL permitted subtree violation. It needs a name-constrained CA under one of your anchors that issues outside its constraint (SPEC-046 amendment 1). The same review found that a NaN in any assertion made the report fail to encode, which ended the command with PHP's fatal error, without a key (SPEC-007 amendment 6); the fuzzer, once it encoded every report, found the same effect from a damaged KeyUsage extension quoted in an explanation (SPEC-043 amendment 3). -
2026-10-08, a trust anchor outside its own validity — present in
0.1.0to0.5.0, fixed in0.5.1(notes/step-273-anchor-validity.md,notes/step-275-anchor-validity-built.md). The chain walk judged every x5chain intermediate at the time the signer is judged, but never the anchor it reached. Measured on a throw-away hierarchy: a root valid only in 2020, a root valid only from 2090, and an expired intermediate configured as the anchor each made their signerTrustedhere, and so did the expired root carried in x5chain as well;c2patool0.28.1 saysValidwithsigningCredential.untrustedfor each, 0.27.22Trusted. It needs an out-of-date anchor in your settings and a key under it (SPEC-014 amendment 5). Reported privately from the security review of a downstream module, with a related point: the state counted an ingredient'ssigningCredential.trustedas the active manifest's own. Not reachable, now not possible either (SPEC-014 amendment 6). -
2026-10-08, a certificate authority without keyUsage — present in
0.2.2to0.5.1, fixed in0.5.2(notes/step-283-trust-matrix.md). SPEC-014 amendment 4 asked an issuing certificate forkeyCertSignonly when it carried a keyUsage extension, so an intermediate or an anchor without one could issue. Found by the trust matrix, which varies one property of one certificate at a time againstc2patooland OpenSSL: both casesTrustedhere;c2patool0.27.22 and 0.28.1ValidwithsigningCredential.untrusted;openssl verify -x509_strict"CA cert does not include key usage extension". RFC 5280 §4.2.1.3 requires the extension on such a certificate. It needs a certificate authority without keyUsage under one of your anchors, or as one (SPEC-014 amendment 7). None of the 53 anchors in the C2PA lists of 2026-08-14 and DigiCert Trusted Root G4 lacks it. -
2026-10-23, a review of the whole code base before
0.8.0— four present in released versions, fixed in0.8.0(notes/step-389-acknowledgement-scope.mdtonotes/step-395-bmff-offset-tables.md). Four review passes, one per area, run as Claude Code subagents (AI-LOG.md); every finding re-run or re-read before it was fixed.- An ingredient acknowledging its own failures,
0.1.0to0.7.1: the records of every reached manifest were pooled, so a replaced ingredient could excuse its own signature mismatch and its parent's hash of it;InvalidbecameValid(confirmed with the real classes). SPEC-070 amendment 3. - A redaction reaching the active manifest,
0.2.1to0.7.1: an ingredient could declare one of the active manifest's assertions redacted. Reasoned; after the fix above it needs the active manifest's label in advance. SPEC-035 amendment 6. - BMFF offset tables excluded from the hash,
0.1.0to0.7.1: with the offset tables excluded as an olderc2pa-rswrote them, the media could be changed without breaking the binding (confirmed). SPEC-071 amendment 1. - An entry's EKUs chosen at the clock,
0.2.0to0.7.1: reasoned, not reproduced. SPEC-079 amendment 2. - The JSON trust lists, new in
0.8.0and fixed before it: five list shapes gaveTrustedfor a removed anchor (confirmed). SPEC-079 amendment 2.
- An ingredient acknowledging its own failures,
-
2026-10-24, a fuzzer for the manifest graph — fixed in
0.8.1(notes/step-406-graph-fuzz-plan.mdtonotes/step-410-cycle-not-acknowledged.md).bin/graph-fuzz.phpbuilds stores in which every manifest is signed again with throw-away keys, so the only fault is the one it placed, and judges each against a prediction made from its plan. Line coverage had not shown these: the code at fault was executed by every run.- Only the first reference's hash checked,
0.1.0to0.8.0: a second ingredient assertion naming the same manifest with a wrong hash left the fileTrusted(confirmed on0.7.1and0.8.0). SPEC-021 amendment 7. - A redaction of the active manifest through a cycle,
0.8.0: the fix of0.8.0above followed a cycle back to the active manifest (confirmed on0.8.0; needs the active manifest's label in advance). SPEC-035 amendment 7. - Not counted above, because it changed a rule rather than broke one: a
cyclic store whose cycle a record acknowledged could be
Trustedwherec2patoolrefuses it; since0.8.1a cycle cannot be acknowledged (SPEC-070 amendment 4).
- Only the first reference's hash checked,
-
2026-10-25, measured against
c2patool0.28.3 — not counted above, changed in0.8.2(notes/step-421-ed25519-parameters.md,notes/step-422-ecdsa-pairing.md). Two signatures wereValidorTrustedhere andInvalidinc2patool: an ECDSA algorithm under a key on another curve (0.1.0to0.8.1; everyc2patoolmeasured refuses it) and a signer certificate signed withid-Ed25519and parameters (c2patool0.28.3 refuses it, 0.28.2 did not). Neither lets anyone sign in another's name: the signature is made with the certificate holder's key. Not counted, because they change a rule to followc2patoolrather than fix one that broke. SPEC-009 amendment 5, SPEC-015 amendment 11. -
2026-10-25, the reading of C2PA 2.4 checked again — not counted above, changed in
0.8.3(notes/step-427-claim-without-alg.md,notes/step-429-opened-references.md). Two files wereTrustedhere andInvalidinc2patool0.28.1 and 0.28.3 (confirmed on0.8.2): a BMFF hash with no algorithm named anywhere, checked with SHA-256 by a fallback, and ac2pa.openedaction whose parent reference was followed by a reference to nothing or to acomponentOfingredient. In neither was a byte left unhashed or a signature unchecked: the BMFF hash still matched the media, and the parent still resolved and matched. Not counted, for the reason given for0.8.2. SPEC-027 amendment 9, SPEC-033 amendment 3. -
2026-10-29, a security review of the whole code before
0.10.0— six present in released versions, fixed in0.10.0(notes/step-502-cawg-refused-everywhere.mdtonotes/step-518-segment-chunk-limits.md). Five review passes, one per area, run as Claude Code subagents (AI-LOG.md). Every finding was re-run before it was fixed, one per step, each with its amendment approved first.- A CAWG identity assertion outside the refusal,
0.1.0to0.9.0: the refusal knewcawg.identityonly by its exact label and only in the active manifest, so an instance (cawg.identity__1) or one in an ingredient manifest left a fileTrustedthatc2patoolcallsInvalid(confirmed with signed probes). SPEC-013 amendment 24, SPEC-021 amendment 8. - Time Stamping written twice,
0.2.4to0.9.0: a leaf naming the Time Stamping EKU twice escaped the rule that keeps a time-stamping certificate from signing manifests under the legacytrust_anchors(confirmed). SPEC-031 amendment 5. - An OCSP
goodwithoutnextUpdate,0.9.0: it counted for ever, so a stored response from before a revocation kept a caller's revocation source from being asked, and a revoked signer stayedTrusted(confirmed). SPEC-030 amendment 7. - A delegated OCSP responder outside its validity,
0.9.0: a retired responder key could answergoodand, the same way, keep a revoked signerTrusted(confirmed). SPEC-030 amendment 8. - The handler of an excluded track behind a 64-bit header,
0.7.0to0.9.0: it was read at the wrong offset, so a timed-metadata track could be turned into an unsigned video track that stayedTrusted(confirmed). SPEC-072 amendment 1. - The BMFF box walk out of step,
0.1.0to0.9.0: one to seven bytes after a container's last child were not passed over, so the tree could list boxes the file does not hold there. Measured as a refusal; a wrongTrustedis reasoned, not built. SPEC-029 amendment 4. - Not counted above, because no verdict was wrong: five inputs that
made
verify()throw (SPEC-080 amendment 3, SPEC-016 amendment 7, SPEC-009 amendment 6, SPEC-007 amendments 10 and 11), four that cost seconds or tens of megabytes (SPEC-080 amendment 4, SPEC-021 amendment 9, SPEC-016 amendment 8, SPEC-001 amendment 6 with SPEC-002 amendment 4), and two low findings (SPEC-009 amendment 7, SPEC-005 amendment 3).
- A CAWG identity assertion outside the refusal,
The method — for every rule of the form "check X when Y is present",
build a signed manifest in which Y is absent and measure — is now
tooling (bin/make-absence-variants.php) and part of every new spec.
It will not fetch anything over the network during verification, will not bundle a production trust list, will not sign, and will not hold a private key — test keys included. Any change to these four is a documented architecture decision, not a patch.