Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionInvalid pointer in the Security: PSM component
ComponentSecurity/NSS
Bug ClassLogic Error
Tracker2052134
Fix commit5c2e3f1bea94 (firefox) +47/-8
CISA KEVNot listed
CreditedArtur Cygan of Trail of Bits in partnership with OpenAI
Disclosed2026-07-21

Background

PSM (Platform Security Module)
Gecko’s security/manager glue 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 pycert option 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.

Key insight
The single mistake was consuming the embedded certificate’s public key before validating that its 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

  1. Craft a bad SPKI An attacker builds an end-entity certificate whose SubjectPublicKeyInfo uses an unknown algorithm OID and an empty subjectPublicKey bit string.
  2. Wrap it in a COSE signature The tampered certificate is embedded in the COSE signature of a signed app’s manifest.
  3. Submit the app for verification The app is opened through nsIX509CertDB.openSignedAppFileAsync, driving the PSM COSE verification path.
  4. Trigger the invalid pointer Verification extracts and uses the undecoded public key handle, dereferencing an invalid pointer instead of returning an error.

Impact Assessment

An attacker who supplies a maliciously signed app gains a controlled invalid-pointer dereference in the process performing signed-app verification (PSM/NSS in the parent), reachable via 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

FunctionChangeNotes
add_signature_test
security/manager/ssl/tests/unit/test_signed_apps.js
modified

Files Changed

  • security/manager/ssl/tests/unit/sign_app.py
  • security/manager/ssl/tests/unit/test_signed_apps.js
  • security/manager/ssl/tests/unit/test_signed_apps/moz.build
  • security/manager/ssl/tests/unit/test_signed_apps/tampered-spki.zip
  • security/manager/tools/pycert.py

Audit Directions

  • Unvalidated key extraction
    Audit all COSE/JAR verification paths that pull a public key from an embedded certificate’s SubjectPublicKeyInfo and confirm parse success is checked before the handle is used.
  • Unknown-algorithm handling
    Review 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 coverage
    Look 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.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…