Cloudron makes it easy to run web apps like WordPress, Nextcloud, GitLab on your server. Find out more or install now.


Skip to content
  • Categories
  • Recent
  • Tags
  • Popular
  • Bookmarks
  • Search
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Default (No Skin)
  • No Skin
Collapse
Brand Logo

Cloudron Forum

Offical apps | Community apps | Demo | Docs | Install
  1. Cloudron Forum
  2. Discuss
  3. Global Security Issue
Everything related to the PHP CVE and eMudhra Cert Please read our Security issue thread - PHP and eMudhra cert compromise.

Global Security Issue

Scheduled Pinned Locked Moved Discuss
securitytls
10 Posts 6 Posters 259 Views 7 Watching
  • Oldest to Newest
  • Newest to Oldest
  • Most Votes
Reply
  • Reply as topic
Log in to reply
This topic has been deleted. Only users with topic management privileges can see it.
  • L
    L
    LoudLemur
    CA Maintainer
    wrote last edited by
    #1

    https://arstechnica.com/security/2026/10/the-internet-goes-on-high-alert-after-discovery-of-8-compromised-tls-certs/

    Internet encryption is currently having a nuclear meltdown.

    ​Cryptographers are calling it "bigger than Heartbleed."

    ​A global Certificate Authority just leaked 8 master private keys that allow anyone to impersonate any server on Earth (Google, banks, government sites).

    1 Reply Last reply
    5
    • nebulonN
      nebulonN
      nebulon
      Staff
      wrote last edited by nebulon
      #2

      We are monitoring that also. The impact is a bit limited and only depends on apps which call out to other servers from a cloudron perspective. So n8n with its outbound calling nodes is a higher risk, but many other apps don't depend on 3rd party services as much which could get MITMd.

      Make sure to update your browsers and phone and such though!

      1 Reply Last reply
      6
      • J
        J
        joseph
        Staff
        wrote last edited by
        #3

        The PHP issue seems to be rather more severe. Even https://nextcloud.com is down/gone.

        1 Reply Last reply
        6
        • J
          J
          joseph
          Staff
          wrote last edited by
          #4

          image.jpeg

          A 1 Reply Last reply
          4
          • J joseph

            image.jpeg

            A
            A
            AartJansen
            wrote last edited by
            #5

            @joseph its back up now.

            1 Reply Last reply
            1
            • L
              L
              LoudLemur
              CA Maintainer
              wrote last edited by
              #6

              This security issue began way back in April 2025 and has been there to exploit since then till Oct 17th, 2026, just a few days ago.

              1 Reply Last reply
              0
              • girishG
                girishG
                girish
                Staff
                wrote last edited by
                #7

                https://eliastaylor.com/noexploit/ is an interesting read. I have to admit despite all the frenzy, I haven't seen any code exploit yet. Do you guys happen to have testing something which shows the issue? The way it was publicized, it was as if I can run a simple curl command and run random commands remotely.

                L S 2 Replies Last reply
                1
                • girishG girish

                  https://eliastaylor.com/noexploit/ is an interesting read. I have to admit despite all the frenzy, I haven't seen any code exploit yet. Do you guys happen to have testing something which shows the issue? The way it was publicized, it was as if I can run a simple curl command and run random commands remotely.

                  L
                  L
                  LoudLemur
                  CA Maintainer
                  wrote last edited by nebulon
                  #8

                  @girish

                  The two posts below provide emudhra_audit.py, a checker for the October 2026 eMudhra incident. Eight intermediate CA keys in that incident were generated with private values below 256, so anyone can recover them (Mozilla Bugzilla 2083449). Rather than relying on certificate names, the script does three things:

                  It mathematically recovers any EC key with a tiny private value.
                  It matches the sixteen compromised certificates by fingerprint.
                  It flags any trust anchor from which a forged chain could be built.

                  Clients such as curl, OpenSSL, Python, Go and Node do not check revocation by default. They stay exposed until emSign ECC Root CA - G3 is removed from your bundles. Run uv run --python 3.13 --with cryptography emudhra_audit.py --self-test first, then run it on the host. Add --docker to cover every Cloudron app container, and use --host yoursite for your own endpoints. If anything is reported, update ca-certificates and your images, then rescan until it comes back clean.

                  -- removed some AI code --

                  1 Reply Last reply
                  0
                  • L
                    L
                    LoudLemur
                    CA Maintainer
                    wrote last edited by joseph
                    #9
                    #!/usr/bin/env python3
                    """
                    emudhra_audit.py - audit trust stores, certificate files, containers and live
                    TLS endpoints for exposure to the October 2026 eMudhra weak-key incident
                    (Mozilla Bugzilla 2083449).
                    
                    What it actually checks (instead of just matching names):
                    
                      1. Weak EC keys, mathematically. Every EC certificate it sees (P-256, P-384,
                         P-521) is tested with baby-step giant-step for a private scalar d where
                         d or n-d is below 2^bits (default 2^24, the eMudhra keys were below 2^8).
                         A hit means the private key has been recovered, and it is printed.
                         This finds the eight eMudhra keys without knowing their names, and finds
                         any future key with the same failure mode.
                      2. Known-bad certificates by SHA-256: the eight compromised intermediates and
                         the eight Sectigo cross-certificates over the same keys.
                      3. Trust anchors that can complete a path to the compromised keys
                         (emSign ECC Root CA - G3, emSign Root TLS CA - G3, emSign Root CS CA - G3,
                         and the Sectigo "AAA Certificate Services" cross-signer). An attacker
                         supplies the intermediate in the handshake, so any client that trusts one
                         of these anchors and does not consult revocation data is exposed.
                      4. Optional proof of a path: given the compromised intermediates as files
                         (or --fetch-ct to download them from crt.sh), it verifies which anchors in
                         each store cryptographically signed them.
                      5. Live endpoints: fetches the chain a server presents, runs the checks
                         above, and warns when the chain sits under the emSign ECC G3 hierarchy
                         that browsers are removing.
                    
                    Nothing is silently skipped: unreadable files and unparseable PEM blocks are
                    reported.
                    
                    Needs Python 3.11+ and the "cryptography" package; Python 3.13+ is needed to see
                    the full chain a server presents (older versions inspect the leaf only).
                    Tested with Python 3.13 and cryptography 50.
                    Easiest: uv run --python 3.13 --with cryptography emudhra_audit.py
                    Exit status: 0 clean, 1 warnings, 2 critical findings, 3 errors only.
                    """
                    from __future__ import annotations
                    
                    import argparse
                    import base64
                    import hashlib
                    import json
                    import math
                    import os
                    import re
                    import shutil
                    import socket
                    import ssl
                    import subprocess
                    import sys
                    import urllib.request
                    import warnings
                    from dataclasses import asdict, dataclass, field
                    from pathlib import Path
                    
                    from cryptography import x509
                    from cryptography.hazmat.primitives import hashes, serialization
                    from cryptography.hazmat.primitives.asymmetric import ec
                    from cryptography.x509.oid import NameOID
                    
                    # Old roots with non-positive serials are still real roots; do not let a warning hide output.
                    warnings.filterwarnings("ignore", category=UserWarning, module="cryptography")
                    warnings.filterwarnings("ignore", message=".*serial number.*")
                    
                    # --------------------------------------------------------------------------
                    # Incident data (source: Mozilla Bugzilla 2083449, comments 0 and 2)
                    # --------------------------------------------------------------------------
                    KNOWN_BAD_SHA256 = {
                        # Issued by emSign Root TLS CA - G3 / emSign Root CS CA - G3
                        "E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838": "EM EV TLS CA - G3B",
                        "98B896625C0C230ADEEC3EB2CF6AAD5001ECF9156A68644E82374CE5833BC25B": "EM OV TLS CA - G3B",
                        "42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493": "EM DV TLS CA - G3B",
                        "432EC25F0D28DE9006D97BF752F17BBB2CD41F669933FCB072D9A1148D0261F3": "EM EV TLS CA - G3C",
                        "CB6E15165AC802E9B3F993F16C25D41F4E81C21EBDE71AE1B012AC00BF9C7C3F": "EM OV TLS CA - G3C",
                        "0164D7CEAE5BB909C272AF151FF44B1177AE9B47D9424B94F1D9558C30934D19": "EM DV TLS CA - G3C",
                        "9E39DE2881273ACC225A149C72BA3E0F51424963669AB633EE1DC7DF5DB067B4": "EM OV CS CA - G3B",
                        "4B2460A466F95FD40113BCDCCA2CEB29B3B093921DE45474DA7DF48874C630B1": "EM OV CS CA - G3C",
                        # Sectigo (AAA Certificate Services) cross-certificates over the same keys
                        "579480AEA166290BD3C643589E795FC0D0E54E86505D180F1DFF99EFD3199512": "EM EV TLS CA - G3B (Sectigo cross)",
                        "5F1DED4DD590AF74631290CF073B122A2AAC6A1FA479778A0BD43579BD832699": "EM OV TLS CA - G3B (Sectigo cross)",
                        "45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE": "EM DV TLS CA - G3B (Sectigo cross)",
                        "FD9D5C2DBBD4995AB69B8D50C439D9527B00529199C2FD01B2174EB58F07021F": "EM EV TLS CA - G3C (Sectigo cross)",
                        "7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489": "EM OV TLS CA - G3C (Sectigo cross)",
                        "62B7F9868263F72F6911FC78CEAF72C1979158B7368BF648683BB2AA39B02E72": "EM DV TLS CA - G3C (Sectigo cross)",
                        "A496E23AC0CAB8155FC9B117B97EAE368EFCBB9041AEC8804D5A308D2D01C2D8": "EM OV CS CA - G3B (Sectigo cross)",
                        "84112972B8E59D14560AE7E7119654474AAD119990CF3722BFC1E3B91615E1FD": "EM OV CS CA - G3C (Sectigo cross)",
                    }
                    
                    # Anchors from which a path to the compromised keys can be built.
                    EXPOSING_ANCHORS = {
                        "emSign ECC Root CA - G3": ("CRITICAL", "root removed by Mozilla (NSS) and Chrome for this incident"),
                        "emSign Root TLS CA - G3": ("CRITICAL", "directly signed the six compromised TLS intermediates"),
                        "emSign Root CS CA - G3": ("CRITICAL", "directly signed the two compromised code signing intermediates"),
                        "AAA Certificate Services": ("WARN", "cross-signed all eight compromised keys; the cross-certificates are "
                                                     "revoked, so you are exposed only if your clients ignore revocation"),
                    }
                    
                    # Issuers whose leaf certificates live under emSign ECC Root CA - G3 (Bugzilla 2083449 comment 30).
                    G3_HIERARCHY_ISSUERS = {
                        "emSign Root TLS CA - G3", "InCommon Intermediate CA - OVG3C", "InCommon Intermediate CA - DVG3C",
                        "EM EV TLS CA - G3A-1", "EM OV TLS CA - G3A-1", "emSign ECC SSL CA - G3",
                        "emSign ECC EV SSL CA - G3", "emSign TLS CA - G3",
                    } | {v for v in KNOWN_BAD_SHA256.values() if "cross" not in v}
                    
                    STORE_PATHS = [
                        "/etc/ssl/certs/ca-certificates.crt",                   # Debian, Ubuntu, Cloudron host, Alpine
                        "/etc/pki/tls/certs/ca-bundle.crt",                     # Fedora, RHEL, Bazzite
                        "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",    # Fedora, RHEL
                        "/etc/pki/ca-trust/extracted/openssl/ca-bundle.trust.crt",
                        "/etc/ssl/cert.pem",                                    # Alpine, BSD, macOS
                        "/etc/ssl/ca-bundle.pem",                               # openSUSE
                        "/var/lib/ca-certificates/ca-bundle.pem",               # openSUSE
                        "/etc/ca-certificates/extracted/tls-ca-bundle.pem",     # Arch
                    ]
                    ENV_VARS = ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "GIT_SSL_CAINFO"]
                    CERT_SUFFIXES = {".pem", ".crt", ".cer", ".der", ".cert", ".ca-bundle"}
                    SEVERITY_ORDER = {"CRITICAL": 3, "WARN": 2, "INFO": 1, "ERROR": 0}
                    
                    # --------------------------------------------------------------------------
                    # Elliptic curve arithmetic for the weak-key search
                    # --------------------------------------------------------------------------
                    CURVE_PARAMS = {
                        "secp256r1": (2**256 - 2**224 + 2**192 + 2**96 - 1,
                                      0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551, ec.SECP256R1),
                        "secp384r1": (2**384 - 2**128 - 2**96 + 2**32 - 1,
                                      0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973,
                                      ec.SECP384R1),
                        "secp521r1": (2**521 - 1,
                                      0x01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409,
                                      ec.SECP521R1),
                    }
                    
                    
                    class Curve:
                        """Affine short-Weierstrass arithmetic with a = -3 (all NIST prime curves)."""
                    
                        def __init__(self, name: str):
                            self.name = name
                            self.p, self.n, self.cls = CURVE_PARAMS[name]
                            self.a = self.p - 3
                            self.G = self.pub_of(1)
                            self._tables: dict[int, dict[int, int]] = {}
                            # Self-test: 2G and (n-1)G from the library must match our arithmetic,
                            # which validates p, a and n before any result is trusted.
                            if self.add(self.G, self.G) != self.pub_of(2) or self.pub_of(self.n - 1) != self.neg(self.G):
                                raise RuntimeError(f"curve self-test failed for {name}")
                    
                        def pub_of(self, d: int):
                            nums = ec.derive_private_key(d, self.cls()).public_key().public_numbers()
                            return (nums.x, nums.y)
                    
                        def neg(self, P):
                            return None if P is None else (P[0], (-P[1]) % self.p)
                    
                        def add(self, P, Q):
                            if P is None:
                                return Q
                            if Q is None:
                                return P
                            p = self.p
                            if P[0] == Q[0]:
                                if (P[1] + Q[1]) % p == 0:
                                    return None
                                lam = (3 * P[0] * P[0] + self.a) * pow(2 * P[1], -1, p) % p
                            else:
                                lam = (Q[1] - P[1]) * pow(Q[0] - P[0], -1, p) % p
                            x = (lam * lam - P[0] - Q[0]) % p
                            return (x, (lam * (P[0] - x) - P[1]) % p)
                    
                        def baby_table(self, m: int) -> dict[int, int]:
                            if m not in self._tables:
                                table, P = {}, None
                                for j in range(1, m + 1):
                                    P = self.add(P, self.G)
                                    table.setdefault(P[0], j)
                                self._tables[m] = table
                            return self._tables[m]
                    
                        def small_dlog(self, Q, bits: int) -> int | None:
                            """Return d with d*G == Q if d or n-d is below roughly 2^bits, else None."""
                            m = 1 << ((bits + 1) // 2)
                            table = self.baby_table(m)
                            giant = self.neg(self.pub_of(m))  # -(m*G)
                            for target, sign in ((Q, 1), (self.neg(Q), -1)):
                                R = target
                                for i in range(m + 1):
                                    if R is None:
                                        cands = [i * m]
                                    elif R[0] in table:
                                        j = table[R[0]]
                                        cands = [i * m + j, i * m - j]
                                    else:
                                        R = self.add(R, giant)
                                        continue
                                    for c in cands:
                                        d = (sign * c) % self.n
                                        if d and self.pub_of(d) == Q:
                                            return d
                                    R = self.add(R, giant)
                            return None
                    
                    
                    _CURVES: dict[str, Curve] = {}
                    
                    
                    def curve(name: str) -> Curve | None:
                        if name not in CURVE_PARAMS:
                            return None
                        if name not in _CURVES:
                            _CURVES[name] = Curve(name)
                        return _CURVES[name]
                    
                    
                    # --------------------------------------------------------------------------
                    # Certificate loading (reports problems instead of hiding them)
                    # --------------------------------------------------------------------------
                    PEM_RE = re.compile(rb"-----BEGIN (TRUSTED )?CERTIFICATE-----(.+?)-----END (?:TRUSTED )?CERTIFICATE-----", re.S)
                    
                    
                    def first_der_tlv(data: bytes) -> bytes:
                        """Slice the first DER element (strips OpenSSL 'TRUSTED CERTIFICATE' aux data)."""
                        if len(data) < 2 or data[0] != 0x30:
                            raise ValueError("not a DER SEQUENCE")
                        length, hdr = data[1], 2
                        if length & 0x80:
                            nbytes = length & 0x7F
                            length, hdr = int.from_bytes(data[2:2 + nbytes], "big"), 2 + nbytes
                        return data[:hdr + length]
                    
                    
                    def load_certs(blob: bytes) -> tuple[list[x509.Certificate], list[str]]:
                        certs, problems = [], []
                        blocks = PEM_RE.findall(blob)
                        if not blocks:
                            try:
                                return [x509.load_der_x509_certificate(first_der_tlv(blob))], []
                            except Exception:
                                return [], []  # not a certificate file at all; caller decides whether that matters
                        for idx, (trusted, body) in enumerate(blocks, 1):
                            try:
                                der = base64.b64decode(b"".join(body.split()))
                                certs.append(x509.load_der_x509_certificate(first_der_tlv(der) if trusted else der))
                            except Exception as exc:  # noqa: BLE001 - every failure is reported
                                problems.append(f"PEM block {idx}: {type(exc).__name__}: {exc}")
                        return certs, problems
                    
                    
                    def cn(name: x509.Name) -> str:
                        attrs = name.get_attributes_for_oid(NameOID.COMMON_NAME)
                        return str(attrs[0].value) if attrs else name.rfc4514_string()
                    
                    
                    def sha256_fp(cert: x509.Certificate) -> str:
                        return cert.fingerprint(hashes.SHA256()).hex().upper()
                    
                    
                    def spki_sha256(cert: x509.Certificate) -> str:
                        spki = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
                        return base64.b64encode(hashlib.sha256(spki).digest()).decode()
                    
                    
                    # --------------------------------------------------------------------------
                    # Findings
                    # --------------------------------------------------------------------------
                    @dataclass
                    class Finding:
                        severity: str
                        source: str
                        message: str
                        subject: str = ""
                        sha256: str = ""
                        detail: dict = field(default_factory=dict)
                    
                    
                    class Audit:
                        def __init__(self, bits: int, verbose: bool):
                            self.bits, self.verbose = bits, verbose
                            self.findings: list[Finding] = []
                            self.stats = {"sources": 0, "certificates": 0, "ec_keys_tested": 0}
                            self._dlog_cache: dict[str, int | None] = {}
                            self.stores: dict[str, list[x509.Certificate]] = {}
                    
                        def add(self, *a, **kw):
                            self.findings.append(Finding(*a, **kw))
                    
                        def weak_key(self, cert: x509.Certificate) -> int | None:
                            key = cert.public_key()
                            if not isinstance(key, ec.EllipticCurvePublicKey):
                                return None
                            c = curve(key.curve.name)
                            if c is None:
                                return None
                            nums = key.public_numbers()
                            cache_key = f"{key.curve.name}:{nums.x:x}"
                            if cache_key not in self._dlog_cache:
                                self.stats["ec_keys_tested"] += 1
                                self._dlog_cache[cache_key] = c.small_dlog((nums.x, nums.y), self.bits)
                            return self._dlog_cache[cache_key]
                    
                        def check_cert(self, cert: x509.Certificate, source: str, role: str):
                            self.stats["certificates"] += 1
                            fp, subj = sha256_fp(cert), cn(cert.subject)
                            d = self.weak_key(cert)
                            if d is not None:
                                n = curve(cert.public_key().curve.name).n
                                shown = str(d) if d < n // 2 else f"n - {n - d}"
                                self.add("CRITICAL", source, f"private key RECOVERED (d = {shown}); anyone can sign with this key",
                                         subj, fp, {"curve": cert.public_key().curve.name, "private_scalar_hex": format(d, "x"),
                                                    "spki_sha256_b64": spki_sha256(cert), "is_ca": is_ca(cert)})
                            if fp in KNOWN_BAD_SHA256:
                                self.add("CRITICAL", source, f"known compromised certificate present: {KNOWN_BAD_SHA256[fp]}", subj, fp)
                            if role == "store" and subj in EXPOSING_ANCHORS and cert.subject == cert.issuer:
                                sev, why = EXPOSING_ANCHORS[subj]
                                self.add(sev, source, f"trust anchor present: {subj} ({why})", subj, fp,
                                         {"serial": format(cert.serial_number, "x")})
                            elif role == "store" and subj in EXPOSING_ANCHORS:
                                sev, why = EXPOSING_ANCHORS[subj]
                                self.add(sev, source, f"certificate for {subj} present in store (not self-signed; {why})", subj, fp)
                            elif self.verbose and re.search(r"emsign|emudhra", cert.subject.rfc4514_string(), re.I):
                                self.add("INFO", source, "other eMudhra/emSign certificate (not implicated by this incident)", subj, fp)
                    
                        # ---- sources ----
                        def scan_blob(self, blob: bytes, source: str, role: str = "store", quiet_nonpem: bool = False):
                            certs, problems = load_certs(blob)
                            if not certs and not problems:
                                if not quiet_nonpem:
                                    self.add("ERROR", source, "no certificates found in this file")
                                return
                            self.stats["sources"] += 1
                            for p in problems:
                                self.add("ERROR", source, f"could not parse {p}")
                            for c in certs:
                                self.check_cert(c, source, role)
                            if role == "store":
                                self.stores[source] = certs
                    
                        def scan_path(self, path: Path, role: str = "store"):
                            try:
                                if path.is_dir():
                                    for f in sorted(path.rglob("*")):
                                        if f.is_file() and (f.suffix.lower() in CERT_SUFFIXES or f.suffix == "" or f.suffix == ".0"):
                                            self.scan_path_file(f, role, quiet_nonpem=True)
                                else:
                                    self.scan_path_file(path, role)
                            except OSError as exc:
                                self.add("ERROR", str(path), f"unreadable: {exc}")
                    
                        def scan_path_file(self, f: Path, role: str, quiet_nonpem: bool = False):
                            try:
                                if f.stat().st_size > 50_000_000:
                                    return
                                self.scan_blob(f.read_bytes(), str(f), role, quiet_nonpem)
                            except OSError as exc:
                                self.add("ERROR", str(f), f"unreadable: {exc}")
                    
                        def scan_container(self, engine: str, name: str):
                            found = False
                            for p in STORE_PATHS:
                                r = subprocess.run([engine, "exec", name, "cat", p], capture_output=True, timeout=60)
                                if r.returncode == 0 and r.stdout:
                                    found = True
                                    self.scan_blob(r.stdout, f"{engine}:{name}:{p}", "store")
                            if not found:
                                self.add("ERROR", f"{engine}:{name}", "no known trust bundle path readable in container")
                    
                        def scan_host(self, target: str, timeout: float):
                            m = re.fullmatch(r"\[?([^\[\]]+?)\]?(?::(\d+))?", target)
                            host, port = (m.group(1), int(m.group(2) or 443)) if m else (target, 443)
                            src = f"tls://{host}:{port}"
                            ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
                            ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
                            try:
                                with socket.create_connection((host, port), timeout=timeout) as raw, \
                                        ctx.wrap_socket(raw, server_hostname=host) as s:
                                    chain = s.get_unverified_chain() if hasattr(s, "get_unverified_chain") else None
                                    ders = [c if isinstance(c, bytes) else c.public_bytes(ssl.ENCODING_DER) for c in chain] if chain \
                                        else [s.getpeercert(binary_form=True)]
                            except (OSError, ssl.SSLError) as exc:
                                self.add("ERROR", src, f"connection failed: {exc}")
                                return
                            self.stats["sources"] += 1
                            certs = [x509.load_der_x509_certificate(d) for d in ders]
                            if chain is None:
                                self.add("INFO", src, "Python < 3.13: only the leaf certificate could be inspected")
                            for c in certs:
                                self.check_cert(c, src, "chain")
                            issuers = {cn(c.issuer) for c in certs} | {cn(c.subject) for c in certs[1:]}
                            hit = sorted(issuers & G3_HIERARCHY_ISSUERS)
                            if hit:
                                self.add("WARN", src, "served chain sits under emSign ECC Root CA - G3 (" + ", ".join(hit) +
                                         "); replace this certificate, Chrome removes the root on 27 October 2026",
                                         cn(certs[0].subject), sha256_fp(certs[0]))
                            try:
                                vctx = ssl.create_default_context()
                                with socket.create_connection((host, port), timeout=timeout) as raw, \
                                        vctx.wrap_socket(raw, server_hostname=host):
                                    pass
                                self.add("INFO", src, "chain is accepted by this machine's default Python/OpenSSL trust store")
                            except ssl.SSLCertVerificationError as exc:
                                self.add("INFO", src, f"chain is rejected by this machine's default trust store: {exc.verify_message}")
                            except (OSError, ssl.SSLError):
                                pass
                    
                        def path_check(self, intermediates: list[x509.Certificate]):
                            """Cryptographically confirm which store anchors signed the compromised intermediates."""
                            for store, anchors in self.stores.items():
                                for inter in intermediates:
                                    for anchor in anchors:
                                        if anchor.subject != inter.issuer:
                                            continue
                                        try:
                                            inter.verify_directly_issued_by(anchor)
                                        except Exception:
                                            continue
                                        self.add("CRITICAL", store,
                                                 f"anchor '{cn(anchor.subject)}' validly signs compromised '{cn(inter.subject)}': "
                                                 "a forged chain verifies against this store unless revocation is enforced",
                                                 cn(anchor.subject), sha256_fp(anchor), {"intermediate_sha256": sha256_fp(inter)})
                    
                    
                    def is_ca(cert: x509.Certificate) -> bool:
                        try:
                            return cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
                        except x509.ExtensionNotFound:
                            return False
                    
                    
                    # --------------------------------------------------------------------------
                    # Discovery helpers
                    # --------------------------------------------------------------------------
                    def default_store_paths() -> list[Path]:
                        cands = list(STORE_PATHS)
                        dvp = ssl.get_default_verify_paths()
                        cands += [x for x in (dvp.cafile, dvp.openssl_cafile) if x]
                        cands += [os.environ[v] for v in ENV_VARS if os.environ.get(v)]
                        try:
                            import certifi  # type: ignore
                            cands.append(certifi.where())
                        except ImportError:
                            pass
                        seen, out = set(), []
                        for c in cands:
                            p = Path(c)
                            if p.is_file() and (rp := p.resolve()) not in seen:
                                seen.add(rp)
                                out.append(p)
                        return out
                    
                    
                    def fetch_ct(cache: Path) -> list[x509.Certificate]:
                        cache.mkdir(parents=True, exist_ok=True)
                        out = []
                        for fp, label in KNOWN_BAD_SHA256.items():
                            f = cache / f"{fp}.pem"
                            if not f.exists():
                                req = urllib.request.Request(f"https://crt.sh/?d={fp}", headers={"User-Agent": "emudhra_audit"})
                                try:
                                    with urllib.request.urlopen(req, timeout=60) as r:
                                        f.write_bytes(r.read())
                                except OSError as exc:
                                    print(f"[ERROR] could not fetch {label} from crt.sh: {exc}", file=sys.stderr)
                                    continue
                            certs, _ = load_certs(f.read_bytes())
                            if certs and sha256_fp(certs[0]) == fp:
                                out.append(certs[0])
                            else:
                                print(f"[ERROR] {f} does not contain the expected certificate {fp}", file=sys.stderr)
                        return out
                    
                    
                    def self_test(bits: int) -> int:
                        print(f"Self-test: generating keys with tiny private scalars and recovering them (bits={bits})")
                        ok = True
                        for name in CURVE_PARAMS:
                            c = curve(name)
                            for d in (4, 155, 237, (1 << (bits - 1)) + 12345, c.n - 56):
                                got = c.small_dlog(c.pub_of(d), bits)
                                ok &= got == d
                                print(f"  {name:10} d={d if d < c.n // 2 else 'n-' + str(c.n - d):>12}  recovered={got == d}")
                            strong = ec.generate_private_key(c.cls()).public_key().public_numbers()
                            miss = c.small_dlog((strong.x, strong.y), bits) is None
                            ok &= miss
                            print(f"  {name:10} random key correctly NOT recovered={miss}")
                        print("SELF-TEST PASSED" if ok else "SELF-TEST FAILED")
                        return 0 if ok else 2
                    
                    
                    # --------------------------------------------------------------------------
                    # CLI
                    # --------------------------------------------------------------------------
                    def main() -> int:
                        ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter)
                        ap.add_argument("paths", nargs="*", help="bundle files or directories to scan (default: discover system stores)")
                        ap.add_argument("--host", action="append", default=[], metavar="HOST[:PORT]", help="inspect a live TLS endpoint")
                        ap.add_argument("--docker", nargs="*", metavar="CONTAINER", help="scan trust bundles inside containers (all running if none named)")
                        ap.add_argument("--engine", default="docker", help="container engine binary (docker or podman)")
                        ap.add_argument("--intermediates", nargs="*", default=[], metavar="FILE", help="compromised intermediates to path-check against stores")
                        ap.add_argument("--fetch-ct", action="store_true", help="download the 16 compromised certificates from crt.sh for the path check")
                        ap.add_argument("--bits", type=int, default=24, help="weak-key search bound, 2^bits (default 24; 32 is thorough but slower)")
                        ap.add_argument("--timeout", type=float, default=10.0)
                        ap.add_argument("--json", action="store_true", help="machine-readable output")
                        ap.add_argument("-v", "--verbose", action="store_true", help="also list unrelated eMudhra/emSign certificates")
                        ap.add_argument("--self-test", action="store_true", help="prove the weak-key detector works, then exit")
                        args = ap.parse_args()
                    
                        if args.self_test:
                            return self_test(args.bits)
                    
                        audit = Audit(args.bits, args.verbose)
                        explicit = bool(args.paths or args.host or args.docker is not None)
                        for p in args.paths or ([] if explicit else default_store_paths()):
                            audit.scan_path(Path(p))
                        if args.docker is not None:
                            if not shutil.which(args.engine):
                                audit.add("ERROR", args.engine, "container engine not found")
                            else:
                                names = args.docker
                                if not names:
                                    r = subprocess.run([args.engine, "ps", "--format", "{{.Names}}"], capture_output=True, text=True)
                                    if r.returncode != 0:
                                        audit.add("ERROR", args.engine, f"could not list containers: {r.stderr.strip()}")
                                    names = r.stdout.split()
                                    if r.returncode == 0 and not names:
                                        audit.add("ERROR", args.engine, "no running containers found")
                                for n in names:
                                    audit.scan_container(args.engine, n)
                        for h in args.host:
                            audit.scan_host(h, args.timeout)
                    
                        inters: list[x509.Certificate] = []
                        for f in args.intermediates:
                            certs, _ = load_certs(Path(f).read_bytes())
                            inters += certs
                        if args.fetch_ct:
                            inters += fetch_ct(Path.home() / ".cache" / "emudhra_audit")
                        for c in inters:
                            audit.check_cert(c, "supplied-intermediates", "chain")
                        if inters:
                            audit.path_check(inters)
                    
                        findings = sorted(audit.findings, key=lambda f: -SEVERITY_ORDER[f.severity])
                        if args.json:
                            print(json.dumps({"stats": audit.stats, "findings": [asdict(f) for f in findings]}, indent=2))
                        else:
                            for f in findings:
                                print(f"[{f.severity}] {f.source}\n    {f.message}")
                                if f.subject:
                                    print(f"    subject: {f.subject}")
                                if f.sha256:
                                    print(f"    sha256:  {f.sha256}")
                                for k, v in f.detail.items():
                                    print(f"    {k}: {v}")
                            s = audit.stats
                            print(f"\nScanned {s['sources']} sources, {s['certificates']} certificates, "
                                  f"{s['ec_keys_tested']} distinct EC keys tested for d < 2^{args.bits} (and n - d).")
                            if not s["sources"]:
                                print("Nothing was scanned. Pass a bundle path, --host or --docker.")
                        sev = {f.severity for f in findings}
                        if not audit.stats["sources"]:
                            return 3
                        if "CRITICAL" in sev:
                            return 2
                        if "WARN" in sev:
                            return 1
                        return 3 if "ERROR" in sev else 0
                    
                    
                    if __name__ == "__main__":
                        sys.exit(main())
                    
                    1 Reply Last reply
                    0
                    • girishG girish

                      https://eliastaylor.com/noexploit/ is an interesting read. I have to admit despite all the frenzy, I haven't seen any code exploit yet. Do you guys happen to have testing something which shows the issue? The way it was publicized, it was as if I can run a simple curl command and run random commands remotely.

                      S
                      S
                      shore2829
                      wrote last edited by shore2829
                      #10

                      @girish
                      Weird blog to be honest. Only two posts with the first one telling us LLMs will hack everything on the internet and the second one ranting about how LLMs didn't find an easy hack but deceived us.
                      I am not skilled enough of doing any testing myself but I am thankful for your overtime hours and acting as fast as you did. If it happens to be not as bad as everyone feared, we can happily move on.

                      As for me, I took it as an urgent reason to finally switch a legacy website. Working under pressure gets things done faster. 😉

                      1 Reply Last reply
                      5

                      Hello! It looks like you're interested in this conversation, but you don't have an account yet.

                      Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.

                      With your input, this post could be even better 💗

                      Register Login
                      Reply
                      • Reply as topic
                      Log in to reply
                      • Oldest to Newest
                      • Newest to Oldest
                      • Most Votes


                      • Login

                      • Don't have an account? Register

                      • Login or register to search.
                      • First post
                        Last post
                      0
                      • Categories
                      • Recent
                      • Tags
                      • Popular
                      • Bookmarks
                      • Search