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 @@ -33,8 +33,10 @@ 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

private let maximumPacketSize: Int

init(delegate: SSHMultiplexerDelegate, allocator: ByteBufferAllocator, childChannelInitializer: SSHChildChannel.Initializer?) {
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(maximumPacketSize),
initialOutboundWindowSize: 0,
maximumPacketSize: 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 @@ -97,6 +97,8 @@ final class SSHChildChannel {
private var didClose = false

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 @@ -468,15 +474,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(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(maximumPacketSize))
self.processOutboundMessage(.channelOpen(message), promise: nil)
self.writePendingToMultiplexer()
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,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 @@ -43,6 +43,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)

Expand Down Expand Up @@ -179,6 +181,7 @@ extension NIOSSHError {
case invalidHostKeyForKeyExchange
case invalidOpenSSHPublicKey
case invalidCertificate
case excessiveVersionLength
}

private var base: Base
Expand All @@ -195,6 +198,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 length of the nonce provided to a cipher is invalid for that cipher.
Joannis marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -22,6 +22,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 = 1 << 17

public init(userAuthDelegate: NIOSSHClientUserAuthenticationDelegate,
serverAuthDelegate: NIOSSHClientServerAuthenticationDelegate,
Expand Down
44 changes: 37 additions & 7 deletions Sources/NIOSSH/SSHPacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,21 @@ struct SSHPacketParser {

private var buffer: ByteBuffer
private var state: State
private let maximumPacketSize: Int

/// 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 = 1 << 17) {
// Assert that users don't provide a packet size lower than allowed by spec
assert(maximumPacketSize >= 32_768, "Maximum Packet Size is below minimum requirement as specified by RFC 4254")
assert(maximumPacketSize <= (1 << 24), "Maximum Packet Size is set abnormally high")
Joannis marked this conversation as resolved.
Show resolved Hide resolved

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

mutating func append(bytes: inout ByteBuffer) {
Expand Down Expand Up @@ -71,6 +77,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 @@ -114,12 +124,26 @@ struct SSHPacketParser {
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 4_096
// 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, 4_096))
Joannis marked this conversation as resolved.
Show resolved Hide resolved

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 +156,13 @@ 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)!

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

return packetLength + UInt32(protection.macBytes)
Joannis marked this conversation as resolved.
Show resolved Hide resolved
}

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 @@ -22,6 +22,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 = 1 << 17

public init(hostKeys: [NIOSSHPrivateKey], userAuthDelegate: NIOSSHServerUserAuthenticationDelegate, globalRequestDelegate: GlobalRequestDelegate? = nil) {
self.hostKeys = hostKeys
Expand Down
39 changes: 36 additions & 3 deletions Tests/NIOSSHTests/EndToEndTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,20 @@ class BackToBackEmbeddedChannel {
try self.server.connect(to: .init(unixDomainSocketPath: "/fake")).wait()
}

func configureWithHarness(_ harness: TestHarness) throws {
let clientHandler = NIOSSHHandler(role: .client(.init(userAuthDelegate: harness.clientAuthDelegate, serverAuthDelegate: harness.clientServerAuthDelegate, globalRequestDelegate: harness.clientGlobalRequestDelegate)),
func configureWithHarness(_ harness: TestHarness, maximumPacketSize: Int? = nil) throws {
var clientConfiguration = SSHClientConfiguration(userAuthDelegate: harness.clientAuthDelegate, serverAuthDelegate: harness.clientServerAuthDelegate, globalRequestDelegate: harness.clientGlobalRequestDelegate)

var serverConfiguration = SSHServerConfiguration(hostKeys: harness.serverHostKeys, userAuthDelegate: harness.serverAuthDelegate, globalRequestDelegate: harness.serverGlobalRequestDelegate)

if let maximumPacketSize = maximumPacketSize {
clientConfiguration.maximumPacketSize = maximumPacketSize
serverConfiguration.maximumPacketSize = maximumPacketSize
}

let clientHandler = NIOSSHHandler(role: .client(clientConfiguration),
allocator: self.client.allocator,
inboundChildChannelInitializer: nil)
let serverHandler = NIOSSHHandler(role: .server(.init(hostKeys: harness.serverHostKeys, userAuthDelegate: harness.serverAuthDelegate, globalRequestDelegate: harness.serverGlobalRequestDelegate)),
let serverHandler = NIOSSHHandler(role: .server(serverConfiguration),
allocator: self.server.allocator) { channel, _ in
self.activeServerChannels.append(channel)
channel.closeFuture.whenComplete { _ in self.activeServerChannels.removeAll(where: { $0 === channel }) }
Expand Down Expand Up @@ -129,6 +138,8 @@ struct TestHarness {
var serverGlobalRequestDelegate: GlobalRequestDelegate?

var serverHostKeys: [NIOSSHPrivateKey] = [.init(ed25519Key: .init())]

var maximumPacketSize: Int? = nil
}

final class UserEventExpecter: ChannelInboundHandler {
Expand Down Expand Up @@ -240,6 +251,28 @@ class EndToEndTests: XCTestCase {
helper(ChannelSuccessEvent())
helper(ChannelFailureEvent())
}

/// This test validates that all the channel requests roiund-trip appropriately.
func testChannelRejectsHugePacketsRequests() throws {
XCTAssertNoThrow(try self.channel.configureWithHarness(TestHarness(), maximumPacketSize: 32_768))
XCTAssertNoThrow(try self.channel.activate())
XCTAssertNoThrow(try self.channel.interactInMemory())

// Create a channel.
let clientChannel = try self.channel.createNewChannel()
XCTAssertNoThrow(try self.channel.interactInMemory())
guard let serverChannel = self.channel.activeServerChannels.first else {
XCTFail("Server channel not created")
return
}

let userEventRecorder = UserEventExpecter()
XCTAssertNoThrow(try serverChannel.pipeline.addHandler(userEventRecorder).wait())

let hugeCommand = String(repeating: "a", count: 32_768)
try clientChannel.triggerUserOutboundEvent(SSHChannelRequestEvent.ExecRequest(command: hugeCommand, wantReply: true)).wait()
XCTAssertThrowsError(try self.channel.interactInMemory())
}

func testGlobalRequestWithDefaultDelegate() throws {
XCTAssertNoThrow(try self.channel.configureWithHarness(TestHarness()))
Expand Down
9 changes: 9 additions & 0 deletions Tests/NIOSSHTests/SSHPacketParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ final class SSHPacketParserTests: XCTestCase {
// Now we should have cleared up.
XCTAssertEqual(parser._discardableBytes, 0)
}

func testMaximumPacketSizeInVersion() throws {
var parser = SSHPacketParser(allocator: ByteBufferAllocator(), maximumPacketSize: 1 << 15)
let longVersionString = String(repeating: "z", count: 5000)
var version = ByteBuffer.of(string: longVersionString + "\r\n")
parser.append(bytes: &version)

XCTAssertThrowsError(try parser.nextPacket())
}
}

extension ByteBuffer {
Expand Down