From fe40563d652f5cbe1e065b80b603cfb8da0db32a Mon Sep 17 00:00:00 2001 From: Christian Ristig Date: Fri, 9 May 2025 09:41:05 +0200 Subject: [PATCH] Added signature verification support for SMB2 client --- SMBLibrary.Tests/Client/SMB2SigningTests.cs | 264 ++++++++++++++++++++ SMBLibrary/Client/SMB2Client.cs | 103 +++++++- 2 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 SMBLibrary.Tests/Client/SMB2SigningTests.cs diff --git a/SMBLibrary.Tests/Client/SMB2SigningTests.cs b/SMBLibrary.Tests/Client/SMB2SigningTests.cs new file mode 100644 index 0000000..3be355c --- /dev/null +++ b/SMBLibrary.Tests/Client/SMB2SigningTests.cs @@ -0,0 +1,264 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client; +using System; +using System.Net; +using SMBLibrary.Server; +using SMBLibrary.Authentication.GSSAPI; +using SMBLibrary.Client.Authentication; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class SMB2SigningTests + { + private TcpRelay m_tcpRelay; + private SMBServer m_smbServer; + private SMB2Client client; + private static readonly byte[] m_sessionKey = new byte[16]; + private static readonly byte[] m_fakeIdentifier = new byte[] { 1, 2, 3, 4, 5, 6 }; + + [TestInitialize] + public void Initialize() + { + new Random().NextBytes(m_sessionKey); + + SMBShareCollection shares = new SMBShareCollection(); + GSSProvider securityProvider = new GSSProvider(new FakeAuthenticationProvider()); + m_smbServer = new SMBServer(shares, securityProvider); + m_smbServer.Start(IPAddress.Any, SMBTransportType.NetBiosOverTCP, 30002, false, true, true, null); + + m_tcpRelay = new TcpRelay(); + Task.Run(m_tcpRelay.Start); + + client = new SMB2Client(); + client.SigningRequired = true; + m_tcpRelay.SignatureManipulationEnabled = true; + Assert.IsTrue(client.Connect(IPAddress.Loopback, SMBTransportType.DirectTCPTransport, 30001, 3000)); + } + + [TestCleanup] + public void CleanUp() + { + client?.Disconnect(); + m_tcpRelay.Stop(); + m_smbServer.Stop(); + } + + [TestMethod] + public void When_SignatureIsValid_ShouldNotBeInvalid() + { + client.DisconnectOnInvalidSignature = false; + m_tcpRelay.SignatureManipulationEnabled = false; + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, client.Login(new FakeAuthenticationClient())); + + client.TreeConnect("test", out NTStatus status); + Assert.AreEqual(NTStatus.STATUS_OBJECT_PATH_NOT_FOUND, status); + Assert.IsTrue(client.IsConnected); + } + + [TestMethod] + public void When_SigningEnabledAndSignatureIsInvalid_LoginShouldFail() + { + client.DisconnectOnInvalidSignature = false; + m_tcpRelay.SignatureManipulationEnabled = true; + + Assert.AreEqual(NTStatus.STATUS_INVALID_SMB, client.Login(new FakeAuthenticationClient())); + Assert.IsTrue(client.IsConnected); + } + + [TestMethod] + public void When_SigningEnabledAndSignatureIsInvalid_LoginShouldDisconnect() + { + client.DisconnectOnInvalidSignature = true; + m_tcpRelay.SignatureManipulationEnabled = true; + + Assert.AreEqual(NTStatus.STATUS_INVALID_SMB, client.Login(new FakeAuthenticationClient())); + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + public void When_SigningEnabledAndSignatureIsInvalid_CommandShouldFail() + { + m_tcpRelay.SignatureManipulationEnabled = false; + client.DisconnectOnInvalidSignature = false; + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, client.Login(new FakeAuthenticationClient())); + + m_tcpRelay.SignatureManipulationEnabled = true; + + client.TreeConnect("test", out NTStatus status); + Assert.AreEqual(NTStatus.STATUS_INVALID_SMB, status); + Assert.IsTrue(client.IsConnected); + } + + [TestMethod] + public void When_SigningEnabledAndSignatureIsInvalid_CommandShouldDisconnect() + { + m_tcpRelay.SignatureManipulationEnabled = false; + client.DisconnectOnInvalidSignature = true; + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, client.Login(new FakeAuthenticationClient())); + + m_tcpRelay.SignatureManipulationEnabled = true; + + client.TreeConnect("test", out NTStatus status); + Assert.AreEqual(NTStatus.STATUS_INVALID_SMB, status); + Assert.IsFalse(client.IsConnected); + } + + private class FakeAuthenticationClient : IAuthenticationClient + { + public byte[] GetSessionKey() + { + return m_sessionKey; + } + + public byte[] InitializeSecurityContext(byte[] securityBlob) + { + SimpleProtectedNegotiationTokenInit initToken = new SimpleProtectedNegotiationTokenInit(); + initToken.MechanismTypeList = new List() { m_fakeIdentifier }; + return initToken.GetBytes(true); + } + } + + private class FakeAuthenticationProvider : IGSSMechanism + { + public byte[] Identifier => m_fakeIdentifier; + + public NTStatus AcceptSecurityContext(ref object context, byte[] inputToken, out byte[] outputToken) + { + outputToken = null; + return NTStatus.STATUS_SUCCESS; + } + + public bool DeleteSecurityContext(ref object context) + { + return true; + } + + public object GetContextAttribute(object context, GSSAttributeName attributeName) + { + switch (attributeName) + { + case GSSAttributeName.SessionKey: + return m_sessionKey; + } + return null; + } + } + + private class TcpRelay + { + private TcpListener m_relay; + private TcpClient m_localClient; + private TcpClient m_remoteClient; + private Thread m_clientToServer; + private Thread m_serverToClient; + + public bool SignatureManipulationEnabled { get; set; } = false; + + public void Start() + { + m_remoteClient = new TcpClient(); + m_remoteClient.Connect(IPAddress.Loopback, 30002); + + m_relay = new TcpListener(IPAddress.Any, 30001); + m_relay.Start(); + + m_localClient = m_relay.AcceptTcpClient(); + + NetworkStream localStream = m_localClient.GetStream(); + NetworkStream remoteStream = m_remoteClient.GetStream(); + + m_clientToServer = new Thread(() => ForwardData(localStream, remoteStream)); + m_serverToClient = new Thread(() => ForwardData(remoteStream, localStream)); + + m_clientToServer.Start(); + m_serverToClient.Start(); + } + + public void Stop() + { + m_localClient?.Dispose(); + m_remoteClient?.Dispose(); + m_relay?.Stop(); + m_clientToServer.Join(); + m_serverToClient.Join(); + } + + private void ForwardData(NetworkStream input, NetworkStream output) + { + byte[] buffer = new byte[4096]; + int bytesRead; + + try + { + while ((bytesRead = input.Read(buffer, 0, buffer.Length)) > 0) + { + if (TryGetSmb2HeaderOffset(buffer, bytesRead, out int smbOffset) && IsSigned(buffer, smbOffset) && SignatureManipulationEnabled) + { + ManipulateSignature(buffer, smbOffset); + } + + output.Write(buffer, 0, bytesRead); + } + } + catch + { + // Likely disconnected + } + } + + private static bool TryGetSmb2HeaderOffset(byte[] data, int length, out int offset) + { + offset = -1; + + // NetBIOS Session Header? + if (length >= 68 && data[0] == 0x00) + { + if (data[4] == 0xFE && data[5] == (byte)'S' && data[6] == (byte)'M' && data[7] == (byte)'B') + { + offset = 4; + return true; + } + } + else if (length >= 64 && data[0] == 0xFE && data[1] == (byte)'S' && data[2] == (byte)'M' && data[3] == (byte)'B') + { + offset = 0; + return true; + } + + return false; + } + + private static bool IsSigned(byte[] data, int offset) + { + uint flags = BitConverter.ToUInt32(data, offset + 16); + return (flags & 0x00000008) != 0; + } + + private static void ManipulateSignature(byte[] data, int offset) + { + Random rand = new Random(); + + // Change a random signature byte + int idx = rand.Next(48, 48 + 16); + + // Generate a new random byte and make sure it is different from the current value + int byteValue; + do + { + byteValue = rand.Next(0, 256); + } + while (data[offset + idx] == byteValue); + + data[offset + idx] = (byte) byteValue; + } + } + } +} \ No newline at end of file diff --git a/SMBLibrary/Client/SMB2Client.cs b/SMBLibrary/Client/SMB2Client.cs index 7b5a887..448dfeb 100644 --- a/SMBLibrary/Client/SMB2Client.cs +++ b/SMBLibrary/Client/SMB2Client.cs @@ -1,5 +1,5 @@ /* Copyright (C) 2017-2025 Tal Aloni . All rights reserved. - * + * * You can redistribute this program and/or modify it under the terms of * the GNU Lesser Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later version. @@ -59,10 +59,15 @@ namespace SMBLibrary.Client private byte[] m_preauthIntegrityHashValue; // SMB 3.1.1 private ushort m_availableCredits = 1; + private byte[] m_sessionSetupResponseMessage; + public SMB2Client() { } + public bool SigningRequired { get; set; } = false; + public bool DisconnectOnInvalidSignature { get; set; } = false; + /// /// When a Windows Server host is using Failover Cluster and Cluster Shared Volumes, each of those CSV file shares is associated /// with a specific host name associated with the cluster and is not accessible using the node IP address or node host name. @@ -197,7 +202,7 @@ namespace SMBLibrary.Client private bool NegotiateDialect() { NegotiateRequest request = new NegotiateRequest(); - request.SecurityMode = SecurityMode.SigningEnabled; + request.SecurityMode = SigningRequired ? SecurityMode.SigningRequired | SecurityMode.SigningEnabled : SecurityMode.SigningEnabled; request.Capabilities = Capabilities.Encryption; request.ClientGuid = Guid.NewGuid(); request.ClientStartTime = DateTime.Now; @@ -219,7 +224,7 @@ namespace SMBLibrary.Client m_dialect = response.DialectRevision; // [MS-SMB2] 3.3.5.7 If Connection.Dialect is "3.1.1" and Session.IsAnonymous and Session.IsGuest // are set to FALSE and the request is not signed or not encrypted, then the server MUST disconnect the connection. - m_signingRequired = (response.SecurityMode & SecurityMode.SigningRequired) > 0 || + m_signingRequired = SigningRequired || (response.SecurityMode & SecurityMode.SigningRequired) > 0 || response.DialectRevision == SMB2Dialect.SMB311; m_maxTransactSize = Math.Min(response.MaxTransactSize, ClientMaxTransactSize); m_maxReadSize = Math.Min(response.MaxReadSize, ClientMaxReadSize); @@ -256,7 +261,7 @@ namespace SMBLibrary.Client } SessionSetupRequest request = new SessionSetupRequest(); - request.SecurityMode = SecurityMode.SigningEnabled; + request.SecurityMode = SigningRequired ? SecurityMode.SigningRequired | SecurityMode.SigningEnabled : SecurityMode.SigningEnabled; request.SecurityBuffer = negotiateMessage; TrySendCommand(request); SMB2Command response = WaitForCommand(request.MessageID); @@ -270,7 +275,7 @@ namespace SMBLibrary.Client m_sessionID = response.Header.SessionID; request = new SessionSetupRequest(); - request.SecurityMode = SecurityMode.SigningEnabled; + request.SecurityMode = SigningRequired ? SecurityMode.SigningRequired | SecurityMode.SigningEnabled : SecurityMode.SigningEnabled; request.SecurityBuffer = authenticateMessage; TrySendCommand(request); response = WaitForCommand(request.MessageID); @@ -301,6 +306,26 @@ namespace SMBLibrary.Client m_encryptionKey = SMB2Cryptography.GenerateClientEncryptionKey(m_sessionKey, m_dialect, m_preauthIntegrityHashValue); m_decryptionKey = SMB2Cryptography.GenerateClientDecryptionKey(m_sessionKey, m_dialect, m_preauthIntegrityHashValue); } + + // [MS-SMB2] 3.2.5.1.3 Verifying the Signature + // If signature verification fails, the client MUST discard the received message. + // The client MAY also choose to disconnect the connection. + try + { + if (!VerifySignature(m_sessionSetupResponseMessage, response)) + { + m_isLoggedIn = false; + if (DisconnectOnInvalidSignature) + { + Disconnect(); + } + return NTStatus.STATUS_INVALID_SMB; + } + } + finally + { + m_sessionSetupResponseMessage = null; + } } return response.Header.Status; } @@ -526,14 +551,42 @@ namespace SMBLibrary.Client // and the client MUST NOT attempt to locate the request, but instead process it as follows: // If the command field in the SMB2 header is SMB2 OPLOCK_BREAK, it MUST be processed as specified in 3.2.5.19. // Otherwise, the response MUST be discarded as invalid. - if (command.Header.MessageID != 0xFFFFFFFFFFFFFFFF || command.Header.Command == SMB2CommandName.OplockBreak) + if (command.Header.MessageID == 0xFFFFFFFFFFFFFFFF && command.Header.Command != SMB2CommandName.OplockBreak) { - lock (m_incomingQueueLock) + return; + } + + // [MS-SMB2] 3.2.5.1.3 Verifying the Signature + // If signature verification fails, the client MUST discard the received message. + // The client MAY also choose to disconnect the connection. + if (m_isLoggedIn) + { + // This check covers all messages except the final session setup message which is treated seperately + if (!VerifySignature(messageBytes, command)) { - m_incomingQueue.Add(command); - m_incomingQueueEventHandle.Set(); + if (DisconnectOnInvalidSignature) + { + m_isLoggedIn = false; + Disconnect(); + } + return; } } + else if ((command.Header.Command == SMB2CommandName.SessionSetup) && + (command.Header.Status == NTStatus.STATUS_SUCCESS)) + { + // The final session setup message already has a valid signature, but the session/signing key have not been set yet (m_isLoggedIn is false). + // The message is evaluated within the Login() method, which will then determine the keys and set m_isLoggedIn to true. + // Nevertheless, the signature must be verified. So the raw message is preserved here and the signature is verified later in the + // Login() method, after the keys have been set. + m_sessionSetupResponseMessage = messageBytes; + } + + lock (m_incomingQueueLock) + { + m_incomingQueue.Add(command); + m_incomingQueueEventHandle.Set(); + } } else if ((packet is PositiveSessionResponsePacket || packet is NegativeSessionResponsePacket) && m_transport == SMBTransportType.NetBiosOverTCP) { @@ -552,6 +605,38 @@ namespace SMBLibrary.Client } } + // [MS-SMB2] 3.2.5.1.3 Verifying the Signature + private bool VerifySignature(byte[] messageBytes, SMB2Command command) + { + if (!m_signingRequired) + { + // Client and server require no signing at all + return true; + } + + if (messageBytes == null) + { + throw new ArgumentNullException("messageBytes"); + } + + bool isUnspecifiedMessageId = command.Header.MessageID == 0xFFFFFFFFFFFFFFFF; + bool isPendingMessage = command.Header.Status == NTStatus.STATUS_PENDING; + + if (m_encryptSessionData || isUnspecifiedMessageId || isPendingMessage) + { + // Signing is not required + return true; + } + + byte[] key = (m_dialect >= SMB2Dialect.SMB300) ? m_signingKey : m_sessionKey; + + Array.Clear(messageBytes, SMB2Header.SignatureOffset, command.Header.Signature.Length); + byte[] signature = SMB2Cryptography.CalculateSignature(key, m_dialect, messageBytes, 0, messageBytes.Length); + signature = ByteReader.ReadBytes(signature, 0, command.Header.Signature.Length); + + return ByteUtils.AreByteArraysEqual(signature, command.Header.Signature); + } + internal SMB2Command WaitForCommand(ulong messageID) { return WaitForCommand(messageID, out bool _);