Skip to content

Commit 36449b2

Browse files
krutonclaude
andcommitted
fix(security): enforce local receive window on incoming channel data
A malicious server could send SSH_MSG_CHANNEL_DATA beyond the advertised local window (RFC 4254 §5.2), causing unbounded memory growth in the Channel.UNLIMITED queues (DoS) and integer overflow in the window-adjust computation when localWindowSize went negative. Extract LocalChannelWindow to centralize both local consume-and-refill and remote adjust-with-overflow-check logic, eliminating the duplication across SessionChannel, ForwardingChannel, and AgentChannel. MAX_WINDOW_SIZE now lives in LocalChannelWindow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a162807 commit 36449b2

6 files changed

Lines changed: 225 additions & 63 deletions

File tree

sshlib/src/main/kotlin/org/connectbot/sshlib/client/AgentChannel.kt

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
package org.connectbot.sshlib.client
1919

2020
import kotlinx.coroutines.channels.Channel
21-
import org.connectbot.sshlib.SshException
2221
import org.slf4j.LoggerFactory
2322

2423
internal class AgentChannel(
@@ -28,16 +27,16 @@ internal class AgentChannel(
2827
private var remoteChannelNumber: Int,
2928
private val maxPacketSize: Int,
3029
remoteWindowSizeInitial: Long,
30+
initialWindowSize: Int = 64 * 1024,
3131
) {
3232
companion object {
3333
private val logger = LoggerFactory.getLogger(AgentChannel::class.java)
34-
private const val MAX_WINDOW_SIZE = 0xFFFFFFFFL
3534
}
3635

3736
private var _isOpen = true
3837
private var closeSent = false
3938

40-
@Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial
39+
private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial)
4140
private val windowAvailable = Channel<Unit>(Channel.CONFLATED)
4241

4342
val isOpen: Boolean get() = _isOpen
@@ -47,8 +46,12 @@ internal class AgentChannel(
4746
logger.warn("Received data on closed agent channel")
4847
return
4948
}
49+
val adjust = window.consumeLocal(data.size)
5050

5151
logger.debug("Agent channel received ${data.size} bytes")
52+
if (adjust > 0) {
53+
connection.sendWindowAdjust(remoteChannelNumber, adjust)
54+
}
5255

5356
val response = handler.handleRequest(data)
5457

@@ -57,15 +60,9 @@ internal class AgentChannel(
5760
}
5861

5962
fun onWindowAdjust(bytesToAdd: Long) {
60-
if (bytesToAdd <= 0) {
61-
throw SshException("Invalid window adjust: bytesToAdd must be positive, got $bytesToAdd")
62-
}
63-
if (remoteWindowSize + bytesToAdd > MAX_WINDOW_SIZE) {
64-
throw SshException("Channel window overflow: current=$remoteWindowSize, adding=$bytesToAdd exceeds max $MAX_WINDOW_SIZE")
65-
}
66-
remoteWindowSize += bytesToAdd
67-
logger.debug("Agent channel window adjust +$bytesToAdd, remote window now $remoteWindowSize")
68-
if (remoteWindowSize > 0) {
63+
window.adjustRemote(bytesToAdd)
64+
logger.debug("Agent channel window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}")
65+
if (window.remoteRemaining > 0) {
6966
windowAvailable.trySend(Unit)
7067
}
7168
}
@@ -91,17 +88,17 @@ internal class AgentChannel(
9188
private suspend fun sendData(data: ByteArray) {
9289
var offset = 0
9390
while (offset < data.size) {
94-
while (remoteWindowSize <= 0) {
91+
while (window.remoteRemaining <= 0) {
9592
windowAvailable.receive()
9693
}
9794
val chunkSize = minOf(
9895
data.size - offset,
99-
remoteWindowSize.toInt(),
96+
window.remoteRemaining.toInt(),
10097
maxPacketSize,
10198
)
10299
val chunk = data.copyOfRange(offset, offset + chunkSize)
103100
connection.sendChannelData(remoteChannelNumber, chunk)
104-
remoteWindowSize -= chunkSize
101+
window.consumeRemote(chunkSize)
105102
offset += chunkSize
106103
}
107104
}

sshlib/src/main/kotlin/org/connectbot/sshlib/client/ForwardingChannel.kt

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package org.connectbot.sshlib.client
1919

2020
import kotlinx.coroutines.channels.Channel
2121
import kotlinx.coroutines.channels.ReceiveChannel
22-
import org.connectbot.sshlib.SshException
2322
import org.slf4j.LoggerFactory
2423

2524
internal class ForwardingChannel(
@@ -32,15 +31,11 @@ internal class ForwardingChannel(
3231
) {
3332
companion object {
3433
private val logger = LoggerFactory.getLogger(ForwardingChannel::class.java)
35-
private const val WINDOW_ADJUST_THRESHOLD = 64 * 1024
36-
private const val MAX_WINDOW_SIZE = 0xFFFFFFFFL
3734
}
3835

3936
private var _isOpen = true
4037
private var closeSent = false
41-
private var localWindowSize: Long = initialWindowSize.toLong()
42-
43-
@Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial
38+
private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial)
4439
private val windowAvailable = Channel<Unit>(Channel.CONFLATED)
4540

4641
private val _incomingData = Channel<ByteArray>(Channel.UNLIMITED)
@@ -49,25 +44,17 @@ internal class ForwardingChannel(
4944
val isOpen: Boolean get() = _isOpen
5045

5146
internal suspend fun onData(data: ByteArray) {
47+
val adjust = window.consumeLocal(data.size)
5248
_incomingData.trySend(data)
53-
localWindowSize -= data.size
54-
if (localWindowSize < WINDOW_ADJUST_THRESHOLD) {
55-
val adjust = initialWindowSize - localWindowSize.toInt()
56-
localWindowSize += adjust
49+
if (adjust > 0) {
5750
connection.sendWindowAdjust(remoteChannelNumber, adjust)
5851
}
5952
}
6053

6154
internal fun onWindowAdjust(bytesToAdd: Long) {
62-
if (bytesToAdd <= 0) {
63-
throw SshException("Invalid window adjust: bytesToAdd must be positive, got $bytesToAdd")
64-
}
65-
if (remoteWindowSize + bytesToAdd > MAX_WINDOW_SIZE) {
66-
throw SshException("Channel window overflow: current=$remoteWindowSize, adding=$bytesToAdd exceeds max $MAX_WINDOW_SIZE")
67-
}
68-
remoteWindowSize += bytesToAdd
69-
logger.debug("Forwarding channel window adjust +$bytesToAdd, remote window now $remoteWindowSize")
70-
if (remoteWindowSize > 0) {
55+
window.adjustRemote(bytesToAdd)
56+
logger.debug("Forwarding channel window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}")
57+
if (window.remoteRemaining > 0) {
7158
windowAvailable.trySend(Unit)
7259
}
7360
}
@@ -95,17 +82,17 @@ internal class ForwardingChannel(
9582
suspend fun sendData(data: ByteArray) {
9683
var offset = 0
9784
while (offset < data.size) {
98-
while (remoteWindowSize <= 0) {
85+
while (window.remoteRemaining <= 0) {
9986
windowAvailable.receive()
10087
}
10188
val chunkSize = minOf(
10289
data.size - offset,
103-
remoteWindowSize.toInt(),
90+
window.remoteRemaining.toInt(),
10491
maxPacketSize,
10592
)
10693
val chunk = data.copyOfRange(offset, offset + chunkSize)
10794
connection.sendChannelData(remoteChannelNumber, chunk)
108-
remoteWindowSize -= chunkSize
95+
window.consumeRemote(chunkSize)
10996
offset += chunkSize
11097
}
11198
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.connectbot.sshlib.client
19+
20+
import org.connectbot.sshlib.SshException
21+
22+
/**
23+
* Tracks SSH channel flow control windows per RFC 4254 §5.2.
24+
*
25+
* Local window: bytes we are willing to receive. Enforces that the server never
26+
* exceeds it; auto-refills when below [adjustThreshold].
27+
*
28+
* Remote window: bytes the server is willing to receive. Enforces uint32 max
29+
* and positive-only adjustments per the protocol.
30+
*/
31+
internal class LocalChannelWindow(
32+
private val initialSize: Int,
33+
private val adjustThreshold: Int = 16 * 1024,
34+
remoteInitial: Long = 0,
35+
) {
36+
companion object {
37+
const val MAX_WINDOW_SIZE = 0xFFFFFFFFL
38+
}
39+
40+
private var localRemaining: Long = initialSize.toLong()
41+
42+
@Volatile var remoteRemaining: Long = remoteInitial
43+
private set
44+
45+
/**
46+
* Consume [size] bytes from the local window, rejecting excess data.
47+
* Returns the window-adjust amount to send back (0 if no adjust needed).
48+
*/
49+
fun consumeLocal(size: Int): Int {
50+
if (size > localRemaining) {
51+
throw SshException("Server sent $size bytes exceeding local window ($localRemaining)")
52+
}
53+
localRemaining -= size
54+
return if (localRemaining < adjustThreshold) {
55+
val adjust = initialSize - localRemaining.toInt()
56+
localRemaining += adjust
57+
adjust
58+
} else {
59+
0
60+
}
61+
}
62+
63+
/**
64+
* Apply a window adjustment from the server, increasing the remote window.
65+
* Validates [bytesToAdd] is positive and won't overflow the uint32 max.
66+
*/
67+
fun adjustRemote(bytesToAdd: Long) {
68+
if (bytesToAdd <= 0) {
69+
throw SshException("Invalid window adjust: bytesToAdd must be positive, got $bytesToAdd")
70+
}
71+
if (remoteRemaining + bytesToAdd > MAX_WINDOW_SIZE) {
72+
throw SshException("Channel window overflow: current=$remoteRemaining, adding=$bytesToAdd exceeds max $MAX_WINDOW_SIZE")
73+
}
74+
remoteRemaining += bytesToAdd
75+
}
76+
77+
/**
78+
* Consume [size] bytes from the remote window for sending.
79+
* Caller must ensure [remoteRemaining] > 0 before calling.
80+
*/
81+
fun consumeRemote(size: Int) {
82+
remoteRemaining -= size
83+
}
84+
}

sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import kotlinx.coroutines.delay
2525
import kotlinx.coroutines.launch
2626
import kotlinx.coroutines.sync.Mutex
2727
import kotlinx.coroutines.sync.withLock
28-
import org.connectbot.sshlib.SshException
2928
import org.connectbot.sshlib.SshSession
3029
import org.connectbot.sshlib.protocol.ByteString
3130
import org.connectbot.sshlib.protocol.ChannelRequestExec
@@ -52,15 +51,11 @@ class SessionChannel internal constructor(
5251
) : SshSession {
5352
companion object {
5453
private val logger = LoggerFactory.getLogger(SessionChannel::class.java)
55-
private const val WINDOW_ADJUST_THRESHOLD = 16 * 1024
56-
private const val MAX_WINDOW_SIZE = 0xFFFFFFFFL
5754
}
5855

5956
private var _isOpen = true
6057
private var closeSent = false
61-
private var localWindowSize: Long = initialWindowSize.toLong()
62-
63-
@Volatile private var remoteWindowSize: Long = remoteWindowSizeInitial
58+
private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial)
6459
private val windowAvailable = Channel<Unit>(Channel.CONFLATED)
6560

6661
private val _stdout = Channel<ByteArray>(Channel.UNLIMITED)
@@ -88,38 +83,28 @@ class SessionChannel internal constructor(
8883
get() = ptyGranted && canSendChaff && obscureKeystrokeTimingIntervalMs > 0
8984

9085
internal suspend fun onData(data: ByteArray) {
86+
val adjust = window.consumeLocal(data.size)
9187
_stdout.trySend(data)
92-
localWindowSize -= data.size
93-
if (localWindowSize < WINDOW_ADJUST_THRESHOLD) {
94-
val adjust = initialWindowSize - localWindowSize.toInt()
95-
localWindowSize += adjust
88+
if (adjust > 0) {
9689
connection.sendWindowAdjust(_remoteChannelNumber, adjust)
9790
}
9891
}
9992

10093
internal suspend fun onExtendedData(dataType: Int, data: ByteArray) {
94+
val adjust = window.consumeLocal(data.size)
10195
_extendedData.trySend(dataType to data)
10296
if (dataType == 1) {
10397
_stderr.trySend(data)
10498
}
105-
localWindowSize -= data.size
106-
if (localWindowSize < WINDOW_ADJUST_THRESHOLD) {
107-
val adjust = initialWindowSize - localWindowSize.toInt()
108-
localWindowSize += adjust
99+
if (adjust > 0) {
109100
connection.sendWindowAdjust(_remoteChannelNumber, adjust)
110101
}
111102
}
112103

113104
internal fun onWindowAdjust(bytesToAdd: Long) {
114-
if (bytesToAdd <= 0) {
115-
throw SshException("Invalid window adjust: bytesToAdd must be positive, got $bytesToAdd")
116-
}
117-
if (remoteWindowSize + bytesToAdd > MAX_WINDOW_SIZE) {
118-
throw SshException("Channel window overflow: current=$remoteWindowSize, adding=$bytesToAdd exceeds max $MAX_WINDOW_SIZE")
119-
}
120-
remoteWindowSize += bytesToAdd
121-
logger.debug("Window adjust +$bytesToAdd, remote window now $remoteWindowSize")
122-
if (remoteWindowSize > 0) {
105+
window.adjustRemote(bytesToAdd)
106+
logger.debug("Window adjust +$bytesToAdd, remote window now ${window.remoteRemaining}")
107+
if (window.remoteRemaining > 0) {
123108
windowAvailable.trySend(Unit)
124109
}
125110
}
@@ -163,17 +148,17 @@ class SessionChannel internal constructor(
163148
private suspend fun writeDirect(data: ByteArray) {
164149
var offset = 0
165150
while (offset < data.size) {
166-
while (remoteWindowSize <= 0) {
151+
while (window.remoteRemaining <= 0) {
167152
windowAvailable.receive()
168153
}
169154
val chunkSize = minOf(
170155
data.size - offset,
171-
remoteWindowSize.toInt(),
156+
window.remoteRemaining.toInt(),
172157
maxPacketSize,
173158
)
174159
val chunk = data.copyOfRange(offset, offset + chunkSize)
175160
connection.sendChannelData(_remoteChannelNumber, chunk)
176-
remoteWindowSize -= chunkSize
161+
window.consumeRemote(chunkSize)
177162
offset += chunkSize
178163
}
179164
}

0 commit comments

Comments
 (0)