Repository navigation
Fix race between SendMessage and server-initiated key re-exchange #1823
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
radekvermirovsky
wants to merge
6
commits into
sshnet:develop
Choose a base branch
from
radekvermirovsky:fix/rekey-race-1764
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
665f9b5
Fix race between SendMessage and server-initiated key re-exchange
radekvermirovsky a1cc0a8
Make ProFTPD rekey test pass on CI
radekvermirovsky 4fdeac5
Merge remote-tracking branch 'origin/develop' into fix/rekey-race-1764
radekvermirovsky 9d17a65
Merge remote-tracking branch 'origin/develop' into fix/rekey-race-1764
radekvermirovsky e93bc75
Accept and defer connection protocol messages received during a key r…
f400dd5
Merge remote-tracking branch 'origin/develop' into fix/rekey-race-1764
radekvermirovsky File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| * | ||
| !server/* | ||
| !user/* | ||
| !proftpd/* |
45 changes: 45 additions & 0 deletions
45
test/Renci.SshNet.IntegrationTests/Logging/TextWriterLogger.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| #nullable enable | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Renci.SshNet.IntegrationTests.Logging | ||
| { | ||
| internal class TextWriterLogger(TextWriter writer, string categoryName) : ILogger | ||
| { | ||
| public IDisposable? BeginScope<TState>(TState state) | ||
| where TState : notnull | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| public bool IsEnabled(LogLevel logLevel) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) | ||
| { | ||
| StringBuilder sb = new StringBuilder(); | ||
| sb.Append(logLevel); | ||
| sb.Append(": "); | ||
| sb.Append(categoryName); | ||
| sb.Append(": "); | ||
|
|
||
| string message = formatter(state, exception); | ||
| sb.Append(message); | ||
|
|
||
| if (exception != null) | ||
| { | ||
| sb.Append(": "); | ||
| sb.Append(exception); | ||
| } | ||
|
|
||
| string line = sb.ToString(); | ||
|
|
||
| lock (writer) | ||
| { | ||
| writer.WriteLine(line); | ||
| } | ||
| } | ||
| } | ||
| } |
18 changes: 18 additions & 0 deletions
18
test/Renci.SshNet.IntegrationTests/Logging/TextWriterLoggerProvider.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| #nullable enable | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Renci.SshNet.IntegrationTests.Logging | ||
| { | ||
| internal class TextWriterLoggerProvider(TextWriter writer) : ILoggerProvider | ||
| { | ||
| public ILogger CreateLogger(string categoryName) | ||
| { | ||
| return new TextWriterLogger(writer, categoryName); | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| } | ||
| } | ||
| } |
164 changes: 164 additions & 0 deletions
164
test/Renci.SshNet.IntegrationTests/ProFtpdRekeyTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| #if NET // The test uses Parallel.ForEachAsync, which is not available on .NET Framework. | ||
|
|
||
| using DotNet.Testcontainers.Builders; | ||
| using DotNet.Testcontainers.Containers; | ||
| using DotNet.Testcontainers.Images; | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| using Renci.SshNet.IntegrationTests.Logging; | ||
|
|
||
| namespace Renci.SshNet.IntegrationTests | ||
| { | ||
| /// <summary> | ||
| /// Reproduces https://github.com/sshnet/SSH.NET/issues/1764: connection failures during | ||
| /// SFTP transfers when the server initiates a key re-exchange. | ||
| /// <para> | ||
| /// Unlike OpenSSH, which queues non key exchange output while a re-exchange is in progress, | ||
| /// ProFTPD mod_sftp keeps sending channel messages (SSH_MSG_CHANNEL_WINDOW_ADJUST, | ||
| /// SSH_MSG_CHANNEL_DATA) after it has sent its SSH_MSG_KEXINIT. The same load which passes | ||
| /// against the OpenSSH test server therefore fails against ProFTPD with either | ||
| /// "Message type 93 is not valid in the current context." or a connection drop | ||
| /// ("Key exchange failed"). These tests run against a ProFTPD container configured to | ||
| /// re-key every 1 MB (see proftpd/proftpd.conf) to give the race many trials per upload. | ||
| /// </para> | ||
| /// <para> | ||
| /// Trace-level logging to a file is enabled for the duration of these tests: the small | ||
| /// per-message overhead on the message listener thread widens the window between the | ||
| /// arrival of the server's SSH_MSG_KEXINIT and its processing, during which concurrent | ||
| /// uploaders keep sending data. This mirrors real-world conditions (applications with | ||
| /// trace logging enabled, or slower links) and makes the race fail reliably on loopback. | ||
| /// </para> | ||
| /// </summary> | ||
| [TestClass] | ||
| public sealed class ProFtpdRekeyTests : TestBase | ||
| { | ||
| private static IFutureDockerImage _proFtpdImage; | ||
| private static IContainer _proFtpdServer; | ||
| private static string _proFtpdHostName; | ||
| private static ushort _proFtpdPort; | ||
| private static StreamWriter _traceLogWriter; | ||
| private static ILoggerFactory _traceLoggerFactory; | ||
|
|
||
| [ClassInitialize] | ||
| [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "MSTests requires context parameter")] | ||
| public static async Task ClassInitialize(TestContext context) | ||
| { | ||
| // The Windows Tests in CI cannot run the ProFTPD container: Docker on the Windows | ||
| // runners is in Windows containers mode ("no matching manifest for windows/amd64"), | ||
| // which is why the OpenSSH server for the other integration tests is set up in | ||
| // WSL2 with Podman instead (see InfrastructureFixture). | ||
| if (OperatingSystem.IsWindows() && Environment.GetEnvironmentVariable("CI") == "true") | ||
| { | ||
| Assert.Inconclusive("Requires a container runtime able to run Linux containers."); | ||
| } | ||
|
|
||
| _traceLogWriter = new StreamWriter(Path.GetTempFileName()) { AutoFlush = true }; | ||
| _traceLoggerFactory = LoggerFactory.Create(builder => | ||
| { | ||
| builder.SetMinimumLevel(LogLevel.Trace); | ||
| builder.AddProvider(new TextWriterLoggerProvider(_traceLogWriter)); | ||
| }); | ||
|
|
||
| SshNetLoggingConfiguration.InitializeLogging(_traceLoggerFactory); | ||
|
|
||
| _proFtpdImage = new ImageFromDockerfileBuilder() | ||
| .WithName("renci-ssh-tests-proftpd-image") | ||
| .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), Path.Combine("test", "Renci.SshNet.IntegrationTests")) | ||
| .WithDockerfile("proftpd/Dockerfile") | ||
| .WithDeleteIfExists(true) | ||
| .Build(); | ||
|
|
||
| await _proFtpdImage.CreateAsync(context.CancellationToken); | ||
|
|
||
| _proFtpdServer = new ContainerBuilder(_proFtpdImage) | ||
| .WithHostname("renci-ssh-tests-proftpd") | ||
| .WithPortBinding(22, true) | ||
| .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(22)) | ||
| .Build(); | ||
|
|
||
| await _proFtpdServer.StartAsync(context.CancellationToken); | ||
|
|
||
| _proFtpdPort = _proFtpdServer.GetMappedPublicPort(22); | ||
| _proFtpdHostName = _proFtpdServer.Hostname; | ||
| } | ||
|
|
||
| [ClassCleanup] | ||
| public static async Task ClassCleanup() | ||
| { | ||
| if (_proFtpdServer != null) | ||
| { | ||
| await _proFtpdServer.DisposeAsync(); | ||
| } | ||
|
|
||
| if (_proFtpdImage != null) | ||
| { | ||
| await _proFtpdImage.DisposeAsync(); | ||
| } | ||
|
|
||
| // Restore the assembly-wide logging configuration set up by InfrastructureFixture. | ||
| var defaultLoggerFactory = LoggerFactory.Create(builder => | ||
| { | ||
| builder.SetMinimumLevel(LogLevel.Debug); | ||
| builder.AddTestConsoleLogger(); | ||
| }); | ||
|
|
||
| SshNetLoggingConfiguration.InitializeLogging(defaultLoggerFactory); | ||
|
|
||
| _traceLoggerFactory?.Dispose(); | ||
| _traceLogWriter?.Dispose(); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task Sftp_ConcurrentUploads_WithServerRekey() | ||
| { | ||
| const int fileSize = 128 * 1024 * 1024; | ||
| const int concurrentUploads = 4; | ||
| const int attempts = 3; | ||
|
|
||
| using (var sftp = new SftpClient(_proFtpdHostName, _proFtpdPort, "sshnet", "ssh4ever")) | ||
| { | ||
| await sftp.ConnectAsync(CancellationToken.None); | ||
|
|
||
| for (var attempt = 0; attempt < attempts; attempt++) | ||
| { | ||
| await Parallel.ForEachAsync(Enumerable.Range(0, concurrentUploads), async (i, ct) => | ||
| { | ||
| var localFile = CreateZeroFilledTempFile(fileSize); | ||
|
|
||
| try | ||
| { | ||
| var remoteFile = $"rekey-test-{i}"; | ||
|
|
||
| using (var fileStream = File.OpenRead(localFile)) | ||
| { | ||
| await sftp.UploadFileAsync(fileStream, remoteFile, ct); | ||
| } | ||
|
|
||
| var remoteLength = (await sftp.GetAsync(remoteFile, ct)).Attributes.Size; | ||
|
|
||
| Assert.AreEqual(fileSize, remoteLength); | ||
| } | ||
| finally | ||
| { | ||
| File.Delete(localFile); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static string CreateZeroFilledTempFile(long size) | ||
| { | ||
| var file = Path.GetTempFileName(); | ||
|
|
||
| using (var fs = File.OpenWrite(file)) | ||
| { | ||
| fs.SetLength(size); | ||
| } | ||
|
|
||
| return file; | ||
| } | ||
| } | ||
| } | ||
| #endif | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # ProFTPD with mod_sftp, used by ProFtpdRekeyTests to reproduce | ||
| # https://github.com/sshnet/SSH.NET/issues/1764 (alpine:3.24 ships ProFTPD 1.3.9c). | ||
| FROM alpine:3.24 | ||
| RUN apk add --no-cache proftpd proftpd-mod_sftp openssh-keygen | ||
| RUN ssh-keygen -t rsa -b 3072 -N "" -m PEM -f /etc/proftpd/host_rsa | ||
| RUN chmod 400 /etc/proftpd/host_rsa | ||
| RUN adduser -D sshnet | ||
| RUN echo 'sshnet:ssh4ever' | chpasswd | ||
| COPY proftpd/proftpd.conf /etc/proftpd/proftpd.conf | ||
| EXPOSE 22 | ||
| CMD ["proftpd", "--nodaemon", "--config", "/etc/proftpd/proftpd.conf"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| ServerName "renci-ssh-tests-proftpd" | ||
| ServerType standalone | ||
| DefaultServer on | ||
| # SFTPEngine converts this server to SSH/SFTP entirely; no plain FTP is served. | ||
| Port 22 | ||
| User nobody | ||
| Group nobody | ||
| DefaultRoot ~ | ||
| AllowOverwrite on | ||
| PidFile /var/run/proftpd.pid | ||
| ScoreboardFile /var/run/proftpd.scoreboard | ||
|
|
||
| LoadModule mod_sftp.c | ||
|
|
||
| <IfModule mod_sftp.c> | ||
| SFTPEngine on | ||
| SFTPHostKey /etc/proftpd/host_rsa | ||
| SFTPAuthMethods password | ||
| SFTPLog /var/log/sftp.log | ||
| # Re-key every 1 MB so that the key re-exchange races of | ||
| # https://github.com/sshnet/SSH.NET/issues/1764 get many trials per upload. | ||
| # Unlike OpenSSH, ProFTPD mod_sftp keeps sending channel messages | ||
| # (SSH_MSG_CHANNEL_WINDOW_ADJUST / SSH_MSG_CHANNEL_DATA) after it has | ||
| # initiated a re-exchange, which is what triggers the client-side failures. | ||
| SFTPRekey required 3600 1 | ||
| </IfModule> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please double check this? I do not believe that is what is happening. RFC 4253 section 7.1 says
It would be against spec if ProFTPD were to be sending channel messages after it has sent KEXINIT, not something for this library to solve. Note that error message in #1764 is
Message type 93 is not valid., notMessage type 93 is not valid in the current context.. The former message pattern I do not see in this library and I believe it is a message from the server.I think this is rather a problem in this library where responding to the KEXINIT can preempt other messages that have already checked a kex is not in progress.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks Rob — you’re right. I re-ran the ProFTPD test with trace logging on the send-side-only state of this branch: across 1138 server-initiated re-exchanges ProFTPD sent nothing but SSH_MSG_KEX_ECDH_REPLY between its KEXINIT and NEWKEYS, so the comment in the test is wrong and the failure is purely our send side preempted by the KEXINIT reply. Sorry for the noise.
Your 76ab503 is functionally the same fix as this PR, just cleaner, and I agree with the strict-kex relaxation.
Do you want to push it to develop yourself (then I’ll close this PR), or should I finish it here — take your commit, drop my Session.cs change and the receive-side commit, fix the test comments, keep the ProFTPD test? Either works for me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I would take my commit as is, without the ProFTPD test for now (it was helpful to validate and I like the idea of testing against multiple servers, but don't think there is enough value-add for one test).
If you would like contribution credit for the investigation and the validation, you can reset your branch to mine or I can add you as a co-author on the commit.