Skip to content

Commit 90145be

Browse files
alexclaude
andauthored
Fix quadratic-time PEM scanning in load_ssh_private_key (#15461)
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 <noreply@anthropic.com>
1 parent e448a0c commit 90145be

2 files changed

Lines changed: 37 additions & 7 deletions

File tree

  • src/cryptography/hazmat/primitives/serialization
  • tests/hazmat/primitives

src/cryptography/hazmat/primitives/serialization/ssh.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,6 @@ def _bcrypt_kdf(
8181
_DEFAULT_CIPHER = b"aes256-ctr"
8282
_DEFAULT_ROUNDS = 16
8383

84-
# re is only way to work on bytes-like data
85-
_PEM_RC = re.compile(_SK_START + b"(.*?)" + _SK_END, re.DOTALL)
86-
8784
# padding for max blocksize
8885
_PADDING = memoryview(bytearray(range(1, 1 + 16)))
8986

@@ -684,11 +681,19 @@ def load_ssh_private_key(
684681
if password is not None:
685682
utils._check_bytes("password", password)
686683

687-
m = _PEM_RC.search(data)
688-
if not m:
684+
# The base64 body lies between the first opening marker and the first
685+
# closing marker after it. Two linear scans find them; anchoring the
686+
# second scan past the opening marker keeps the total work linear in the
687+
# length of the input even when there is no closing marker to find.
688+
# bytes.find does not accept a memoryview, which _check_byteslike permits.
689+
buf = data if isinstance(data, (bytes, bytearray)) else bytes(data)
690+
p1 = buf.find(_SK_START)
691+
if p1 == -1:
692+
raise ValueError("Not OpenSSH private key format")
693+
p1 += len(_SK_START)
694+
p2 = buf.find(_SK_END, p1)
695+
if p2 == -1:
689696
raise ValueError("Not OpenSSH private key format")
690-
p1 = m.start(1)
691-
p2 = m.end(1)
692697
data = binascii.a2b_base64(memoryview(data)[p1:p2])
693698
if not data.startswith(_SK_MAGIC):
694699
raise ValueError("Not OpenSSH private key format")

tests/hazmat/primitives/test_ssh.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,31 @@ def test_load_ssh_private_key_errors(self):
485485
with pytest.raises(ValueError):
486486
load_ssh_private_key(data, None)
487487

488+
def test_load_ssh_private_key_missing_footer(self):
489+
# An opening marker with no closing marker is rejected, in linear
490+
# time.
491+
data = self.make_file(footer=b"")
492+
with pytest.raises(ValueError):
493+
load_ssh_private_key(data, password=None)
494+
495+
# Many opening markers with no closing marker: the worst case for
496+
# locating the markers.
497+
data = b"-----BEGIN OPENSSH PRIVATE KEY-----" * 100000
498+
with pytest.raises(ValueError):
499+
load_ssh_private_key(data, password=None)
500+
501+
def test_load_ssh_private_key_surrounding_data(self):
502+
# Data before the opening marker and after the closing marker is
503+
# ignored.
504+
body = self.make_file()
505+
for wrapped in [
506+
b"leading junk\n" + body,
507+
body + b"trailing junk\n",
508+
b"leading\n" + body + b"trailing\n",
509+
]:
510+
key = load_ssh_private_key(wrapped, password=None)
511+
assert isinstance(key, ec.EllipticCurvePrivateKey)
512+
488513
def test_ssh_errors_bad_values(self):
489514
# bad curve
490515
data = self.make_file(pub_type=b"ecdsa-sha2-nistp444")

0 commit comments

Comments
 (0)