Unverified Commit 65322d7d authored by edef's avatar edef Committed by GitHub
Browse files

putty: fix CVE-2026-4115 (#503403)

parents 11556a83 72b07307
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -23,6 +23,12 @@ stdenv.mkDerivation rec {
    hash = "sha256-cYd3wT1j0N/5H+AxYrwqBbTfyLCCdjTNYLUc79/2McY=";
  };

  patches = [
    # Fix EdDSA signature verification accepting out-of-range s values
    # https://git.tartarus.org/?p=simon/putty.git;a=commit;h=af996b5ec27ab79bae3882071b9d6acf16044549
    ./eddsa-verify-check-out-of-range-s.patch
  ];

  nativeBuildInputs = [
    cmake
    perl
@@ -63,6 +69,7 @@ stdenv.mkDerivation rec {
  ];

  meta = {
    maintainers = with lib.maintainers; [ aprl ];
    description = "Free Telnet/SSH Client";
    longDescription = ''
      PuTTY is a free implementation of Telnet and SSH for Windows and Unix
+87 −0
Original line number Diff line number Diff line
From af996b5ec27ab79bae3882071b9d6acf16044549 Mon Sep 17 00:00:00 2001
From: Simon Tatham <anakin@pobox.com>
Date: Wed, 25 Feb 2026 08:29:58 +0000
Subject: [PATCH] eddsa_verify: add check for out-of-range s.

The integer s in an EdDSA signature is treated as an exponent: the
curve's base point is raised to that power. (OK, multiplied by it, if
you use the elliptic curve notational convention rather than the
general group convention.) Therefore, in principle, it doesn't make
any difference if s varies by a multiple of the base point's
order (which is around 2^252, therefore a larger s still fits easily
within the 256-bit space for it in the signature encoding). However,
RFC 8032 requires s to be strictly less than that order, so that
there's a single canonical encoding for any given signature.

I'm not treating this as a vulnerability because I don't believe
there's any situation in SSH where canonicality of signatures is
important. But it should be fixed, nonetheless.

In the fix, it's OK to use an ordinary if statement to check the bound
on s, because they're visible to everybody anyway: the integer s is
encoded directly in the signature, and the bound we're checking it
against is a well-known public integer, so nothing new is revealed by
any timing side channel proving that that was the reason for the
rejection. (Not even if the message being signed were secret, which it
is in SSH: the validation of s doesn't depend on the message.)

Thanks to Yujie Zhu for the report.
---
 crypto/ecc-ssh.c   |  5 +++++
 test/cryptsuite.py | 18 ++++++++++++++++++
 2 files changed, 23 insertions(+)

diff --git a/crypto/ecc-ssh.c b/crypto/ecc-ssh.c
index e524dfc4..fcde908d 100644
--- a/crypto/ecc-ssh.c
+++ b/crypto/ecc-ssh.c
@@ -1091,6 +1091,11 @@ static bool eddsa_verify(ssh_key *key, ptrlen sig, ptrlen data)
     if (!r)
         return false;
     mp_int *s = mp_from_bytes_le(sstr);
+    if (mp_cmp_hs(s, ek->curve->e.G_order)) {
+        ecc_edwards_point_free(r);
+        mp_free(s);
+        return false;
+    }
 
     mp_int *H = eddsa_signing_exponent_from_data(ek, extra, rstr, data);
 
diff --git a/test/cryptsuite.py b/test/cryptsuite.py
index 1ee283c2..30c4ebeb 100755
--- a/test/cryptsuite.py
+++ b/test/cryptsuite.py
@@ -93,6 +93,9 @@ def le_integer(x, nbits):
 def be_integer(x, nbits):
     return bytes(reversed(le_integer(x, nbits)))
 
+def decode_le_integer(s):
+    return sum(byte << (8*i) for i,byte in enumerate(s))
+
 @contextlib.contextmanager
 def queued_random_data(nbytes, seed):
     hashsize = 512 // 8
@@ -3518,6 +3521,21 @@ LzN/Ly+uECsga2hoc+P/ZHMULMZkCfrOyWdeXz7BR/acLZJoT579
                 self.assertEqual(
                     mlkem_decaps(params, bytes(dk_bytes), c), fail)
 
+    def testEd25519Overflow(self):
+        test_key = ssh_key_new_priv('ed25519', b64('AAAAC3NzaC1lZDI1NTE5AAAAIMt0/CMBL+64GQ/r/JyGxo6oHs86i9bOHhMJYbDbxEJf'), b64('AAAAIB38jy02ZWYb4EXrJG9RIljEhqidrG5DdhZvMvoeOTZs'))
+        test_string = b'hello, world'
+        good_sig = test_key.sign(test_string, 0)
+        self.assertTrue(test_key.verify(good_sig, test_string))
+        prefixlen = 4 + len('ssh-ed25519') + 4
+        self.assertEqual(len(good_sig), prefixlen + 64)
+        good_sstr = good_sig[prefixlen+32:]
+        good_s = decode_le_integer(good_sstr)
+        bad_s = good_s + ed25519.G_order
+        bad_sstr = le_integer(bad_s, 256)
+        bad_sig = good_sig[:prefixlen+32] + bad_sstr
+        self.assertEqual(len(bad_sig), len(good_sig))
+        self.assertFalse(test_key.verify(bad_sig, test_string))
+
 class standard_test_vectors(MyTestBase):
     def testAES(self):
         def vector(cipher, key, plaintext, ciphertext):
-- 
2.30.2