protocol: fix pre-auth decompression bomb (remote OOM)

The declared originalSize of a compressed packet is attacker-controlled
and was used to pre-allocate the decompression buffer before any
authentication. Two holes allowed a remote, unauthenticated peer to
force multi-GB heap allocations and crash the app:

- the compression-ratio guard was skipped entirely when the compressed
  payload was empty, so a 0-byte payload claiming a 2 GB original size
  went straight to ByteArray(originalSize)
- even when applied, a 50,000:1 ratio allowed ~43 KB of payload to
  claim ~2 GB, and catch (Throwable) swallowed the resulting
  OutOfMemoryError so the attack could repeat indefinitely

Fix:
- reject originalSize <= 0 or > MAX_PAYLOAD_LENGTH before decompressing
- reject empty compressed payloads and tighten the ratio guard to 100:1
- inflate incrementally with a hard output cap instead of
  pre-allocating the attacker-claimed size; abort if output exceeds
  the declared size
- catch Exception, not Throwable, in decodeCore so OOM is not masked

Adds regression tests covering the empty-payload bypass, oversize and
negative declared sizes, the tightened ratio, and heap-growth proofs
that 2 GB bomb inputs decode without large allocations.
This commit is contained in:
callebtc 2026-07-27 02:18:50 +02:00
parent 92d07b22fa
commit a06d789e18
4 changed files with 373 additions and 43 deletions

View File

@ -442,14 +442,21 @@ object BinaryProtocol {
buffer.get(compressedPayload)
// Security check: Compression bomb protection
if (compressedSize > 0) {
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 50_000.0) {
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
return null
}
val maxOriginalSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
if (originalSize <= 0 || originalSize > maxOriginalSize) {
Log.w("BinaryProtocol", "🚫 Invalid declared original size: $originalSize")
return null
}
if (compressedSize == 0) {
Log.w("BinaryProtocol", "🚫 Compressed payload is empty but declares originalSize=$originalSize")
return null
}
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 100.0) {
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
return null
}
// Decompress
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
} else {
@ -477,7 +484,7 @@ object BinaryProtocol {
route = route
)
} catch (e: Throwable) {
} catch (e: Exception) {
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
return null
}

View File

@ -71,49 +71,60 @@ object CompressionUtil {
/**
* Decompress deflate compressed data - exact same as iOS
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
*
* Security: never pre-allocates the attacker-claimed [originalSize];
* inflates incrementally and aborts if output exceeds [originalSize].
*/
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
if (originalSize <= 0) return null
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
return try {
inflateWithLimit(compressedData, originalSize, rawDeflate = true)
} catch (e: Exception) {
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
// Fallback: try with zlib headers in case of mixed usage
try {
inflateWithLimit(compressedData, originalSize, rawDeflate = false)
} catch (fallbackException: Exception) {
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
null
}
}
}
private fun inflateWithLimit(compressedData: ByteArray, originalSize: Int, rawDeflate: Boolean): ByteArray? {
val inflater = Inflater(rawDeflate)
try {
val inflater = Inflater(true) // true = raw deflate, no headers
inflater.setInput(compressedData)
val decompressedBuffer = ByteArray(originalSize)
val actualSize = inflater.inflate(decompressedBuffer)
inflater.end()
// Verify decompressed size matches expected (same validation as iOS)
return if (actualSize == originalSize) {
decompressedBuffer
} else if (actualSize > 0) {
// Handle case where actual size is different
decompressedBuffer.copyOfRange(0, actualSize)
val outputStream = ByteArrayOutputStream(minOf(originalSize, 8192))
val buffer = ByteArray(8192)
var total = 0
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count > 0) {
total += count
if (total > originalSize) {
Log.w("CompressionUtil", "🚫 Decompressed output exceeds declared size ($total > $originalSize)")
return null
}
outputStream.write(buffer, 0, count)
} else if (inflater.needsInput() || inflater.needsDictionary()) {
break
}
}
return if (total == originalSize) {
outputStream.toByteArray()
} else if (total > 0) {
outputStream.toByteArray()
} else {
null
}
} catch (e: Exception) {
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
// Fallback: try with zlib headers in case of mixed usage
try {
val inflater = Inflater(false) // false = expect zlib headers
inflater.setInput(compressedData)
val decompressedBuffer = ByteArray(originalSize)
val actualSize = inflater.inflate(decompressedBuffer)
inflater.end()
return if (actualSize == originalSize) {
decompressedBuffer
} else if (actualSize > 0) {
decompressedBuffer.copyOfRange(0, actualSize)
} else {
null
}
} catch (fallbackException: Exception) {
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
return null
}
} finally {
inflater.end()
}
}

