Skip to content

CVE-2026-48816: Verified Repro With Script Download

CVE-2026-48816: sigstore-js Insufficient Verification of Data Authenticity

CVE-2026-48816 is verified against sigstore/sigstore-js · github. Fixed in 3.1.1. This medium reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00210.

REPRO-2026-00210 sigstore/sigstore-js · github Variant found Jul 2, 2026 CVE entry ↗ .txt
Severity
MEDIUM
CVSS
6.5
Confidence
HIGH
Reproduced in
18m 54s
Tool calls
158
Spend
$2.52
01 · Overview

What Is CVE-2026-48816?

CVE-2026-48816 (GHSA-xgjw-pm74-86q4) is a medium-severity insufficient verification of data authenticity (CWE-345) in @sigstore/verify, where a transparency-log timestamp used for certificate validity and timestampThreshold checks is not cryptographically bound in inclusionProof-only bundle entries. Pruva reproduced it (reproduction REPRO-2026-00210).

02 · Severity & CVSS

CVE-2026-48816 Severity & CVSS Score

CVE-2026-48816 is rated medium severity, with a CVSS base score of 6.5 out of 10.

MEDIUM threat level
6.5 / 10 CVSS base
Weakness CWE-345 (Insufficient Verification of Data Authenticity)

Medium — meaningful risk under specific conditions. Schedule a fix in the normal cycle.

How to Reproduce CVE-2026-48816

$ pruva-verify REPRO-2026-00210
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00210/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-48816

Security impact — reproduced
  • reached the target end-to-end
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

tlogEntries[].integratedTime in an attacker-supplied sigstore bundle (inclusionProof-only entry, no signed inclusionPromise)

Attack chain
  1. Verifier.verify
  2. verifyTimestamps
  3. getTLogTimestamp (packages/verify/src/timestamp/index.ts) treats unauthenticated integratedTime as a trusted timestamp; verifySigningKey/verifyCertificate (packages/verify/src/key/index.ts) accepts an expired certificate at the attacker-chosen time
Runnable proof: reproduction_steps.sh
Captured evidence: fixed buildvuln variant fixed jest
Variants tested

Bypass of the @sigstore/verify 3.1.1 fix (commit f074710) for CVE-2026-48816. The fix gates getTLogTimestamp() on inclusionPromise *presence* and relies on verifyTLogs()->verifyTLogSET() to authenticate integratedTime. A SignedEntity supplied to the public Verifier.verify(SignedEntity) API in which the timestamp-provi…

How the agent worked 368 events · 158 tool calls · 19 min
19 minDuration
158Tool calls
104Reasoning steps
368Events
1Dead-ends
Agent activity over 19 min
Support
30
Hypothesis
2
Repro
159
Judge
43
Variant
130
0:0018:54

Root Cause and Exploit Chain for CVE-2026-48816

