CVE-2026-16409
Overview
Background
- PSM (Platform Security Module)
- Gecko’s
security/managerglue that drives NSS to verify certificates and signed content for the browser. - Signed app / COSE signature
- a ZIP app whose manifest is signed with a COSE structure carrying an embedded end-entity X.509 certificate, verified through
nsIX509CertDB.openSignedAppFileAsync. - SPKI (`SubjectPublicKeyInfo`)
- the certificate field pairing an algorithm identifier with the encoded public key bits that the verifier must parse before checking a signature.
- `tamperSpki`
- the new
pycertoption that emits a certificate whose SPKI holds an unrecognized algorithm OID and an empty public key, used as a malformed test input.
Root Cause Analysis
When openSignedAppFileAsync verified a COSE-signed app, the PSM code path extracted the public key from the embedded end-entity certificate’s SubjectPublicKeyInfo and used the resulting handle without confirming the algorithm was recognized and the key successfully decoded. A certificate carrying a bogus algorithm OID (1.3.6.1.4.1.13769.666.666.666.1.500.9.1) and a NULL/empty subjectPublicKey therefore yielded an invalid pointer that was dereferenced instead of being rejected, violating the invariant that a key must be validly parsed before it is used.
The fix makes verification treat an unparseable SPKI as a manifest-validation failure, so the malformed certificate now returns NS_ERROR_SIGNED_JAR_MANIFEST_INVALID rather than reaching the bad dereference. The accompanying pycert/sign_app.py changes and the new tampered-spki.zip regression test in test_signed_apps.js lock in that a tampered SPKI is rejected cleanly.
SubjectPublicKeyInfo had a known algorithm and a decodable key, letting a crafted null key produce an invalid pointer; the fix rejects such input up front as an invalid manifest.Attack Path
- Craft a bad SPKI
An attacker builds an end-entity certificate whose
SubjectPublicKeyInfouses an unknown algorithm OID and an emptysubjectPublicKeybit string. - Wrap it in a COSE signature The tampered certificate is embedded in the COSE signature of a signed app’s manifest.
- Submit the app for verification
The app is opened through
nsIX509CertDB.openSignedAppFileAsync, driving the PSM COSE verification path. - Trigger the invalid pointer Verification extracts and uses the undecoded public key handle, dereferencing an invalid pointer instead of returning an error.
Impact Assessment
openSignedAppFileAsync. The metadata rates the issue low severity with a logic-error class and no demonstrated escalation beyond the faulty pointer use. The precondition is that the target opens the attacker-supplied COSE-signed app for verification.Changed Functions
| Function | Change | Notes |
|---|---|---|
add_signature_testsecurity/manager/ssl/tests/unit/test_signed_apps.js |
modified |
Files Changed
security/manager/ssl/tests/unit/sign_app.pysecurity/manager/ssl/tests/unit/test_signed_apps.jssecurity/manager/ssl/tests/unit/test_signed_apps/moz.buildsecurity/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zipsecurity/manager/tools/pycert.py
Audit Directions
- Unvalidated key extractionAudit all COSE/JAR verification paths that pull a public key from an embedded certificate’s
SubjectPublicKeyInfoand confirm parse success is checked before the handle is used. - Unknown-algorithm handlingReview code that maps SPKI algorithm OIDs to key objects for cases where an unrecognized OID or empty key yields a null/invalid pointer rather than a clean error.
- Malformed-input regression coverageLook for signed-app and certificate parsing routines lacking negative tests like
tampered-spki.zip, and add cases that assert a rejection error instead of a crash.
Patch
diff --git a/security/manager/tools/pycert.py b/security/manager/tools/pycert.py
index c2be31d88d5..c4486279e1c 100755
--- a/security/manager/tools/pycert.py
+++ b/security/manager/tools/pycert.py
@@ -16,6 +16,7 @@ subject:<subject distinguished name specification>
[validity:<YYYYMMDD-YYYYMMDD|duration in days>]
[issuerKey:<key specification>]
[subjectKey:<key specification>]
+[tamperSpki:]
[signature:{sha256WithRSAEncryption,sha1WithRSAEncryption,
md5WithRSAEncryption,ecdsaWithSHA256,ecdsaWithSHA384,
ecdsaWithSHA512}]
@@ -391,6 +392,7 @@ class Certificate:
self.savedEmbeddedSCTListData = None
self.subjectKey = pykey.keyFromSpecification("default")
self.issuerKey = pykey.keyFromSpecification("default")
+ self.tamperSpki = False
self.serialNumber = None
self.decodeParams(paramStream)
# If a serial number wasn't specified, generate one based on
@@ -456,6 +458,8 @@ class Certificate:
self.setupKey("issuer", value)
elif param == "subjectKey":
self.setupKey("subject", value)
+ elif param == "tamperSpki":
+ self.tamperSpki = True
elif param == "signature":
self.signature = value
elif param == "serialNumber":
@@ -798,9 +802,20 @@ class Certificate:
tbsCertificate["issuer"] = self.getIssuer()
tbsCertificate["validity"] = self.getValidity()
tbsCertificate["subject"] = self.getSubject()
- tbsCertificate["subjectPublicKeyInfo"] = (
- self.subjectKey.asSubjectPublicKeyInfo()
- )
+ if self.tamperSpki:
+ algorithmIdentifier = rfc5280.AlgorithmIdentifier()
+ algorithmIdentifier["algorithm"] = univ.ObjectIdentifier(
+ "1.3.6.1.4.1.13769.666.666.666.1.500.9.1"
+ )
+ algorithmIdentifier["parameters"] = univ.Null()
+ spki = rfc5280.SubjectPublicKeyInfo()
+ spki["algorithm"] = algorithmIdentifier
+ spki["subjectPublicKey"] = univ.BitString("'0500'H")
+ tbsCertificate["subjectPublicKeyInfo"] = spki
+ else:
+ tbsCertificate["subjectPublicKeyInfo"] = (
+ self.subjectKey.asSubjectPublicKeyInfo()
+ )
if self.extensions:
extensions = rfc5280.Extensions().subtype(
explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 3)
diff --git a/security/manager/ssl/tests/unit/sign_app.py b/security/manager/ssl/tests/unit/sign_app.py
index 66dec481008..599c344db1a 100755
--- a/security/manager/ssl/tests/unit/sign_app.py
+++ b/security/manager/ssl/tests/unit/sign_app.py
@@ -10,8 +10,7 @@ resulting zip file. Mainly for creating test inputs to the
nsIX509CertDB.openSignedAppFileAsync API.
"""
from base64 import b64encode
-from cbor2 import dumps
-from cbor2.types import CBORTag
+from cbor2 import CBORTag, dumps
from hashlib import sha1, sha256
import argparse
from io import StringIO
@@ -133,11 +132,11 @@ def addManifestEntry(filename, hashes, contents, entries):
entries.append(entry)
-def getCert(subject, keyName, issuerName, ee, issuerKey="", validity=""):
+def getCert(subject, keyName, issuerName, ee, issuerKey="", validity="", tamperSpki=False):
"""Helper function to create an X509 cert from a specification.
Takes the subject, the subject key name to use, the issuer name,
a bool whether this is an EE cert or not, and optionally an issuer key
- name."""
+ name, a validity period, and whether or not to tamper with the SPKI."""
certSpecification = (
"issuer:%s\n" % issuerName
+ "subject:"
@@ -156,13 +155,15 @@ def getCert(subject, keyName, issuerName, ee, issuerKey="", validity=""):
certSpecification += "\nissuerKey:%s" % issuerKey
if validity:
certSpecification += "\nvalidity:%s" % validity
+ if tamperSpki:
+ certSpecification += "\ntamperSpki:"
certSpecificationStream = StringIO()
print(certSpecification, file=certSpecificationStream)
certSpecificationStream.seek(0)
return pycert.Certificate(certSpecificationStream)
-def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certValidity):
+def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certValidity, tamperSpki):
"""Given a COSE algorithm ('ES256', 'ES384', 'ES512'), an issuer
name, the name of the issuer's key, and a validity period, returns a
(algorithm id, pykey.ECCKey, encoded certificate) triplet for use
@@ -188,6 +189,7 @@ def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certVal
True,
issuerKey,
certValidity,
+ tamperSpki,
)
return (algId, key, ee.toDER())
@@ -199,6 +201,7 @@ def signZip(
rootName,
rootKey,
certValidity,
+ tamperSpki,
manifestHashes,
signatureHashes,
pkcs7Hashes,
@@ -269,6 +272,7 @@ def signZip(
coseIssuerName,
issuerKey,
certValidity,
+ tamperSpki,
)
for coseAlgorithm in coseAlgorithms
]
@@ -398,6 +402,12 @@ def main(outputFile, appPath, *args):
+ "algorithms (out of ES256, ES384, and ES512)",
default=[],
)
+ parser.add_argument(
+ "-t",
+ "--tamper-spki",
+ action="store_true",
+ help="Whether or not to tamper with the SPKI (only supported for COSE signatures)",
+ )
parser.add_argument(
"-z",
"--pad-headers",
@@ -432,6 +442,7 @@ def main(outputFile, appPath, *args):
parsed.root,
parsed.root_key,
parsed.cert_validity,
+ parsed.tamper_spki,
[hashNameToFunctionAndIdentifier(h) for h in parsed.manifest_hash],
[hashNameToFunctionAndIdentifier(h) for h in parsed.signature_hash],
parsed.pkcs7_hash,
diff --git a/security/manager/ssl/tests/unit/test_signed_apps.js b/security/manager/ssl/tests/unit/test_signed_apps.js
index 1fe15b0bada..c370b6e9b31 100644
--- a/security/manager/ssl/tests/unit/test_signed_apps.js
+++ b/security/manager/ssl/tests/unit/test_signed_apps.js
@@ -534,6 +534,18 @@ add_signature_test(COSEOnly, function () {
);
});
+add_signature_test(COSEOnly, function () {
+ certdb.openSignedAppFileAsync(
+ Ci.nsIX509CertDB.AppXPCShellRoot,
+ original_app_path("tampered-spki"),
+ check_open_result(
+ "tampered-spki",
+ Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID,
+ []
+ )
+ );
+});
+
// Sanity check to ensure a no-op tampering gives a valid result
add_signature_test(PKCS7WithSHA1OrSHA256, function () {
let tampered = tampered_app_path("identity_tampering");
diff --git a/security/manager/ssl/tests/unit/test_signed_apps/moz.build b/security/manager/ssl/tests/unit/test_signed_apps/moz.build
index 603549de778..5ad469c41fa 100644
--- a/security/manager/ssl/tests/unit/test_signed_apps/moz.build
+++ b/security/manager/ssl/tests/unit/test_signed_apps/moz.build
@@ -71,6 +71,7 @@ def SignedAppFile(name, flags, app_directory="app/"):
# SignedAppFile('validity_expired.zip', ['-c', 'ES256', '-p', 'sha256', '--cert-validity', '19700101-19701212'])
# SignedAppFile('validity_not_yet_valid.zip', ['-c', 'ES256', '-p', 'sha256', '--cert-validity', '99990101-99991212'])
# SignedAppFile('alternate-root.zip', ['-k', 'alternate', '-c', 'ES256', '-p', 'sha256'])
+# SignedAppFile('tampered-spki.zip', ['-c', 'ES256', '-t'])
# To generate a new entry, add SignedAppFile, run mach build and copy from
# objdir/_tests/xpcshell/security/manager/ssl/tests/unit/test_signed_apps/
diff --git a/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip b/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip
new file mode 100644
index 00000000000..7155dc07cbe
Binary files /dev/null and b/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip differ
Regression Test / PoC
diff --git a/security/manager/ssl/tests/unit/sign_app.py b/security/manager/ssl/tests/unit/sign_app.py
index 66dec481008..599c344db1a 100755
--- a/security/manager/ssl/tests/unit/sign_app.py
+++ b/security/manager/ssl/tests/unit/sign_app.py
@@ -10,8 +10,7 @@ resulting zip file. Mainly for creating test inputs to the
nsIX509CertDB.openSignedAppFileAsync API.
"""
from base64 import b64encode
-from cbor2 import dumps
-from cbor2.types import CBORTag
+from cbor2 import CBORTag, dumps
from hashlib import sha1, sha256
import argparse
from io import StringIO
@@ -133,11 +132,11 @@ def addManifestEntry(filename, hashes, contents, entries):
entries.append(entry)
-def getCert(subject, keyName, issuerName, ee, issuerKey="", validity=""):
+def getCert(subject, keyName, issuerName, ee, issuerKey="", validity="", tamperSpki=False):
"""Helper function to create an X509 cert from a specification.
Takes the subject, the subject key name to use, the issuer name,
a bool whether this is an EE cert or not, and optionally an issuer key
- name."""
+ name, a validity period, and whether or not to tamper with the SPKI."""
certSpecification = (
"issuer:%s\n" % issuerName
+ "subject:"
@@ -156,13 +155,15 @@ def getCert(subject, keyName, issuerName, ee, issuerKey="", validity=""):
certSpecification += "\nissuerKey:%s" % issuerKey
if validity:
certSpecification += "\nvalidity:%s" % validity
+ if tamperSpki:
+ certSpecification += "\ntamperSpki:"
certSpecificationStream = StringIO()
print(certSpecification, file=certSpecificationStream)
certSpecificationStream.seek(0)
return pycert.Certificate(certSpecificationStream)
-def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certValidity):
+def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certValidity, tamperSpki):
"""Given a COSE algorithm ('ES256', 'ES384', 'ES512'), an issuer
name, the name of the issuer's key, and a validity period, returns a
(algorithm id, pykey.ECCKey, encoded certificate) triplet for use
@@ -188,6 +189,7 @@ def coseAlgorithmToSignatureParams(coseAlgorithm, issuerName, issuerKey, certVal
True,
issuerKey,
certValidity,
+ tamperSpki,
)
return (algId, key, ee.toDER())
@@ -199,6 +201,7 @@ def signZip(
rootName,
rootKey,
certValidity,
+ tamperSpki,
manifestHashes,
signatureHashes,
pkcs7Hashes,
@@ -269,6 +272,7 @@ def signZip(
coseIssuerName,
issuerKey,
certValidity,
+ tamperSpki,
)
for coseAlgorithm in coseAlgorithms
]
@@ -398,6 +402,12 @@ def main(outputFile, appPath, *args):
+ "algorithms (out of ES256, ES384, and ES512)",
default=[],
)
+ parser.add_argument(
+ "-t",
+ "--tamper-spki",
+ action="store_true",
+ help="Whether or not to tamper with the SPKI (only supported for COSE signatures)",
+ )
parser.add_argument(
"-z",
"--pad-headers",
@@ -432,6 +442,7 @@ def main(outputFile, appPath, *args):
parsed.root,
parsed.root_key,
parsed.cert_validity,
+ parsed.tamper_spki,
[hashNameToFunctionAndIdentifier(h) for h in parsed.manifest_hash],
[hashNameToFunctionAndIdentifier(h) for h in parsed.signature_hash],
parsed.pkcs7_hash,
diff --git a/security/manager/ssl/tests/unit/test_signed_apps.js b/security/manager/ssl/tests/unit/test_signed_apps.js
index 1fe15b0bada..c370b6e9b31 100644
--- a/security/manager/ssl/tests/unit/test_signed_apps.js
+++ b/security/manager/ssl/tests/unit/test_signed_apps.js
@@ -534,6 +534,18 @@ add_signature_test(COSEOnly, function () {
);
});
+add_signature_test(COSEOnly, function () {
+ certdb.openSignedAppFileAsync(
+ Ci.nsIX509CertDB.AppXPCShellRoot,
+ original_app_path("tampered-spki"),
+ check_open_result(
+ "tampered-spki",
+ Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID,
+ []
+ )
+ );
+});
+
// Sanity check to ensure a no-op tampering gives a valid result
add_signature_test(PKCS7WithSHA1OrSHA256, function () {
let tampered = tampered_app_path("identity_tampering");
diff --git a/security/manager/ssl/tests/unit/test_signed_apps/moz.build b/security/manager/ssl/tests/unit/test_signed_apps/moz.build
index 603549de778..5ad469c41fa 100644
--- a/security/manager/ssl/tests/unit/test_signed_apps/moz.build
+++ b/security/manager/ssl/tests/unit/test_signed_apps/moz.build
@@ -71,6 +71,7 @@ def SignedAppFile(name, flags, app_directory="app/"):
# SignedAppFile('validity_expired.zip', ['-c', 'ES256', '-p', 'sha256', '--cert-validity', '19700101-19701212'])
# SignedAppFile('validity_not_yet_valid.zip', ['-c', 'ES256', '-p', 'sha256', '--cert-validity', '99990101-99991212'])
# SignedAppFile('alternate-root.zip', ['-k', 'alternate', '-c', 'ES256', '-p', 'sha256'])
+# SignedAppFile('tampered-spki.zip', ['-c', 'ES256', '-t'])
# To generate a new entry, add SignedAppFile, run mach build and copy from
# objdir/_tests/xpcshell/security/manager/ssl/tests/unit/test_signed_apps/
diff --git a/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip b/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip
new file mode 100644
index 00000000000..7155dc07cbe
Binary files /dev/null and b/security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip differ