View File

@ -1155,6 +1155,231 @@ class BinaryProtocolTest {
assertNull("v2 compressed with payloadLength < 4 must return null", result)
}
/**
* v2 compression bomb with EMPTY compressed payload is rejected
*
* Regression test for the decompression bomb bypass where the ratio
* guard was only applied when compressedSize > 0. A packet with a
* 0-byte compressed payload and originalSize = Int.MAX_VALUE previously
* skipped the ratio check entirely and forced a ~2 GB ByteArray
* allocation in CompressionUtil.decompress, causing a remote,
* pre-authentication OOM crash.
*
* The decoder must reject this before any allocation.
*/
@Test
fun `v2 compression bomb with empty compressed payload is rejected`() {
val compressedData = ByteArray(0)
val declaredOriginalSize = Int.MAX_VALUE // ~2 GB claim
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
// v2 header
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
// Flags: IS_COMPRESSED set
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
// Payload length (4 bytes for v2): original-size field (4 bytes) + 0 compressed bytes
buffer.putInt(4 + compressedData.size)
// SenderID (8 bytes)
buffer.put(hexToBytes(senderHex))
// Compressed payload section: original size (4 bytes for v2) + empty compressed data
buffer.putInt(declaredOriginalSize)
buffer.put(compressedData)
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
val result = BinaryProtocol.decode(padded)
assertNull("v2 bomb with empty compressed payload must be rejected", result)
}
/**
* v2 declared original size above MAX_PAYLOAD_LENGTH is rejected
*
* The declared originalSize is fully attacker-controlled. Even below
* Int.MAX_VALUE, a multi-hundred-MB claim would force a huge allocation
* per packet, enabling memory-exhaustion DoS. The decoder must reject
* any originalSize exceeding MAX_PAYLOAD_LENGTH before decompressing,
* regardless of the compression ratio.
*/
@Test
fun `v2 declared original size above MAX_PAYLOAD_LENGTH is rejected`() {
val maxPayload = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
val declaredOriginalSize = maxPayload + 1
// Large compressed payload so the ratio check alone would NOT reject it:
// ratio = (MAX+1) / (MAX/50) = 50:1 < 100:1
val compressedData = ByteArray(maxPayload / 50) { 0x55 }
val buffer = ByteBuffer.allocate(16 + 8 + 4 + compressedData.size)
.apply { order(ByteOrder.BIG_ENDIAN) }
// v2 header
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
// Flags: IS_COMPRESSED set
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
// Payload length (4 bytes for v2)
buffer.putInt(4 + compressedData.size)
// SenderID (8 bytes)
buffer.put(hexToBytes(senderHex))
// Compressed payload section
buffer.putInt(declaredOriginalSize)
buffer.put(compressedData)
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val result = BinaryProtocol.decode(raw)
assertNull("originalSize > MAX_PAYLOAD_LENGTH must be rejected even at sane ratio", result)
}
/**
* v2 negative declared original size is rejected
*
* The v2 original-size field is a signed 4-byte int read with getInt().
* 0xFFFFFFFF decodes to -1. A negative size must never reach a
* ByteArray allocation (NegativeArraySizeException) or any downstream
* logic. The decoder must reject it.
*/
@Test
fun `v2 negative declared original size is rejected`() {
val compressedData = byteArrayOf(0x03) // valid raw deflate empty block
val declaredOriginalSize = -1
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
// v2 header
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
// Flags: IS_COMPRESSED set
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
buffer.putInt(4 + compressedData.size)
// SenderID (8 bytes)
buffer.put(hexToBytes(senderHex))
buffer.putInt(declaredOriginalSize)
buffer.put(compressedData)
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
val result = BinaryProtocol.decode(padded)
assertNull("negative originalSize must be rejected", result)
}
/**
* Compression ratio above 100:1 is rejected
*
* The ratio guard was tightened from 50,000:1 to 100:1. A packet with
* ratio 200:1 previously passed the guard and could still claim up to
* ~2 GB from a ~43 KB payload (at the old limit). It must now be
* rejected. Typical legitimate text compresses ~3:1 to ~30:1.
*/
@Test
fun `compression ratio above 100 to 1 is rejected`() {
val compressedData = byteArrayOf(0x03, 0x00, 0x00, 0x00, 0x00) // 5 bytes
val declaredOriginalSize = 1000 // ratio = 200:1
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
// v2 header
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
// Flags: IS_COMPRESSED set
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
buffer.putInt(4 + compressedData.size)
// SenderID (8 bytes)
buffer.put(hexToBytes(senderHex))
buffer.putInt(declaredOriginalSize)
buffer.put(compressedData)
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
val result = BinaryProtocol.decode(padded)
assertNull("ratio 200:1 must be rejected by tightened 100:1 guard", result)
}
/**
* Decompression bomb decode completes without large allocation
*
* End-to-end proof of the fix: feeds a packet claiming a ~2 GB
* original size through decode() and verifies it returns null quickly
* without materially growing the heap. Before the fix, this input
* forced a ~2 GB ByteArray allocation (OutOfMemoryError swallowed by
* catch (Throwable)).
*/
@Test
fun `declared 2GB bomb decodes without heap growth`() {
val compressedData = ByteArray(0)
val declaredOriginalSize = Int.MAX_VALUE
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
buffer.put(2.toByte())
buffer.put(MessageType.MESSAGE.value.toByte())
buffer.put(5.toByte())
buffer.putLong(fixedTimestamp.toLong())
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
buffer.putInt(4 + compressedData.size)
buffer.put(hexToBytes(senderHex))
buffer.putInt(declaredOriginalSize)
buffer.put(compressedData)
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
System.gc()
val heapBefore = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
val result = BinaryProtocol.decode(raw)
System.gc()
val heapAfter = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
assertNull("2GB bomb must be rejected", result)
val growth = heapAfter - heapBefore
assertTrue(
"heap must not grow by more than 64 MB decoding a 2GB bomb (grew ${growth / 1_000_000} MB)",
growth < 64_000_000
)
}
private fun hexToBytes(hex: String): ByteArray {
val result = ByteArray(hex.length / 2)
for (i in result.indices) {

View File

@ -0,0 +1,87 @@
package com.bitchat.android.protocol
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CompressionUtilTest {
/**
* Valid round-trip through the streaming decompressor
*
* Compresses real data with compress() and inflates it with
* decompress(), verifying byte-exact recovery. Guards against the
* streaming rewrite breaking legitimate iOS-compatible raw-deflate
* payloads.
*/
@Test
fun `compress and decompress round-trip correctly`() {
val original = "This is a test message that should compress well. ".repeat(20).toByteArray()
val compressed = CompressionUtil.compress(original)
assertNotNull("compression must succeed for repetitive text", compressed)
val decompressed = CompressionUtil.decompress(compressed!!, original.size)
assertNotNull("decompression must succeed", decompressed)
assertArrayEquals("round-trip must be byte-exact", original, decompressed)
}
/**
* Decompression never pre-allocates the claimed original size
*
* Regression test for the decompression bomb: decompress() previously
* allocated ByteArray(originalSize) up front, so a tiny input claiming
* a ~2 GB output forced a ~2 GB allocation (remote OOM). The streaming
* implementation must inflate incrementally and fail fast on invalid
* input without material heap growth.
*/
@Test
fun `decompress with huge claimed size does not allocate`() {
val garbage = byteArrayOf(0x03) // valid raw deflate empty block, inflates to 0 bytes
System.gc()
val heapBefore = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
val result = CompressionUtil.decompress(garbage, Int.MAX_VALUE)
System.gc()
val heapAfter = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
assertNull("invalid input must return null", result)
val growth = heapAfter - heapBefore
assertTrue(
"heap must not grow by more than 64 MB for a 2GB claim (grew ${growth / 1_000_000} MB)",
growth < 64_000_000
)
}
/**
* Output exceeding the declared size is rejected
*
* If the actual inflated output exceeds the declared originalSize, the
* declared size was a lie. The decompressor must abort rather than
* return silently truncated data or keep inflating unboundedly.
*/
@Test
fun `output exceeding declared size is rejected`() {
val original = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".repeat(10).toByteArray()
val compressed = CompressionUtil.compress(original)
assertNotNull(compressed)
// Declare a size smaller than the real output
val result = CompressionUtil.decompress(compressed!!, original.size / 2)
assertNull("output exceeding declared size must be rejected", result)
}
/**
* Non-positive declared sizes are rejected
*/
@Test
fun `non-positive original size is rejected`() {
val compressed = byteArrayOf(0x03)
assertNull(CompressionUtil.decompress(compressed, 0))
assertNull(CompressionUtil.decompress(compressed, -1))
assertNull(CompressionUtil.decompress(compressed, Int.MIN_VALUE))
}
}