Versions: @sigstore/verify 3.1.0 (vulnerable). Fixed in 3.1.1.Fixed: commit: f074710 ("reject integratedTime w/o inclusionPromise

@sigstore/verify (the sigstore-js verification library) derives a transparency-log timestamp directly from tlogEntries[].integratedTime and uses that timestamp both to check the Fulcio signing certificate's validity window and to satisfy the timestampThreshold. For a bundle whose tlog entry is inclusionProof-only (it carries an inclusionProof but no signed inclusionPromise/SET), integratedTime is not cryptographically bound: the inclusion-proof path (verifyCheckpoint + verifyMerkleInclusion) proves Merkle-tree inclusion against a signed checkpoint but never binds the integratedTime value, whereas only the signed inclusionPromise/SET path (verifyTLogSET) signs over integratedTime. As a result, an attacker who can supply an untrusted bundle can choose integratedTime freely and thereby influence time-based verification decisions — in particular making an expired certificate appear to have been valid at signing time.

  • Package/component affected: @sigstore/verify (npm), part of the sigstore/sigstore-js monorepo. Affected source files:
    • packages/verify/src/bundle/index.tstoSignedEntity adds a transparency-log timestamp for every tlog entry where integratedTime != '0', regardless of whether an inclusionPromise is present.
    • packages/verify/src/timestamp/index.tsgetTLogTimestamp converts entry.integratedTime into a Date with no check that the value is cryptographically bound.
    • packages/verify/src/verifier.tsverifyTimestamps counts every transparency-log timestamp toward timestampThreshold, and verify() runs timestamp (and therefore certificate-validity) checks before verifyTLogs (inclusion proof) — so the unauthenticated time is consumed before any inclusion check that could constrain it.
    • packages/verify/src/tlog/index.ts + packages/verify/src/tlog/set.ts — only the inclusionPromise/SET path (verifyTLogSET) signs over integratedTime; the inclusionProof path does not.
  • Affected versions: @sigstore/verify 3.1.0 (vulnerable). Fixed in 3.1.1.
    • Vulnerable commit (anchored to the fix's parent): 7845532 (f074710^, "OID certificate extension verification (#1658)", still shipping @sigstore/verify 3.1.0).
    • Fixed commit: f074710 ("reject integratedTime w/o inclusionPromise (#1659)"), released as 3.1.1 via c1dc7d4 ("Version Packages (#1607)").
  • Risk level / consequences: Medium (advisory CVSS 6.5, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N; CWE-345 Insufficient Verification of Data Authenticity). Integrity impact: a consumer that accepts attacker-provided bundle inputs and relies on tlog-derived timestamps for certificate-validity checks can be tricked into accepting a bundle whose signing certificate is expired (or otherwise time-invalid) by an unauthenticated timestamp value chosen by the attacker.

Impact Parity

  • Disclosed/claimed maximum impact: Integrity bypass — an attacker-supplied bundle can influence time-based verification (certificate validity windows and timestampThreshold) via an unauthenticated integratedTime in an inclusionProof-only tlog entry.
  • Reproduced impact from this run:
    • The real getTLogTimestamp() callsite accepts an inclusionProof-only entry's integratedTime as a trusted timestamp (vulnerable) and returns undefined for it (fixed).
    • The real Verifier.verify() succeeds on a real cert-signed bundle mutated to be inclusionProof-only with an attacker-chosen integratedTime, accepting a certificate that is expired now (notAfter = 2025-12-14T02:14:39Z, now = 2026-07-02…) because the unauthenticated integratedTime is set inside the cert's validity window (notBefore = 2025-12-14T02:04:39Z). The fixed build rejects the identical bundle with TIMESTAMP_ERROR.
  • Parity: full — the disclosed trust gap (unauthenticated integratedTime used for certificate validity / timestampThreshold) is demonstrated through the real @sigstore/verify verification path with a concrete expired-certificate-accepted outcome and a fixed-version negative control.
  • Not demonstrated: No code execution / memory corruption is claimed or demonstrated; the impact is a verification/authenticity bypass (CWE-345).

Root Cause

toSignedEntity (bundle/index.ts) turns every tlog entry with integratedTime != '0' into a transparency-log timestamp. verifyTimestamps (verifier.ts) then calls getTLogTimestamp(entry) (timestamp/index.ts) which, in the vulnerable code, unconditionally returns:

return {
  type: 'transparency-log',
  logID: entry.logId.keyId,
  timestamp: new Date(Number(entry.integratedTime) * 1000),
};

with no check that entry.inclusionPromise exists. That result is counted toward timestampThreshold and is returned to verifySigningKey, which calls verifyCertificate(cert, timestamps, trustMaterial)verifyCertificateChain(timestamp, leaf, CAs) — i.e. the certificate's validity window is checked at the attacker-chosen integratedTime, not at the current time and not at a cryptographically-bound time. Critically, verify() performs verifyTimestamps (and thus the certificate-validity decision) before verifyTLogs (the inclusion-proof check), so the unauthenticated time is consumed before any check that could constrain it.

Only the inclusionPromise/SET path (verifyTLogSET in tlog/set.ts) signs over integratedTime (body, integratedTime, logIndex, logID). The inclusionProof path (verifyCheckpoint + verifyMerkleInclusion) verifies Merkle inclusion against a signed checkpoint but does not bind integratedTime. Therefore an inclusionProof-only entry can pass inclusion verification while leaving integratedTime fully attacker-controlled.

Fix commit: f074710 ("reject integratedTime w/o inclusionPromise (#1659)").

 export function getTLogTimestamp(
   entry: TransparencyLogEntry
-): TimestampVerificationResult {
+): TimestampVerificationResult | undefined {
+  // Only entries with an inclusion promise provide a verifiable timestamp
+  if (!entry.inclusionPromise) {
+    return undefined;
+  }
+
   return {
     type: 'transparency-log',
     logID: entry.logId.keyId,

and in verifier.ts, verifyTimestamps now only pushes the result when it is defined and compares timestamps.length against timestampThreshold (so an inclusionProof-only entry no longer counts toward the threshold nor feeds certificate validity).

Reproduction Steps

  1. Script: bundle/repro/reproduction_steps.sh (self-contained; harness at bundle/repro/harness_test.ts).
  2. What it does:
    • Reuses/clones sigstore/sigstore-js into the durable project cache (<project_cache_dir>/repo).
    • Resolves the fixed commit f074710 and its parent 7845532 (vulnerable, @sigstore/verify 3.1.0).
    • For each commit: git checkout, npm ci (if needed), npm run build (builds the workspace so jest can resolve @sigstore/bundle/core/jest via their dist), copies the harness into packages/verify/src/__tests__/cve_repro.test.ts, and runs it via npx jest --selectProjects verify --testPathPatterns cve_repro.test.ts.
    • The harness (run at both commits) exercises the real @sigstore/verify source and emits behavior markers to $REPRO_LOG:
      • Part A — callsite: getTLogTimestamp() on an inclusionProof-only entry with an attacker-chosen integratedTime.
      • Part BVerifier.verify() (public-key path) where the sole timestamp is the inclusionProof-only entry's integratedTime (timestampThreshold:1, tlogThreshold:0).
      • Part CVerifier.verify() (certificate path) on the real cert-signed fixture V3.MESSAGE_SIGNATURE.TLOG_HASHEDREKORDV002, mutated so the inclusionProof-only entry's integratedTime is a non-zero value inside the (expired) certificate's validity window and the RFC3161 timestamp is removed — making the unauthenticated integratedTime the only timestamp source.
    • Evaluates markers and writes bundle/repro/runtime_manifest.json and bundle/repro/validation_verdict.json.
  3. Expected evidence of reproduction:
    • bundle/logs/canonical.log (vulnerable) contains [CALLSITE_HIT] and [PROOF_MARKER], including the certificate-path line showing an expired cert accepted via unauthenticated integratedTime.
    • bundle/logs/control.log (fixed) contains [NC_MARKER] only (getTLogTimestamp returns undefined; Verifier.verify() throws TIMESTAMP_ERROR), with no [PROOF_MARKER].
    • Script exits 0.

Evidence

  • Marker logs:
    • bundle/logs/canonical.log
    • bundle/logs/control.log
  • Raw jest output:
    • bundle/logs/canonical_jest.log
    • bundle/logs/control_jest.log
  • Build logs: bundle/logs/canonical_build.log, bundle/logs/control_build.log
  • Harness: bundle/repro/harness_test.ts
  • Manifest/verdict: bundle/repro/runtime_manifest.json, bundle/repro/validation_verdict.json

Key excerpts (vulnerable canonical run, bundle/logs/canonical.log):

[CALLSITE_HIT]
[PROOF_MARKER]: getTLogTimestamp() accepted UNAUTHENTICATED tlog integratedTime=1763174679 as a trusted timestamp (2025-11-15T02:44:39.000Z); entry has NO inclusionPromise (inclusionProof-only) -> integratedTime not cryptographically bound
[PROOF_MARKER]: Verifier.verify() (public-key path) SUCCEEDED; sole timestamp was UNAUTHENTICATED tlog integratedTime=1763174679 from inclusionProof-only entry -> timestampThreshold satisfied & key validity accepted on attacker-chosen time
[CALLSITE_HIT]
[PROOF_MARKER]: Verifier.verify() (certificate path) SUCCEEDED for a cert that is EXPIRED now (notBefore=2025-12-14T02:04:39.000Z, notAfter=2025-12-14T02:14:39.000Z, now=2026-07-02T18:09:39.516Z) using UNAUTHENTICATED tlog integratedTime=1765677909 from an inclusionProof-only entry -> certificate validity window satisfied by attacker-chosen, unauthenticated time

Key excerpts (fixed control run, bundle/logs/control.log):

[NC_MARKER]: getTLogTimestamp() returned undefined for inclusionProof-only entry (no inclusionPromise) -> untrusted integratedTime rejected
[NC_MARKER]: Verifier.verify() (public-key path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime no longer counts toward timestampThreshold
[NC_MARKER]: Verifier.verify() (certificate path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime not counted as a trusted timestamp; no signed timestamp present
  • Environment: Node v24.18.0, npm 11.16.0, jest 30 with @swc/jest (swc via bundled wasm binding). Repo built with tsc --build tsconfig.build.json. Vulnerable commit 7845532 (@sigstore/verify 3.1.0); fixed commit f074710 (@sigstore/verify 3.1.1). Marker counts: canonical CALLSITE_HIT=2 PROOF_MARKER=3; control NC_MARKER=3 PROOF_MARKER=0.

Recommendations / Next Steps

  • Upgrade: Consumers of @sigstore/verify should upgrade to >= 3.1.1 (fix commit f074710).
  • Defense in depth: When accepting attacker-provided bundles, do not treat integratedTime from inclusionProof-only entries as a trusted timestamp. Require a signed inclusionPromise/SET or an RFC3161 timestamp for any time-based decision (certificate validity, timestampThreshold).
  • Testing recommendations: Add regression tests that (a) feed an inclusionProof-only entry with a non-zero integratedTime and assert getTLogTimestamp returns undefined, and (b) assert a bundle whose only timestamp is such an entry is rejected with TIMESTAMP_ERROR. The upstream fix already added the getTLogTimestamp "no inclusion promise -> undefined" test; the certificate-validity impact path demonstrated here is a useful additional regression case.

Additional Notes

  • Idempotency: reproduction_steps.sh was run twice consecutively; both runs exited 0 with identical marker counts (canonical: CALLSITE_HIT=2, PROOF_MARKER=3; control: NC_MARKER=3, PROOF_MARKER=0). The script cleans packages/verify/dist and *.tsbuildinfo per commit and re-copies the harness, so runs are deterministic.
  • Surface alignment: The ticket's claimed_surface is library_api with required_entrypoint_kind=function_call. The proof exercises the real @sigstore/verify library functions (getTLogTimestamp, toSignedEntity, Verifier.verify, toTrustMaterial) through a jest harness, including a real cert-signed bundle fixture — matching the claimed surface.
  • Scope/limitations: The reproduction demonstrates the verification authenticity/integrity bypass (an expired certificate accepted as valid via an unauthenticated timestamp). It does not demonstrate code execution or memory corruption (none is claimed). Part C uses a real sigstore bundle fixture whose certificate is expired relative to the run time; the attacker influence is the integratedTime value, which is the exact unauthenticated field identified by the advisory.

Variant Analysis & Alternative Triggers for CVE-2026-48816

Versions: versions (as tested): 3.1.0 (7845532) and 3.1.1Fixed: version

This is a bypass of the @sigstore/verify 3.1.1 fix (commit f074710, "reject integratedTime w/o inclusionPromise (#1659)") for CVE-2026-48816. The fix makes getTLogTimestamp() return undefined when entry.inclusionPromise is absent, and stops counting such entries toward timestampThreshold. It relies on verifyTLogs()verifyTLogInclusion()verifyTLogSET() to cryptographically bind integratedTime whenever an inclusionPromise is present. That binding only runs for entries in entity.tlogEntries; a SignedEntity supplied to the public Verifier.verify(SignedEntity) API in which the timestamp-providing tlog entry is not present in tlogEntries (decoupled) carries a forged inclusionPromise + attacker-chosen integratedTime. The fix's !entry.inclusionPromise presence-check passes (because a forged inclusionPromise is present), the SET is never validated (verifyTLogs iterates the empty tlogEntries), and verify() succeeds — accepting an expired certificate at the attacker-chosen time. This reproduces the original CVE's expired-cert-accepted impact on the fixed version. The construction is identical in shape to the original CVE PoC's "Part B" (tlogEntries: [], sole transparency-log timestamp). Through the standard toSignedEntity(bundle) path the entries are coupled and a forged SET is rejected by verifyTLogSET (negative control below), so the fix is effective for the normal bundle path — but the fix's own logic does not enforce that coupling.

Fix Coverage / Assumptions

Invariant the fix relies on: "If entry.inclusionPromise is present, then entry.integratedTime is cryptographically bound." The fix itself does not verify the SET; it delegates authentication to verifyTLogSET() inside verifyTLogs(). verifyTLogSET() (packages/verify/src/tlog/set.ts) canonicalizes {body, integratedTime, logIndex, logID} and verifies the SET signature against a trusted tlog public key — so a real SET does bind integratedTime, a tampered integratedTime invalidates a real SET, and a forged SET fails verification.

Code path(s) the fix explicitly covers:

  • getTLogTimestamp() (packages/verify/src/timestamp/index.ts:44) — returns undefined when !entry.inclusionPromise (presence-check at :48).
  • verifyTimestamps() (packages/verify/src/verifier.ts:87) — only pushes a transparency-log timestamp when getTLogTimestamp returns a defined result, and compares timestamps.length against timestampThreshold (:117).

What the fix does NOT cover:

  • It checks inclusionPromise presence, not validity, in getTLogTimestamp().
  • Verifier.verify() (packages/verify/src/verifier.ts:73) runs verifyTimestamps() + verifySigningKey() (certificate validity at the tlog timestamp) before verifyTLogs() (:74–76). Nothing requires that a timestamp-providing tlog entry also be inclusion-verified before its timestamp is trusted.
  • verifyTLogs() (:165) iterates only entity.tlogEntries — never entity.timestamps. So the SET of an entry that appears in timestamps but not in tlogEntries is never validated.
  • The coupling between timestamps and tlogEntries is a property of the toSignedEntity() helper (packages/verify/src/bundle/index.ts:28), not of the Verifier contract. SignedEntity is an exported public type and Verifier.verify(SignedEntity) is the public method; nothing enforces that a caller used toSignedEntity().

Variant / Alternate Trigger

Entry point: the public Verifier.verify(entity: SignedEntity) API in @sigstore/verify (packages/verify/src/verifier.ts:73), where entity is a SignedEntity constructed with:

  • tlogEntries: [] (empty — so verifyTLogs() is a no-op),
  • timestamps: [{ $case: 'transparency-log', tlogEntry: E }] where E has integratedTime = <attacker-chosen> and inclusionPromise = { signedEntryTimestamp: <forged bytes> },
  • a real cert-signed signature + an (now-expired) leaf certificate (for the cert-path demonstration), or a public-key hint (for the threshold demonstration).

Exact code path:

  1. Verifier.verify() (:73) → verifyTimestamps(entity) (:74, :87).
  2. getTLogTimestamp(E) (packages/verify/src/timestamp/index.ts:44): the fix's if (!entry.inclusionPromise) return undefined; (:48) passes because E.inclusionPromise is the forged object → returns { timestamp: new Date(Number(E.integratedTime)*1000) } (:55). The attacker-chosen integratedTime is now a "trusted" timestamp.
  3. verifyTimestamps counts it (timestamps.lengthtimestampThreshold).
  4. verifySigningKey() (:75) → verifyCertificate(leaf, [attackerTime], …) (packages/verify/src/key/index.ts) checks the certificate chain + SCTs at the attacker-chosen time. The leaf cert is expired now but valid at attackerTime (chosen inside its notBefore..notAfter), so it is accepted.
  5. verifyTLogs(entity) (:76, :165) iterates entity.tlogEntries (empty) → no-opverifyTLogInclusion()/verifyTLogSET() (packages/verify/src/tlog/set.ts:33) never run → the forged SET is never validated.
  6. verifySignature() (:77) succeeds (real signature).
  7. verify() returns success on the FIXED version → expired cert accepted.

Why this is materially distinct from the original CVE trigger: the original CVE used an inclusionProof-only entry (no inclusionPromise at all) and was exploited through the coupled toSignedEntity(bundle) path. The fix gated getTLogTimestamp on inclusionPromise presence to close that. The variant defeats that gate by supplying a forged inclusionPromise whose SET is never validated (decoupled SignedEntity), reaching the same unauthenticated-integratedTime → trusted-timestamp → expired-cert sink. It is a different entry shape, a different fix-assumption exploited (presence ≠ validity + decoupling), and the same sink.

Tested variants (all encoded in bundle/vuln_variant/variant_harness.ts):

  • Part 1 — BYPASS, cert path (headline): decoupled SignedEntity + forged inclusionPromise + attacker integratedTimeverify() succeeds, expired cert accepted. Reproduced on both vulnerable and fixed versions.

  • Part 0 — BYPASS, public-key path: same decoupling, public-key path → timestampThreshold satisfied on attacker-chosen unvalidated time. Reproduced on both versions.

  • Part 2 — NEGATIVE CONTROL, bundle path (coupled): the same forged inclusionPromise but through toSignedEntity(bundle) (entry in both timestamps and tlogEntries) → verifyTLogSET rejects the forged SET with TLOG_INCLUSION_PROMISE_ERROR on both versions. Proves the fix's coupling protection holds for the standard bundle path and that verifyTLogSET always validated the SET (pre- and post-fix).

  • Part 3 — ORIGINAL VECTOR baseline, bundle path (coupled): inclusionProof-only entry + attacker integratedTime[ORIG_OK] on vulnerable, [ORIG_REJECT] (TIMESTAMP_ERROR) on fixed. Confirms the fix closes the original vector and sanity-checks the version under test.

  • Package/component affected: @sigstore/verify (npm), part of the sigstore/sigstore-js monorepo. Bypass confirmed on the fixed version 3.1.1 (commit f074710); also present on vulnerable 3.1.0 (7845532).

  • Affected versions (as tested): 3.1.0 (7845532) and 3.1.1 (f074710a91ea9260a9ac2142345634579843a3cd).

  • Risk level / consequences: Medium (same CWE-345 class as the parent advisory, CVSS 6.5). Integrity impact: a consumer that passes an attacker-controlled SignedEntity to Verifier.verify() and relies on tlog-derived timestamps for certificate-validity / timestampThreshold can be tricked into accepting a bundle whose signing certificate is expired (or otherwise time-invalid) via an unauthenticated integratedTime carried by a forged, never-validated inclusionPromise. Realism caveat: through the standard toSignedEntity(bundle) path (the documented bundle attack surface) the fix is effective; the bypass requires a SignedEntity in which the timestamp's tlog entry is not in tlogEntries, which the standard helper does not produce but the public Verifier.verify(SignedEntity) API permits.

Impact Parity

  • Disclosed/claimed maximum impact (parent): Integrity bypass — an attacker-supplied bundle can influence time-based verification (certificate validity windows and timestampThreshold) via an unauthenticated integratedTime.
  • Reproduced impact from this variant run: Verifier.verify() succeeds on the FIXED version for a real cert-signed bundle whose leaf certificate is expired now (notBefore=2025-12-14T02:04:39Z, notAfter=2025-12-14T02:14:39Z, now=2026-07-02…) because the forged inclusionPromise's unauthenticated integratedTime (1765677909 = notBefore+30s, inside the cert validity window) is trusted as the signing time. The public-key path likewise satisfies timestampThreshold:1 on the attacker-chosen, unvalidated time.
  • Parity: full — the same expired-cert-accepted integrity impact as the parent, reproduced on the patched code path.
  • Not demonstrated: No code-execution / memory-safety primitive (this is a verification-logic integrity bypass, not a memory bug); no end-to-end network service exploitation (the entry point is the library API).

Root Cause

The same underlying bug — an unauthenticated integratedTime treated as a trusted timestamp — can still be reached because the fix authenticates integratedTime only indirectly, via verifyTLogSET() inside verifyTLogs(), and verifyTLogs() iterates entity.tlogEntries rather than entity.timestamps. getTLogTimestamp() itself performs only a presence-check on inclusionPromise. Therefore any SignedEntity where a timestamp-providing tlog entry is not in tlogEntries evades SET validation entirely; a forged inclusionPromise satisfies the presence-check and the attacker-chosen integratedTime is trusted — driving verifySigningKey/verifyCertificate to accept an expired certificate. The fix is correct for the toSignedEntity(bundle) coupling but incomplete as a property of the Verifier.verify(SignedEntity) contract.

Fix commit: f074710a91ea9260a9ac2142345634579843a3cd (https://github.com/sigstore/sigstore-js commit f074710, "reject integratedTime w/o inclusionPromise (#1659)").

Reproduction Steps

  1. Script: bundle/vuln_variant/reproduction_steps.sh (self-contained; harness at bundle/vuln_variant/variant_harness.ts).
  2. What it does:
    • Reuses/clones sigstore/sigstore-js into the durable project cache (<project_cache_dir>/repo).
    • Resolves the fixed commit f074710 and its parent 7845532 (vulnerable, @sigstore/verify 3.1.0). Records git rev-parse for both (source_identity.json).
    • For each commit: git checkout, clean built artifacts, npm ci (if needed), npm run build, copy the variant harness into packages/verify/src/__tests__/cve_variant.test.ts, and run it via npx jest --selectProjects verify --testPathPatterns cve_variant.test.ts.
    • Restores the cache repo checkout to its original state (f074710) on exit (trap) and removes the harness copy, so the repo is left clean.
    • Evaluates markers and writes runtime_manifest.json, validation_verdict.json, source_identity.json, root_cause_equivalence.json. Exits 0 if the bypass reproduced on the fixed version, else 1.
  3. Expected evidence of reproduction:
    • bundle/logs/vuln_variant_fixed.log contains [BYPASS_OK] (Part 1 cert path: expired cert accepted) and [BYPASS_OK] (Part 0 public-key path), plus [NC_REJECT] (Part 2 coupled bundle path rejects forged SET) and [ORIG_REJECT] (Part 3 original vector closed).
    • bundle/logs/vuln_variant_vuln.log contains [BYPASS_OK] x2, [NC_REJECT], and [ORIG_OK] (original CVE open).
    • Script exits 0.

Evidence

  • Marker logs:
    • bundle/logs/vuln_variant_fixed.log
    • bundle/logs/vuln_variant_vuln.log
  • Raw jest output:
    • bundle/logs/vuln_variant_fixed_jest.log (4 tests passed)
    • bundle/logs/vuln_variant_vuln_jest.log (4 tests passed)
  • Build logs: bundle/logs/vuln_fixed_build.log, bundle/logs/vuln_vuln_build.log (and *_npmci.log if installed).
  • Harness: bundle/vuln_variant/variant_harness.ts
  • Manifests/verdict: bundle/vuln_variant/runtime_manifest.json, bundle/vuln_variant/validation_verdict.json, bundle/vuln_variant/source_identity.json, bundle/vuln_variant/root_cause_equivalence.json, bundle/vuln_variant/variant_manifest.json.

Key excerpts — fixed run (bundle/logs/vuln_variant_fixed.log):

[CALLSITE_BYPASS]: getTLogTimestamp() returned a trusted timestamp (2025-12-14T02:05:09.000Z) for an entry whose inclusionPromise is FORGED (presence-check passes, SET not yet validated); integratedTime=1765677909
[BYPASS_OK]: Verifier.verify() (cert path) SUCCEEDED on a DECOUPLED SignedEntity (tlogEntries empty) whose sole timestamp is a tlog entry carrying a FORGED inclusionPromise + attacker integratedTime=1765677909; cert is EXPIRED now (notBefore=2025-12-14T02:04:39.000Z, notAfter=2025-12-14T02:14:39.000Z, now=2026-07-02T18:16:12.815Z); fix presence-check passed, SET never validated -> original CVE impact (expired-cert-accepted) reproduced on this version
[BYPASS_OK]: Verifier.verify() (public-key path) SUCCEEDED on a DECOUPLED SignedEntity whose sole timestamp is a tlog entry with a FORGED inclusionPromise + attacker integratedTime=1763174679; timestampThreshold satisfied & key validity accepted on attacker-chosen, unvalidated time
[NC_REJECT]: bundle-path (coupled) verify rejected a FORGED inclusionPromise with code=TLOG_INCLUSION_PROMISE_ERROR: inclusion promise could not be verified -> verifyTLogSET validated the SET and rejected (coupling protection holds on this version)
[ORIG_REJECT]: original vector (inclusionProof-only) verify rejected with code=TIMESTAMP_ERROR: expected 1 timestamps, got 0 -> fix closes the original inclusionProof-only vector on this version

Marker counts: fixed BYPASS_OK=2 NC_REJECT=1 ORIG_REJECT=1; vuln BYPASS_OK=2 NC_REJECT=1 ORIG_OK=1.

Environment: Node v24.18.0, npm 11.16.0, jest 30 with @swc/jest. Repo built with tsc --build (npm run build). Fixed commit f074710a91ea9260a9ac2142345634579843a3cd (@sigstore/verify 3.1.1); vulnerable commit 7845532f9d17f6f765363dbee82b01bd159fb52b (@sigstore/verify 3.1.0). Both resolved via git rev-parse from the cloned repo in the durable project cache (bundle/vuln_variant/source_identity.json).

Recommendations / Next Steps

The fix should not rely on the toSignedEntity() coupling to authenticate integratedTime. One or more of:

  1. Authenticate at the timestamp source. Verify the SET inside/alongside getTLogTimestamp() (or in verifyTimestamps()) against the trusted tlog material before trusting integratedTime — make the timestamp decision independent of verifyTLogs(). This directly closes the decoupled bypass.
  2. Enforce coupling in the verifier. Require that every transparency-log timestamp's tlogEntry is also a member of entity.tlogEntries and has had its inclusion verified, before its timestamp is used for certificate-validity / threshold decisions.
  3. Do not gate on presence alone. Treat inclusionPromise as a timestamp source only after its SET has been verified, not merely because the field is non-null.

Note: simply reordering verifyTLogs() before verifyTimestamps() is not sufficient — with tlogEntries: [] and tlogThreshold: 0, verifyTLogs() is still a no-op, so the decoupled bypass would remain. Option 1 or 2 is required.

Testing recommendations: add a regression test that feeds a decoupled SignedEntity (timestamp tlog entry absent from tlogEntries) with a forged inclusionPromise + attacker integratedTime and asserts verify() rejects (TIMESTAMP_ERROR / TLOG error) rather than accepting an expired certificate.

Additional Notes

  • Idempotency: reproduction_steps.sh was run three times consecutively; all runs exited 0 with identical markers. The cache repo checkout is restored to f074710 on every exit (trap), and the harness copy is removed, so the repo is left in the same state the repro stage left it (no untracked files).
  • Threat-model scope: sigstore-js has no in-repo SECURITY.md; README.md references sigstore's security process. The CVE class is CWE-345 (Insufficient Verification of Data Authenticity), so trusting an unauthenticated value in the verifier is explicitly in-scope. This bypass is the same class and sink, not a documented limitation.
  • Realism caveat (explicit): The bypass requires a SignedEntity not produced by the standard toSignedEntity(bundle) coupling. Through the normal bundle attack surface (the advisory's "attacker supplying a malicious bundle"), the fix is effective (Part 2 negative control). The bypass is reachable via the public Verifier.verify(SignedEntity) API and matches the construction the original CVE PoC Part B used. same_surface_confidence is set to medium and exploitability_confidence to medium to reflect this caveat; same_root_cause_confidence is high (identical sink and impact).
  • Negative control confirms fix coverage: Part 2 shows the coupled bundle path rejects a forged inclusionPromise on both versions (verifyTLogSET always validated the SET), so the fix does not regress the bundle path and the bypass is specifically about the decoupled construction.

CVE-2026-48816 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:001:49
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · CVE-2026-48816 · REPRO-20
0:03
0:05
web search
0:06
web search
0:09
0:11
web search
0:12
0:14
0:15
0:18
0:19
web search
0:22
0:24
web search
0:26
1:10
1:12
web search
1:16
1:18
1:29
1:29
extract_facts
no facts extracted
1:31
1:31
1:31
supportrepro
1:45
1:47
1:47
1:47
1:49
08 · How to Fix

How to Fix CVE-2026-48816

Upgrade sigstore/sigstore-js · github to 3.1.1 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-48816 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-48816

How does the sigstore-js timestamp-forgery attack work?

Because only the signed inclusionPromise/SET path (verifyTLogSET) actually signs over integratedTime, an attacker who supplies a bundle with an inclusionProof-only tlog entry can choose integratedTime freely - for example making an expired Fulcio certificate appear to have been valid at signing time - and the verifier's toSignedEntity/getTLogTimestamp/verifyTimestamps logic will accept it.

How severe is CVE-2026-48816?

It is rated medium severity - it lets an attacker manipulate a time-based verification decision, such as certificate expiry, rather than forge a signature outright.

Where is CVE-2026-48816 fixed?

It is fixed in @sigstore/verify 3.1.1 - upgrade to 3.1.1 or later.

How can I reproduce CVE-2026-48816?

Download the verified script from this page and run it in an isolated environment; it constructs a bundle with an inclusionProof-only tlog entry and an attacker-chosen integratedTime, then shows the vulnerable @sigstore/verify accepting a certificate outside its real validity window while the patched 3.1.1 rejects it.
11 · References

References for CVE-2026-48816

Authoritative sources for CVE-2026-48816 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.