From 3289cb98c850267a143fe30bf4931aac2daf36ea Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Sun, 16 Aug 2026 18:44:42 -0400 Subject: [PATCH] Fix quadratic-time PEM scanning in load_ssh_private_key load_ssh_private_key located the OpenSSH PEM block with a regex whose runtime is quadratic in the length of the input: re.search restarts at every occurrence of the opening marker and rescans to the end of the input looking for a closing marker, so input with an opening marker and no closing marker drives O(n^2) work before any parsing or size check. An attacker who can supply data to the function can consume unbounded CPU (~8s for 273 KiB, extrapolating to hours for a few MiB). Locate the markers directly with bytes.find instead. The first opening marker and the first closing marker after it delimit exactly what the lazy regex matched, and finding them is linear. memoryview inputs, which _check_byteslike permits but bytes.find does not, are converted first. Co-Authored-By: Claude Opus 4.8 --- .../hazmat/primitives/serialization/ssh.py | 19 ++++++++------ tests/hazmat/primitives/test_ssh.py | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/cryptography/hazmat/primitives/serialization/ssh.py b/src/cryptography/hazmat/primitives/serialization/ssh.py index 230e14799a08..0b2526d30bc1 100644 --- a/src/cryptography/hazmat/primitives/serialization/ssh.py +++ b/src/cryptography/hazmat/primitives/serialization/ssh.py @@ -81,9 +81,6 @@ def _bcrypt_kdf( _DEFAULT_CIPHER = b"aes256-ctr" _DEFAULT_ROUNDS = 16 -# re is only way to work on bytes-like data -_PEM_RC = re.compile(_SK_START + b"(.*?)" + _SK_END, re.DOTALL) - # padding for max blocksize _PADDING = memoryview(bytearray(range(1, 1 + 16))) @@ -684,11 +681,19 @@ def load_ssh_private_key( if password is not None: utils._check_bytes("password", password) - m = _PEM_RC.search(data) - if not m: + # The base64 body lies between the first opening marker and the first + # closing marker after it. Two linear scans find them; anchoring the + # second scan past the opening marker keeps the total work linear in the + # length of the input even when there is no closing marker to find. + # bytes.find does not accept a memoryview, which _check_byteslike permits. + buf = data if isinstance(data, (bytes, bytearray)) else bytes(data) + p1 = buf.find(_SK_START) + if p1 == -1: + raise ValueError("Not OpenSSH private key format") + p1 += len(_SK_START) + p2 = buf.find(_SK_END, p1) + if p2 == -1: raise ValueError("Not OpenSSH private key format") - p1 = m.start(1) - p2 = m.end(1) data = binascii.a2b_base64(memoryview(data)[p1:p2]) if not data.startswith(_SK_MAGIC): raise ValueError("Not OpenSSH private key format") diff --git a/tests/hazmat/primitives/test_ssh.py b/tests/hazmat/primitives/test_ssh.py index 2a319185b2bc..bf316759a3cb 100644 --- a/tests/hazmat/primitives/test_ssh.py +++ b/tests/hazmat/primitives/test_ssh.py @@ -485,6 +485,31 @@ def test_load_ssh_private_key_errors(self): with pytest.raises(ValueError): load_ssh_private_key(data, None) + def test_load_ssh_private_key_missing_footer(self): + # An opening marker with no closing marker is rejected, in linear + # time. + data = self.make_file(footer=b"") + with pytest.raises(ValueError): + load_ssh_private_key(data, password=None) + + # Many opening markers with no closing marker: the worst case for + # locating the markers. + data = b"-----BEGIN OPENSSH PRIVATE KEY-----" * 100000 + with pytest.raises(ValueError): + load_ssh_private_key(data, password=None) + + def test_load_ssh_private_key_surrounding_data(self): + # Data before the opening marker and after the closing marker is + # ignored. + body = self.make_file() + for wrapped in [ + b"leading junk\n" + body, + body + b"trailing junk\n", + b"leading\n" + body + b"trailing\n", + ]: + key = load_ssh_private_key(wrapped, password=None) + assert isinstance(key, ec.EllipticCurvePrivateKey) + def test_ssh_errors_bad_values(self): # bad curve data = self.make_file(pub_type=b"ecdsa-sha2-nistp444")