Skip to content

Commit b3d0c13

Browse files
krutonclaude
andcommitted
fix(security): limit zlib decompression output to prevent DoS
ZlibCompressor.uncompress() had no bound on output size. A malicious server could send a small compressed packet (~1 KB) that expands to hundreds of MB, exhausting client memory. This is exploitable post-KEX on any connection that negotiates zlib or zlib@openssh.com compression. Cap decompressed output at MAX_UNCOMPRESSED_SIZE (512 KB) per packet and throw SshException if exceeded, causing the connection to be torn down. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6024b06 commit b3d0c13

2 files changed

Lines changed: 43 additions & 3 deletions

File tree

sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ZlibCompressor.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* ConnectBot SSH Library
3-
* Copyright 2025 Kenny Root
3+
* Copyright 2025-2026 Kenny Root
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -17,10 +17,16 @@
1717

1818
package org.connectbot.sshlib.crypto
1919

20+
import org.connectbot.sshlib.SshException
2021
import java.util.zip.Deflater
2122
import java.util.zip.Inflater
2223

2324
internal class ZlibCompressor : PacketCompressor {
25+
companion object {
26+
// Max decompressed size per packet. Prevents decompression-bomb DoS from a malicious server.
27+
internal const val MAX_UNCOMPRESSED_SIZE = 512 * 1024
28+
}
29+
2430
private val deflater = Deflater(5)
2531
private val inflater = Inflater()
2632

@@ -54,8 +60,12 @@ internal class ZlibCompressor : PacketCompressor {
5460
val count = inflater.inflate(output, totalOut, output.size - totalOut)
5561
totalOut += count
5662
if (count == 0) break
63+
if (totalOut > MAX_UNCOMPRESSED_SIZE) {
64+
throw SshException("Decompressed packet exceeds maximum allowed size ($MAX_UNCOMPRESSED_SIZE bytes)")
65+
}
5766
if (totalOut == output.size) {
58-
output = output.copyOf(output.size * 2)
67+
val newSize = minOf(output.size * 2, MAX_UNCOMPRESSED_SIZE + 1)
68+
output = output.copyOf(newSize)
5969
}
6070
}
6171

sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/ZlibCompressorTest.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* ConnectBot SSH Library
3-
* Copyright 2025 Kenny Root
3+
* Copyright 2025-2026 Kenny Root
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -17,10 +17,12 @@
1717

1818
package org.connectbot.sshlib.crypto
1919

20+
import org.connectbot.sshlib.SshException
2021
import org.junit.jupiter.api.Assertions.assertArrayEquals
2122
import org.junit.jupiter.api.Assertions.assertEquals
2223
import org.junit.jupiter.api.Test
2324
import kotlin.random.Random
25+
import kotlin.test.assertFailsWith
2426

2527
class ZlibCompressorTest {
2628

@@ -111,6 +113,34 @@ class ZlibCompressorTest {
111113
assertEquals(data.size, decompressed.size)
112114
}
113115

116+
@Test
117+
fun `rejects decompression bomb exceeding limit`() {
118+
val compressor = ZlibCompressor()
119+
val decompressor = ZlibCompressor()
120+
121+
// Highly compressible data: 1 MB of zeros compresses to ~1 KB
122+
val bomb = ByteArray(1_024 * 1_024)
123+
val compressed = compressor.compress(bomb)
124+
125+
// The compressed form is small, but decompressing it must be rejected
126+
// when the output would exceed MAX_UNCOMPRESSED_SIZE
127+
assertFailsWith<SshException> {
128+
decompressor.uncompress(compressed)
129+
}
130+
}
131+
132+
@Test
133+
fun `accepts decompressed output within limit`() {
134+
val compressor = ZlibCompressor()
135+
val decompressor = ZlibCompressor()
136+
137+
// Half of the limit — should succeed
138+
val data = ByteArray(ZlibCompressor.MAX_UNCOMPRESSED_SIZE / 2) { it.toByte() }
139+
val compressed = compressor.compress(data)
140+
val result = decompressor.uncompress(compressed)
141+
assertArrayEquals(data, result)
142+
}
143+
114144
@Test
115145
fun smallInputFitsInInitialBuffer() {
116146
val compressor = ZlibCompressor()

0 commit comments

Comments
 (0)