A malicious FTP server can return an arbitrarily large OPIE / S-KEY
sequence number in its 331 login challenge.  ftp_login() reads that
value and passes it to skey_response() as an MD5 iteration count, so a
large sequence forces Wget through up to ~2.1 billion MD5 rounds before
it sends the password -- a server-controlled denial of service.

ftp_login() now rejects a sequence above 9999 before calling
skey_response().  This test drives a crafted "331 opiekey 100000"
challenge and checks that login is refused and nothing is downloaded,
which never happens on an unbounded build.

* tests/FTPServer.pm: Add the opie_sequence server behavior, answering
  USER with a crafted OPIE challenge carrying a chosen sequence number.
* tests/Test-ftp-opie-seq.px: New test for the sequence bound.
* tests/Makefile.am (PX_TESTS): Add Test-ftp-opie-seq.px.

Fixes CVE-2026-16599

Signed-off-by: Pratik Farkase <[email protected]>
---
 tests/FTPServer.pm         | 15 ++++++++
 tests/Makefile.am          |  1 +
 tests/Test-ftp-opie-seq.px | 70 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 86 insertions(+)
 create mode 100644 tests/Test-ftp-opie-seq.px

diff --git a/tests/FTPServer.pm b/tests/FTPServer.pm
index cac80942..51ee7fba 100644
--- a/tests/FTPServer.pm
+++ b/tests/FTPServer.pm
@@ -748,6 +748,21 @@ sub run
                     next;
                 }
 
+                # Reply to USER with a crafted OPIE / S-KEY challenge
+                # carrying a caller-chosen sequence number.  Used by
+                # Test-ftp-opie-seq.px to exercise the CVE-2026-16599
+                # sequence bound in ftp_login().
+                if (defined($self->{_server_behavior}{opie_sequence})
+                    && $cmd eq 'USER')
+                {
+                    my $seq = $self->{_server_behavior}{opie_sequence};
+                    $conn->{username} = $rest;
+                    $conn->{state}    = $_connection_states{WAIT4PWD};
+                    print {$conn->{socket}}
+                      "331 opiekey $seq ab1234\r\n";
+                    next;
+                }
+
                 # Run the command.
                 &{$command_table->{$cmd}}($conn, $cmd, $rest);
             }
diff --git a/tests/Makefile.am b/tests/Makefile.am
index ab4f035b..002cc761 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -55,6 +55,7 @@ PX_TESTS = \
              Test-ftp.px \
              Test-ftp-dir.px \
              Test-ftp-pasv-fail.px \
+             Test-ftp-opie-seq.px \
              Test-ftp-bad-list.px \
              Test-ftp-recursive.px \
              Test-ftp-iri.px \
diff --git a/tests/Test-ftp-opie-seq.px b/tests/Test-ftp-opie-seq.px
new file mode 100644
index 00000000..0a8770f7
--- /dev/null
+++ b/tests/Test-ftp-opie-seq.px
@@ -0,0 +1,70 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use WgetFeature qw(opie);
+use FTPTest;
+
+# Regression test for CVE-2026-16599.
+#
+# When logging in to an FTP server that advertises OPIE / S-KEY, Wget
+# reads the S-KEY sequence number straight out of the server's 331
+# challenge line and feeds it to skey_response() as an MD5 iteration
+# count.  A malicious server could return an arbitrarily large sequence
+# (up to ~2.1 billion), forcing Wget to perform that many MD5 rounds
+# before it could even send the password -- a server-controlled denial
+# of service.
+#
+# ftp_login() now caps the sequence at 9999 while parsing the challenge;
+# a larger value makes it return FTPLOGREFUSED immediately, without ever
+# calling skey_response().  The login attempt then fails and nothing is
+# downloaded.
+#
+# The server below answers USER with "331 opiekey 100000 ..." (100000 >
+# 9999).  With the bound in place login is refused up front, so the test
+# stays offline and fast; it never drives the long MD5 loop.  Asserting
+# the refusal (failed exit code, no file downloaded) is what separates a
+# fixed Wget from a vulnerable one: a vulnerable Wget instead accepts the
+# sequence, runs the loop, sends PASS and downloads the file.
+
+###############################################################################
+
+my $afile = <<EOF;
+Some text.
+EOF
+
+$afile =~ s/\n/\r\n/g;
+
+# code, msg, headers, content
+my %urls = (
+    '/afile.txt' => {
+        content => $afile,
+    },
+);
+
+my $cmdline = $WgetTest::WGETPATH . " --tries=1 
ftp://localhost:{{port}}/afile.txt";;
+
+# With the fix, ftp_login() rejects the over-bound OPIE sequence before
+# sending PASS, so the login fails and nothing is downloaded.  Wget exits
+# 4 (WGET_EXIT_NETWORK_FAIL) once the single attempt is exhausted; --tries=1
+# keeps that deterministic.  A vulnerable binary instead accepts the
+# sequence and drives skey_response()'s MD5 loop, sending PASS afterwards.
+my $expected_error_code = 4;
+
+# Login is refused before any transfer, so nothing must be downloaded.
+my %expected_downloaded_files = ();
+
+###############################################################################
+
+my $the_test = FTPTest->new (
+                             # opie_sequence triggers a crafted OPIE
+                             # challenge with an over-bound sequence.
+                             server_behavior => {opie_sequence => 100000},
+                             input => \%urls,
+                             cmdline => $cmdline,
+                             errcode => $expected_error_code,
+                             output => \%expected_downloaded_files);
+exit $the_test->run();
+
+# vim: et ts=4 sw=4
-- 
2.43.0


Reply via email to