Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions SPECS/python3/CVE-2026-115557.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
From: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
Subject: [PATCH] [3.12] Distrust compromised eMudhra intermediate CA keys

Backport the upstream 3.12 runtime fix and Linux build metadata unchanged.
Omit the Lib/test changes, binary fixture and Windows project metadata;
validate the fix separately.

CVE: CVE-2026-115557
Source: https://github.com/python/cpython/commit/a17d9ef549008133eadf20689b1f4787902efe5b
Upstream-status: Backport

Co-authored-by: Christian Heimes <christian@python.org>

diff --git a/Makefile.pre.in b/Makefile.pre.in
--- a/Makefile.pre.in
+++ b/Makefile.pre.in
@@ -2828,7 +2828,7 @@ MODULE__SHA1_DEPS=$(srcdir)/Modules/hashlib.h $(LIBHACL_HEADERS) Modules/_hacl/H
MODULE__SHA2_DEPS=$(srcdir)/Modules/hashlib.h $(LIBHACL_SHA2_HEADERS) $(LIBHACL_SHA2_A)
MODULE__SHA3_DEPS=$(srcdir)/Modules/hashlib.h $(LIBHACL_HEADERS) Modules/_hacl/Hacl_Hash_SHA3.h Modules/_hacl/Hacl_Hash_SHA3.c
MODULE__SOCKET_DEPS=$(srcdir)/Modules/socketmodule.h $(srcdir)/Modules/addrinfo.h $(srcdir)/Modules/getaddrinfo.c $(srcdir)/Modules/getnameinfo.c
-MODULE__SSL_DEPS=$(srcdir)/Modules/_ssl.h $(srcdir)/Modules/_ssl/cert.c $(srcdir)/Modules/_ssl/debughelpers.c $(srcdir)/Modules/_ssl/misc.c $(srcdir)/Modules/_ssl_data.h $(srcdir)/Modules/_ssl_data_111.h $(srcdir)/Modules/_ssl_data_300.h $(srcdir)/Modules/socketmodule.h
+MODULE__SSL_DEPS=$(srcdir)/Modules/_ssl.h $(srcdir)/Modules/_ssl/cert.c $(srcdir)/Modules/_ssl/debughelpers.c $(srcdir)/Modules/_ssl/misc.c $(srcdir)/Modules/_ssl/distrusted_spki.h $(srcdir)/Modules/_ssl_data.h $(srcdir)/Modules/_ssl_data_111.h $(srcdir)/Modules/_ssl_data_300.h $(srcdir)/Modules/socketmodule.h
MODULE__TESTCAPI_DEPS=$(srcdir)/Modules/_testcapi/testcapi_long.h $(srcdir)/Modules/_testcapi/parts.h $(srcdir)/Modules/_testcapi/util.h
MODULE__SQLITE3_DEPS=$(srcdir)/Modules/_sqlite/connection.h $(srcdir)/Modules/_sqlite/cursor.h $(srcdir)/Modules/_sqlite/microprotocols.h $(srcdir)/Modules/_sqlite/module.h $(srcdir)/Modules/_sqlite/prepare_protocol.h $(srcdir)/Modules/_sqlite/row.h $(srcdir)/Modules/_sqlite/util.h

