mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
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:
parent
92d07b22fa
commit
a06d789e18
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user