Skip to content

Security: provemark/c2pa-verifier

Security

SECURITY.md

Security

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.

What counts

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.

Reporting

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.

Scope of what is verified

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.

Findings so far

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 no c2pa.hash.data assertion — a manifest that binds to no pixels at all — was Valid, and Trusted with 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. c2patool refuses 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 with c2pa.created / c2pa.opened, was Valid; c2patool refuses 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 official truepic-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 still Trusted with the signer's root as an anchor. c2patool 0.27.22 agrees with this verifier; c2patool 0.28.0 rejects the file, and its new rule is how this was found. It is present in 0.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.0 to 0.2.1, fixed in 0.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 Valid or Trusted (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.

  • 2026-09-25, a certificate time with a fraction — present in 0.1.0 to 0.2.2, fixed in 0.2.3 (notes/step-156-certificate-validity.md). The validity window came from PHP's openssl_x509_parse(), which misreads a time with a fraction of a second: a notAfter of 20250101000000.5Z became 2500-12-31, so a certificate that expired on 2025-01-01 was Trusted. Both c2patool versions 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 Valid or Trusted. Present in 0.2.1 to 0.2.3. Both c2patool versions say Invalid (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 a Trusted manifest. Present in 0.1.0 to 0.2.3. Both c2patool versions still say Trusted; 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).

  • 2026-09-27, the certificate chain — present in 0.1.0 to 0.2.4, fixed in 0.2.5 (notes/step-164-chain-constraints-measured.md). The review's lower findings, measured on a throw-away hierarchy against both c2patool versions and openssl verify, gave four wrong Trusted:

    • 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.

    c2patool refuses all four. They were closed by SPEC-046 and SPEC-047. The same release refuses, beyond c2patool, 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.0 to 0.2.5, fixed in 0.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, was Trusted here and in both c2patool versions. The chain walk also accepted an intermediate with such a key. SPEC-049 refuses the exponent in the leaf and, beyond c2patool, anywhere between the anchor and the leaf.

  • 2026-09-30, a hash algorithm the specification does not allow — present in 0.1.0 to 0.2.7, fixed in 0.2.8 (notes/step-185-spec051.md, notes/step-188-spec052.md). A review of src/ 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) made verify() throw a ValueError, with no key needed (SPEC-051);
    • an ingredient reference: a signed reference naming crc32b, with the right crc32b, was Trusted here and Invalid in c2patool 0.27.22, which hashes the manifest box with the ingredient claim's algorithm only (SPEC-052). It needs a signer who chose crc32b.

    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).

  • 2026-10-05, a name constraint over a name that is not UTF-8 — present in 0.2.5 to 0.2.9, fixed in 0.3.0 (notes/step-247-whole-review.md). The name-constraint check folded attribute values with a /u regular 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 only O = "Caf\xE9" (a T61String), a leaf with O = "Other\xFF": Trusted here; c2patool 0.28.1 Valid with signingCredential.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.0 to 0.5.0, fixed in 0.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 signer Trusted here, and so did the expired root carried in x5chain as well; c2patool 0.28.1 says Valid with signingCredential.untrusted for each, 0.27.22 Trusted. 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's signingCredential.trusted as 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.2 to 0.5.1, fixed in 0.5.2 (notes/step-283-trust-matrix.md). SPEC-014 amendment 4 asked an issuing certificate for keyCertSign only 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 against c2patool and OpenSSL: both cases Trusted here; c2patool 0.27.22 and 0.28.1 Valid with signingCredential.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 in 0.8.0 (notes/step-389-acknowledgement-scope.md to notes/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.0 to 0.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; Invalid became Valid (confirmed with the real classes). SPEC-070 amendment 3.
    • A redaction reaching the active manifest, 0.2.1 to 0.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.0 to 0.7.1: with the offset tables excluded as an older c2pa-rs wrote 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.0 to 0.7.1: reasoned, not reproduced. SPEC-079 amendment 2.
    • The JSON trust lists, new in 0.8.0 and fixed before it: five list shapes gave Trusted for a removed anchor (confirmed). SPEC-079 amendment 2.
  • 2026-10-24, a fuzzer for the manifest graph — fixed in 0.8.1 (notes/step-406-graph-fuzz-plan.md to notes/step-410-cycle-not-acknowledged.md). bin/graph-fuzz.php builds 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.0 to 0.8.0: a second ingredient assertion naming the same manifest with a wrong hash left the file Trusted (confirmed on 0.7.1 and 0.8.0). SPEC-021 amendment 7.
    • A redaction of the active manifest through a cycle, 0.8.0: the fix of 0.8.0 above followed a cycle back to the active manifest (confirmed on 0.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 Trusted where c2patool refuses it; since 0.8.1 a cycle cannot be acknowledged (SPEC-070 amendment 4).
  • 2026-10-25, measured against c2patool 0.28.3 — not counted above, changed in 0.8.2 (notes/step-421-ed25519-parameters.md, notes/step-422-ecdsa-pairing.md). Two signatures were Valid or Trusted here and Invalid in c2patool: an ECDSA algorithm under a key on another curve (0.1.0 to 0.8.1; every c2patool measured refuses it) and a signer certificate signed with id-Ed25519 and parameters (c2patool 0.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 follow c2patool rather 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 were Trusted here and Invalid in c2patool 0.28.1 and 0.28.3 (confirmed on 0.8.2): a BMFF hash with no algorithm named anywhere, checked with SHA-256 by a fallback, and a c2pa.opened action whose parent reference was followed by a reference to nothing or to a componentOf ingredient. 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 for 0.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 in 0.10.0 (notes/step-502-cawg-refused-everywhere.md to notes/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.0 to 0.9.0: the refusal knew cawg.identity only by its exact label and only in the active manifest, so an instance (cawg.identity__1) or one in an ingredient manifest left a file Trusted that c2patool calls Invalid (confirmed with signed probes). SPEC-013 amendment 24, SPEC-021 amendment 8.
    • Time Stamping written twice, 0.2.4 to 0.9.0: a leaf naming the Time Stamping EKU twice escaped the rule that keeps a time-stamping certificate from signing manifests under the legacy trust_anchors (confirmed). SPEC-031 amendment 5.
    • An OCSP good without nextUpdate, 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 stayed Trusted (confirmed). SPEC-030 amendment 7.
    • A delegated OCSP responder outside its validity, 0.9.0: a retired responder key could answer good and, the same way, keep a revoked signer Trusted (confirmed). SPEC-030 amendment 8.
    • The handler of an excluded track behind a 64-bit header, 0.7.0 to 0.9.0: it was read at the wrong offset, so a timed-metadata track could be turned into an unsigned video track that stayed Trusted (confirmed). SPEC-072 amendment 1.
    • The BMFF box walk out of step, 0.1.0 to 0.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 wrong Trusted is 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).

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.

What this project will not do

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.

There aren't any published security advisories