diff --git a/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst b/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst
new file mode 100644
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst
@@ -0,0 +1,12 @@
+.. cve: CVE-2026-115557
+
+:mod:`ssl` now rejects TLS certificate chains that contain any of a set of
+compromised eMudhra intermediate CA keys. This is an extraordinary measure and
+a last line of defense: removing trust is primarily the job of the platform root
+store, and the built-in denylist only blocks connections while that update has
+not yet reached the OS, a container image, or a bundled trust store in various
+Python packages. Entries are added to the denylist only under a very high
+threshold.
+The denylist matches on the public key (the SHA-256 digest of the :rfc:`5280`
+``SubjectPublicKeyInfo``), so it also covers the cross-signed certificates that
+reuse one of the distrusted keys.
diff --git a/Modules/_ssl.c b/Modules/_ssl.c
--- a/Modules/_ssl.c
+++ b/Modules/_ssl.c
@@ -56,6 +56,7 @@
/* Include OpenSSL header files */
#include "openssl/rsa.h"
#include "openssl/crypto.h"
+#include "openssl/evp.h"
#include "openssl/x509.h"
#include "openssl/x509v3.h"
#include "openssl/pem.h"
@@ -2918,11 +2919,70 @@ static PyType_Spec PySSLSocket_spec = {
* _SSLContext objects
*/

+#include "_ssl/distrusted_spki.h"
+
+/* Returns 1 if the SHA-256 of the cert's SPKI is on the distrusted list,
+ 0 if not (okay), -1 on an internal error. */
+static int
+_cert_has_distrusted_spki(X509 *cert)
+{
+ unsigned char *spki = NULL;
+ int spki_len = i2d_X509_PUBKEY(X509_get_X509_PUBKEY(cert), &spki);
+ if (spki_len <= 0 || spki == NULL) {
+ return -1;
+ }
+
+ unsigned char digest[EVP_MAX_MD_SIZE];
+ unsigned int digest_len = 0;
+ int ok = EVP_Digest(spki, (size_t)spki_len, digest, &digest_len,
+ EVP_sha256(), NULL);
+ OPENSSL_free(spki);
+ assert(sizeof(_distrusted_spki[0]) == SHA256_DIGEST_LENGTH);
+ if (!ok
+ || digest_len != SHA256_DIGEST_LENGTH
+ || sizeof(_distrusted_spki[0]) != SHA256_DIGEST_LENGTH) {
+ return -1;
+ }
+
+ for (size_t i = 0; i < Py_ARRAY_LENGTH(_distrusted_spki); i++) {
+ if (memcmp(digest, _distrusted_spki[i], SHA256_DIGEST_LENGTH) == 0) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+/* Verify callback, called once per cert. Reject distrusted SPKIs. */
+static int
+_ssl_verify_callback(int preverify_ok, X509_STORE_CTX *x509_ctx)
+{
+ /* Don't override an error OpenSSL already reported for this certificate. */
+ if (!preverify_ok) {
+ return preverify_ok;
+ }
+ X509 *cert = X509_STORE_CTX_get_current_cert(x509_ctx);
+ if (cert == NULL) {
+ return preverify_ok;
+ }
+ switch (_cert_has_distrusted_spki(cert)) {
+ case 0:
+ /* okay: not on the distrusted list */
+ return preverify_ok;
+ case 1:
+ /* rejected: public key is on the distrusted list */
+ X509_STORE_CTX_set_error(x509_ctx, X509_V_ERR_CERT_REJECTED);
+ return 0;
+ default:
+ /* internal error */
+ X509_STORE_CTX_set_error(x509_ctx, X509_V_ERR_UNSPECIFIED);
+ return 0;
+ }
+}
+
static int
_set_verify_mode(PySSLContext *self, enum py_ssl_cert_requirements n)
{
int mode;
- int (*verify_cb)(int, X509_STORE_CTX *) = NULL;

switch(n) {
case PY_SSL_CERT_NONE:
@@ -2943,9 +3003,7 @@ _set_verify_mode(PySSLContext *self, enum py_ssl_cert_requirements n)
/* bpo-37428: newPySSLSocket() sets SSL_VERIFY_POST_HANDSHAKE flag for
* server sockets and SSL_set_post_handshake_auth() for client. */

- /* keep current verify cb */
- verify_cb = SSL_CTX_get_verify_callback(self->ctx);
- SSL_CTX_set_verify(self->ctx, mode, verify_cb);
+ SSL_CTX_set_verify(self->ctx, mode, _ssl_verify_callback);
return 0;
}

diff --git a/Modules/_ssl/distrusted_spki.h b/Modules/_ssl/distrusted_spki.h
new file mode 100644
--- /dev/null
+++ b/Modules/_ssl/distrusted_spki.h
@@ -0,0 +1,75 @@
+/* Denylist of distrusted SubjectPublicKeyInfo (SPKI).
+ *
+ * Verification rejects any chain with a cert whose SPKI SHA-256 digest is in
+ * the _distrusted_spki table. We match the key, not a cert fingerprint, so
+ * cross-signs that reuse the key are blocked too.
+ *
+ * This is an extraordinary measure and last line of defense. Removing trust is
+ * primarily the job of the platform root store. This list only blocks keys while
+ * that update is still propagating to the OS, container images, or root CAs
+ * bundled in Python packages. The threshold for adding an entry is very high.
+ *
+ * See https://github.com/python/cpython/issues/160040 for the eMudhra discussion.
+ *
+ * To block another key, add the SHA-256 digest bytes of its SPKI:
+ *
+ * $ openssl x509 -in cert.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | xxd -i
+ */
+#ifndef Py_SSL_DISTRUSTED_SPKI_H
+#define Py_SSL_DISTRUSTED_SPKI_H
+
+#include "openssl/sha.h"
+
+static const unsigned char _distrusted_spki[][SHA256_DIGEST_LENGTH] = {
+ /* Compromised eMudhra intermediates, https://emudhra.we-have-a-problem.com/ */
+ /* CN=EM OV TLS CA - G3B,O=eMudhra BV,C=NL */
+ {
+ 0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84, 0x1e,
+ 0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28, 0xab, 0x4b,
+ 0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b,
+ },
+ /* CN=EM DV TLS CA - G3C,O=eMudhra Inc,C=US */
+ {
+ 0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45, 0x11,
+ 0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25, 0x9e, 0xef,
+ 0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f,
+ },
+ /* CN=EM EV TLS CA - G3C,O=eMudhra Inc,C=US */
+ {
+ 0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89, 0xc1,
+ 0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef, 0xe2, 0xcb,
+ 0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0,
+ },
+ /* CN=EM OV TLS CA - G3C,O=eMudhra Inc,C=US */
+ {
+ 0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13, 0x6c,
+ 0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6, 0x06, 0xcd,
+ 0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8,
+ },
+ /* CN=EM OV CS CA - G3C,O=eMudhra Inc,C=US */
+ {
+ 0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88, 0xe1,
+ 0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b, 0xab, 0x14,
+ 0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30,
+ },
+ /* CN=EM EV TLS CA - G3B,O=eMudhra BV,C=NL */
+ {
+ 0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4, 0x28,
+ 0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d, 0x62, 0xf5,
+ 0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41,
+ },
+ /* CN=EM DV TLS CA - G3B,O=eMudhra BV,C=NL */
+ {
+ 0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f, 0x3f,
+ 0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15, 0xd4, 0xe4,
+ 0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05,
+ },
+ /* CN=EM OV CS CA - G3B,O=eMudhra BV,C=NL */
+ {
+ 0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7, 0xaa,
+ 0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb, 0x5b, 0xa6,
+ 0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6,
+ },
+};
+
+#endif /* !Py_SSL_DISTRUSTED_SPKI_H */
14 changes: 13 additions & 1 deletion SPECS/python3/python3.spec
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Summary: A high-level scripting language
Name: python3
Version: 3.12.15
Release: 1%{?dist}
Release: 3%{?dist}
License: PSF
Vendor: Microsoft Corporation
Distribution: Azure Linux
Expand All @@ -17,6 +17,8 @@ Source0: https://www.python.org/ftp/python/%{version}/Python-%{version}.t
# It has been removed in Python-3.12.0.tar.xz, but as our packages still require it, we will still provide for now.
Source1: https://github.com/python/cpython/blob/3.9/Tools/scripts/pathfix.py
Patch0: cgi3.patch
Patch1: remove-compromised-wheel-ca-roots.patch
Patch2: CVE-2026-115557.patch

BuildRequires: bzip2-devel
BuildRequires: expat-devel >= 2.1.0
Expand Down Expand Up @@ -150,6 +152,9 @@ export OPT="%{extension_cflags} %{openssl_flags}"
--enable-optimizations
%make_build

LD_LIBRARY_PATH=$PWD ./python Tools/remove-untrusted-wheel-roots.py \
--expected-removals 2 Lib/ensurepip/_bundled/pip-*.whl

%install
%make_install
%{_fixperms} %{buildroot}/*
Expand Down Expand Up @@ -242,6 +247,13 @@ LD_LIBRARY_PATH=%{buildroot}%{_libdir} \
%{_libdir}/python%{majmin}/test/*

%changelog
* Sun Oct 25 2026 Jon Slobodzian <joslobo@microsoft.com> - 3.12.15-3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this could all go into a single 3.12.15-2 revision

- Backport upstream SSL rejection of compromised eMudhra intermediate keys.

* Fri Oct 23 2026 Jon Slobodzian <joslobo@microsoft.com> - 3.12.15-2
- Remove affected eMudhra and AAA roots from the ensurepip wheel shipped in python3-libs.
- Regenerate the bundled wheel's RECORD without changing other payload files.

* Sat Oct 03 2026 Kanishk Bansal <kanbansal@microsoft.com> - 3.12.15-1
- Upgrade to 3.12.15 for CVE-2026-15310, CVE-2026-15806, CVE-2026-17084,
CVE-2026-19445, CVE-2026-19553, CVE-2026-19672, CVE-2026-82049, CVE-2026-87910
Expand Down
119 changes: 119 additions & 0 deletions SPECS/python3/remove-compromised-wheel-ca-roots.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
Subject: [PATCH] Distrust incident-related eMudhra and AAA trust anchors

Remove only the affected trust anchors, including the AAA cross-signing
path. Preserve the unaffected emSign G1, C1 and C3 anchors.
Reference: https://emudhra.we-have-a-problem.com/
Upstream-status: Azure Linux trust-store backport

--- /dev/null
+++ b/Tools/remove-untrusted-wheel-roots.py
@@ -0,0 +1,109 @@
+"""Remove incident-related trust anchors from bundled wheels and renew RECORD."""
+
+import argparse
+import base64
+import csv
+import hashlib
+import io
+from pathlib import Path
+import re
+import ssl
+import tempfile
+import zipfile
+
+
+UNTRUSTED_SHA256 = {
+ "d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4",
+ "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b",
+}
+CERTIFICATE = re.compile(
+ rb"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+?-----END CERTIFICATE-----"
+)
+
+
+def prepare_wheel(path):
+ with zipfile.ZipFile(path) as archive:
+ infos = archive.infolist()
+ if len({info.filename for info in infos}) != len(infos):
+ raise ValueError(f"{path}: duplicate wheel members")
+ records = [
+ info.filename
+ for info in infos
+ if info.filename.endswith(".dist-info/RECORD")
+ ]
+ if len(records) != 1:
+ raise ValueError(f"{path}: expected exactly one wheel RECORD")
+ record = records[0]
+ if any(
+ name in archive.namelist() for name in (record + ".jws", record + ".p7s")
+ ):
+ raise ValueError(f"{path}: cannot rewrite a signed wheel")
+ payload = {info.filename: archive.read(info) for info in infos}
+
+ removed = 0
+
+ def retain_certificate(match):
+ nonlocal removed
+ der = ssl.PEM_cert_to_DER_cert(match.group().decode("ascii"))
+ if hashlib.sha256(der).hexdigest() in UNTRUSTED_SHA256:
+ removed += 1
+ return b""
+ return match.group()
+
+ for name, data in payload.items():
+ if name.endswith((".pem", ".crt")):
+ payload[name] = CERTIFICATE.sub(retain_certificate, data)
+
+ if not removed:
+ return None, 0
+
+ output = io.StringIO(newline="")
+ writer = csv.writer(output, lineterminator="\n")
+ for info in infos:
+ name = info.filename
+ if info.is_dir():
+ continue
+ if name == record:
+ writer.writerow((name, "", ""))
+ else:
+ data = payload[name]
+ digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest())
+ writer.writerow((name, "sha256=" + digest.rstrip(b"=").decode(), len(data)))
+ payload[record] = output.getvalue().encode("utf-8")
+ return (path, infos, payload), removed
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--expected-removals", type=int, required=True)
+ parser.add_argument("wheels", nargs="+", type=Path)
+ args = parser.parse_args()
+ plans = []
+ total = 0
+ for path in args.wheels:
+ plan, removed = prepare_wheel(path)
+ if plan is not None:
+ plans.append(plan)
+ total += removed
+ print(f"{path}: removing {removed} affected trust anchors")
+ if total != args.expected_removals:
+ raise ValueError(
+ f"Expected {args.expected_removals} trust anchors, found {total}; "
+ "review the bundled wheels before changing this expectation"
+ )
+ for path, infos, payload in plans:
+ with tempfile.NamedTemporaryFile(
+ dir=path.parent, prefix=path.name + ".", suffix=".tmp", delete=False
+ ) as temporary:
+ temporary_path = Path(temporary.name)
+ try:
+ with zipfile.ZipFile(temporary_path, "w") as archive:
+ for info in infos:
+ archive.writestr(info, payload[info.filename])
+ temporary_path.replace(path)
+ finally:
+ temporary_path.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+ main()
6 changes: 3 additions & 3 deletions toolkit/resources/manifests/package/pkggen_core_aarch64.txt
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ ca-certificates-base-3.0.0-19.azl3.noarch.rpm
ca-certificates-3.0.0-19.azl3.noarch.rpm
dwz-0.14-2.azl3.aarch64.rpm
unzip-6.0-22.azl3.aarch64.rpm
python3-3.12.15-1.azl3.aarch64.rpm
python3-devel-3.12.15-1.azl3.aarch64.rpm
python3-libs-3.12.15-1.azl3.aarch64.rpm
python3-3.12.15-3.azl3.aarch64.rpm
python3-devel-3.12.15-3.azl3.aarch64.rpm
python3-libs-3.12.15-3.azl3.aarch64.rpm
python3-setuptools-69.0.3-6.azl3.noarch.rpm
python3-pygments-2.7.4-2.azl3.noarch.rpm
which-2.21-8.azl3.aarch64.rpm
Expand Down
Loading
Loading