Skip to content
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

Set a maximum packet size of 32768 bytes for version and cleartext packets #49

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
13 changes: 9 additions & 4 deletions Sources/NIOSSH/Child Channels/SSHChannelMultiplexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ final class SSHChannelMultiplexer {
/// Whether new channels are allowed. Set to `false` once the parent channel is shut down at the TCP level.
private var canCreateNewChannels: Bool

init(delegate: SSHMultiplexerDelegate, allocator: ByteBufferAllocator, childChannelInitializer: SSHChildChannel.Initializer?) {
private let maximumPacketSize: Int

init(delegate: SSHMultiplexerDelegate, allocator: ByteBufferAllocator, childChannelInitializer: SSHChildChannel.Initializer?, maximumPacketSize: Int = 1 << 17) {
self.channels = [:]
self.channels.reserveCapacity(8)
self.erroredChannels = []
Expand All @@ -43,6 +45,7 @@ final class SSHChannelMultiplexer {
self.allocator = allocator
self.childChannelInitializer = childChannelInitializer
self.canCreateNewChannels = true
self.maximumPacketSize = maximumPacketSize
}

// Time to clean up. We drop references to things that may be keeping us alive.
Expand Down Expand Up @@ -190,14 +193,16 @@ extension SSHChannelMultiplexer {
self.nextChannelID = 0
}

// TODO: Make the window management parameters configurable
// `maximumPacketSize` can be safely cast, because overrides of the default by implementations are expected to have sensible values
// These are also asserted in SSHPackageParser.init
let channel = SSHChildChannel(allocator: self.allocator,
parent: parentChannel,
multiplexer: self,
initializer: initializer,
localChannelID: channelID,
targetWindowSize: 1 << 24,
initialOutboundWindowSize: 0) // The initial outbound window size is presumed to be 0 until we're told otherwise.
targetWindowSize: Int32(self.maximumPacketSize),
initialOutboundWindowSize: 0,
maximumPacketSize: self.maximumPacketSize) // The initial outbound window size is presumed to be 0 until we're told otherwise.

self.channels[channelID] = channel
return channel
Expand Down
16 changes: 11 additions & 5 deletions Sources/NIOSSH/Child Channels/SSHChildChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ final class SSHChildChannel {

private var type: SSHChannelType?

private let maximumPacketSize: Int

/// A promise from the user that will be fired when the channel goes active.
private var userActivatePromise: EventLoopPromise<Channel>?

Expand All @@ -107,14 +109,16 @@ final class SSHChildChannel {
initializer: Initializer?,
localChannelID: UInt32,
targetWindowSize: Int32,
initialOutboundWindowSize: UInt32) {
initialOutboundWindowSize: UInt32,
maximumPacketSize: Int) {
self.init(allocator: allocator,
parent: parent,
multiplexer: multiplexer,
initializer: initializer,
initialState: .init(localChannelID: localChannelID),
targetWindowSize: targetWindowSize,
initialOutboundWindowSize: initialOutboundWindowSize)
initialOutboundWindowSize: initialOutboundWindowSize,
maximumPacketSize: maximumPacketSize)
}

private init(allocator: ByteBufferAllocator,
Expand All @@ -123,7 +127,8 @@ final class SSHChildChannel {
initializer: Initializer?,
initialState: ChildChannelStateMachine,
targetWindowSize: Int32,
initialOutboundWindowSize: UInt32) {
initialOutboundWindowSize: UInt32,
maximumPacketSize: Int) {
self.allocator = allocator
self.closePromise = parent.eventLoop.makePromise()
self.parent = parent
Expand All @@ -136,6 +141,7 @@ final class SSHChildChannel {
self.state = initialState
self.writabilityManager = ChildChannelWritabilityManager(initialWindowSize: initialOutboundWindowSize,
parentIsWritable: parent.isWritable)
self.maximumPacketSize = maximumPacketSize
self.peerMaxMessageSize = 0

// To begin with we initialize autoRead and halfClosure to false, but we are going to fetch it from our parent before we
Expand Down Expand Up @@ -470,15 +476,15 @@ extension SSHChildChannel: Channel, ChannelCore {
let message = SSHMessage.ChannelOpenConfirmationMessage(recipientChannel: self.state.remoteChannelIdentifier!,
senderChannel: self.state.localChannelIdentifier,
initialWindowSize: self.windowManager.targetWindowSize,
maximumPacketSize: 1 << 24) // This is a weirdly hard-coded choice.
maximumPacketSize: UInt32(self.maximumPacketSize))
self.processOutboundMessage(.channelOpenConfirmation(message), promise: nil)
self.writePendingToMultiplexer()
} else if !self.state.isClosed {
// We need to request the channel. We must have the channel by now.
let message = SSHMessage.ChannelOpenMessage(type: .init(self.type!),
senderChannel: self.state.localChannelIdentifier,
initialWindowSize: self.windowManager.targetWindowSize,
maximumPacketSize: 1 << 24)
maximumPacketSize: UInt32(self.maximumPacketSize))
self.processOutboundMessage(.channelOpen(message), promise: nil)
self.writePendingToMultiplexer()
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ struct SSHConnectionStateMachine {
switch message {
case .version:
try state.serializer.serialize(message: message, to: &buffer)
self.state = .sentVersion(.init(idleState: state, allocator: allocator))
self.state = .sentVersion(.init(idleState: state, allocator: allocator, maximumPacketSize: state.role.maximumPacketSize))
case .disconnect:
try state.serializer.serialize(message: message, to: &buffer)
self.state = .sentDisconnect(state.role)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ extension SSHConnectionStateMachine {

private let allocator: ByteBufferAllocator

init(idleState state: IdleState, allocator: ByteBufferAllocator) {
init(idleState state: IdleState, allocator: ByteBufferAllocator, maximumPacketSize: Int) {
self.role = state.role
self.serializer = state.serializer
self.protectionSchemes = state.protectionSchemes

self.parser = SSHPacketParser(allocator: allocator)
self.parser = SSHPacketParser(allocator: allocator, maximumPacketSize: maximumPacketSize)
self.allocator = allocator
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/NIOSSH/NIOSSHError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ extension NIOSSHError {

internal static let invalidNonceLength = NIOSSHError(type: .invalidNonceLength, diagnostics: nil)

internal static let excessiveVersionLength = NIOSSHError(type: .excessiveVersionLength, diagnostics: nil)

internal static let invalidEncryptedPacketLength = NIOSSHError(type: .invalidEncryptedPacketLength, diagnostics: nil)

internal static let invalidDecryptedPlaintextLength = NIOSSHError(type: .invalidDecryptedPlaintextLength, diagnostics: nil)
Expand Down Expand Up @@ -179,6 +181,7 @@ extension NIOSSHError {
case invalidHostKeyForKeyExchange
case invalidOpenSSHPublicKey
case invalidCertificate
case excessiveVersionLength
}

private var base: Base
Expand All @@ -196,6 +199,9 @@ extension NIOSSHError {
/// The length of the nonce provided to a cipher is invalid for that cipher.
public static let invalidNonceLength: ErrorType = .init(.invalidNonceLength)

/// The version length sent by a client was excessively large.
public static let excessiveVersionLength: ErrorType = .init(.excessiveVersionLength)

/// The encrypted packet received has an invalid length for the negotiated encyption scheme
public static let invalidEncryptedPacketLength: ErrorType = .init(.invalidEncryptedPacketLength)

Expand Down
2 changes: 1 addition & 1 deletion Sources/NIOSSH/NIOSSHHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public final class NIOSSHHandler {
self.pendingChannelInitializations = CircularBuffer(initialCapacity: 4)
self.pendingGlobalRequests = CircularBuffer(initialCapacity: 4)
self.pendingGlobalRequestResponses = CircularBuffer(initialCapacity: 4)
self.multiplexer = SSHChannelMultiplexer(delegate: self, allocator: allocator, childChannelInitializer: inboundChildChannelInitializer)
self.multiplexer = SSHChannelMultiplexer(delegate: self, allocator: allocator, childChannelInitializer: inboundChildChannelInitializer, maximumPacketSize: role.maximumPacketSize)
}
}

Expand Down
9 changes: 9 additions & 0 deletions Sources/NIOSSH/Role.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,13 @@ public enum SSHConnectionRole {
return true
}
}

internal var maximumPacketSize: Int {
switch self {
case .client(let client):
return client.maximumPacketSize
case .server(let server):
return server.maximumPacketSize
}
}
}
3 changes: 3 additions & 0 deletions Sources/NIOSSH/SSHClientConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public struct SSHClientConfiguration {
/// The global request delegate to be used with this client.
public var globalRequestDelegate: GlobalRequestDelegate

/// The maximum packet size that this NIOSSH client will accept
public var maximumPacketSize = SSHPacketParser.defaultMaximumPacketSize

public init(userAuthDelegate: NIOSSHClientUserAuthenticationDelegate,
serverAuthDelegate: NIOSSHClientServerAuthenticationDelegate,
globalRequestDelegate: GlobalRequestDelegate? = nil) {
Expand Down
47 changes: 40 additions & 7 deletions Sources/NIOSSH/SSHPacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,22 @@ struct SSHPacketParser {

private var buffer: ByteBuffer
private var state: State
private let maximumPacketSize: Int
internal static let defaultMaximumPacketSize = 1 << 17

/// Testing only: the number of bytes we can discard from this buffer.
internal var _discardableBytes: Int {
self.buffer.readerIndex
}

init(allocator: ByteBufferAllocator) {
init(allocator: ByteBufferAllocator, maximumPacketSize: Int = defaultMaximumPacketSize) {
// Assert that users don't provide a packet size lower than allowed by spec
precondition(maximumPacketSize >= 32768, "Maximum Packet Size is below minimum requirement as specified by RFC 4254")
precondition(maximumPacketSize <= (1 << 24), "Maximum Packet Size is set abnormally high")

self.buffer = allocator.buffer(capacity: 0)
self.state = .initialized
self.maximumPacketSize = maximumPacketSize
}

mutating func append(bytes: inout ByteBuffer) {
Expand Down Expand Up @@ -71,6 +78,10 @@ struct SSHPacketParser {
return nil
case .cleartextWaitingForLength:
if let length = self.buffer.getInteger(at: self.buffer.readerIndex, as: UInt32.self) {
if length >= self.maximumPacketSize {
throw NIOSSHError.invalidEncryptedPacketLength
}

if let message = try self.parsePlaintext(length: length) {
self.state = .cleartextWaitingForLength
return message
Expand Down Expand Up @@ -111,15 +122,30 @@ struct SSHPacketParser {
}
}

internal static let maximumAllowedVersionSize = 4096
private mutating func readVersion() throws -> String? {
// Looking for a string ending with \r\n
let slice = self.buffer.readableBytesView
if let cr = slice.firstIndex(of: 13), cr.advanced(by: 1) < slice.endIndex, slice[cr.advanced(by: 1)] == 10 {
let version = String(decoding: slice[slice.startIndex ..< cr], as: UTF8.self)
// read \r\n
self.buffer.moveReaderIndex(forwardBy: slice.startIndex.distance(to: cr).advanced(by: 2))
return version

// Prevent the consumed bytes for a version from exceeding the defined maximum allowed size
// In practice, if SSH version packets come anywhere near this it's already likely an attack
// More data cannot be blindly regarded as malicious though, since this might contain multiple packets
let maxIndex = slice.index(slice.startIndex, offsetBy: min(slice.count, Self.maximumAllowedVersionSize))

for index in slice.startIndex ..< slice.endIndex {
if index > maxIndex {
// Does not account for `CRLF`
throw NIOSSHError.excessiveVersionLength
}

if slice[index] == 13, index.advanced(by: 1) < slice.endIndex, slice[index.advanced(by: 1)] == 10 {
let version = String(decoding: slice[slice.startIndex ..< index], as: UTF8.self)
// read \r\n
self.buffer.moveReaderIndex(forwardBy: slice.startIndex.distance(to: index).advanced(by: 2))
return version
}
}

return nil
}

Expand All @@ -132,7 +158,14 @@ struct SSHPacketParser {
try protection.decryptFirstBlock(&self.buffer)

// This force unwrap is safe because we must have a block size, and a block size is always going to be more than 4 bytes.
return self.buffer.getInteger(at: self.buffer.readerIndex)! + UInt32(protection.macBytes)
let packetLength = self.buffer.getInteger(at: self.buffer.readerIndex, as: UInt32.self)!
let decryptedLength = packetLength + UInt32(protection.macBytes)

if decryptedLength >= self.maximumPacketSize {
throw NIOSSHError.invalidEncryptedPacketLength
}

return decryptedLength
}

private mutating func parsePlaintext(length: UInt32) throws -> SSHMessage? {
Expand Down
3 changes: 3 additions & 0 deletions Sources/NIOSSH/SSHServerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public struct SSHServerConfiguration {
/// The host keys for this server.
public var hostKeys: [NIOSSHPrivateKey]

/// The maximum packet size that this NIOSSH server will accept
public var maximumPacketSize = SSHPacketParser.defaultMaximumPacketSize

/// The ssh banner to display to clients upon authentication
public var banner: UserAuthBanner?

Expand Down
Loading