From a06d789e186db61f45132a2db956e7869820678b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:18:50 +0200 Subject: [PATCH] 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. --- .../android/protocol/BinaryProtocol.kt | 23 +- .../android/protocol/CompressionUtil.kt | 81 ++++--- .../android/protocol/BinaryProtocolTest.kt | 225 ++++++++++++++++++ .../android/protocol/CompressionUtilTest.kt | 87 +++++++ 4 files changed, 373 insertions(+), 43 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/protocol/CompressionUtilTest.kt diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index a952d5fa..19946b1d 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt index e8b59254..4eb820f7 100644 --- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt +++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt @@ -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() } } diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index c1b2327b..e19457c5 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -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) { diff --git a/app/src/test/java/com/bitchat/android/protocol/CompressionUtilTest.kt b/app/src/test/java/com/bitchat/android/protocol/CompressionUtilTest.kt new file mode 100644 index 00000000..ee947fcf --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/CompressionUtilTest.kt @@ -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)) + } +}